mirror of
https://github.com/alexgo-io/stacks-puppet-node.git
synced 2026-04-08 08:57:16 +08:00
1225 lines
39 KiB
Python
1225 lines
39 KiB
Python
#!/usr/bin/env python
|
|
|
|
from __future__ import print_function
|
|
|
|
"""
|
|
Blockstack-client
|
|
~~~~~
|
|
copyright: (c) 2014-2015 by Halfmoon Labs, Inc.
|
|
copyright: (c) 2016 by Blockstack.org
|
|
|
|
This file is part of Blockstack-client.
|
|
|
|
Blockstack-client is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
Blockstack-client is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
You should have received a copy of the GNU General Public License
|
|
along with Blockstack-client. If not, see <http://www.gnu.org/licenses/>.
|
|
"""
|
|
|
|
import time
|
|
import json
|
|
import os
|
|
import shutil
|
|
import virtualchain
|
|
import copy
|
|
|
|
from socket import error as socket_error
|
|
from getpass import getpass
|
|
from binascii import hexlify
|
|
import jsonschema
|
|
from jsonschema.exceptions import ValidationError
|
|
|
|
from defusedxml import xmlrpc
|
|
|
|
# prevent the usual XML attacks
|
|
xmlrpc.monkey_patch()
|
|
|
|
import logging
|
|
logging.disable(logging.CRITICAL)
|
|
|
|
import requests
|
|
requests.packages.urllib3.disable_warnings()
|
|
|
|
from .backend.crypto.utils import aes_decrypt, aes_encrypt
|
|
from .backend.blockchain import get_balance
|
|
from .utils import print_result
|
|
|
|
from .keys import *
|
|
|
|
import config
|
|
from .constants import (
|
|
WALLET_PATH, WALLET_PASSWORD_LENGTH, CONFIG_PATH,
|
|
CONFIG_DIR, CONFIG_FILENAME, WALLET_FILENAME,
|
|
BLOCKSTACK_DEBUG, BLOCKSTACK_TEST, SERIES_VERSION
|
|
)
|
|
|
|
from .proxy import get_names_owned_by_address, get_default_proxy
|
|
from .schemas import *
|
|
|
|
import virtualchain
|
|
from virtualchain.lib.ecdsalib import *
|
|
import keylib
|
|
|
|
from .logger import get_logger
|
|
|
|
log = get_logger()
|
|
|
|
|
|
def encrypt_wallet(decrypted_wallet, password, test_legacy=False):
|
|
"""
|
|
Encrypt the wallet.
|
|
Return the encrypted dict on success
|
|
Return {'error': ...} on error
|
|
"""
|
|
|
|
if test_legacy:
|
|
assert BLOCKSTACK_TEST, 'test_legacy only works in test mode'
|
|
|
|
# must be conformant to the current schema
|
|
if not test_legacy:
|
|
jsonschema.validate(decrypted_wallet, WALLET_SCHEMA_CURRENT)
|
|
|
|
owner_address = virtualchain.get_privkey_address(decrypted_wallet['owner_privkey'])
|
|
payment_address = virtualchain.get_privkey_address(decrypted_wallet['payment_privkey'])
|
|
data_pubkey = None
|
|
data_privkey_info = None
|
|
|
|
if decrypted_wallet.has_key('data_privkey'):
|
|
|
|
# make sure data key is hex encoded
|
|
data_privkey_info = decrypted_wallet.get('data_privkey', None)
|
|
if not test_legacy:
|
|
assert data_privkey_info
|
|
|
|
if data_privkey_info:
|
|
if not is_singlesig_hex(data_privkey_info):
|
|
data_privkey_info = ecdsa_private_key(data_privkey_info).to_hex()
|
|
|
|
if not virtualchain.is_singlesig(data_privkey_info):
|
|
log.error('Invalid data private key')
|
|
return {'error': 'Invalid data private key'}
|
|
|
|
data_pubkey = ecdsa_private_key(data_privkey_info).public_key().to_hex()
|
|
|
|
|
|
wallet = {
|
|
'owner_addresses': [owner_address],
|
|
'payment_addresses': decrypted_wallet['payment_addresses'],
|
|
'version': decrypted_wallet['version'],
|
|
'enc': None, # to be filled in
|
|
}
|
|
|
|
if data_pubkey:
|
|
wallet['data_pubkey'] = data_pubkey
|
|
wallet['data_pubkeys'] = [data_pubkey]
|
|
|
|
wallet_enc = {
|
|
'owner_privkey': decrypted_wallet['owner_privkey'],
|
|
'payment_privkey': decrypted_wallet['payment_privkey'],
|
|
}
|
|
|
|
if data_privkey_info:
|
|
wallet_enc['data_privkey'] = data_privkey_info
|
|
|
|
# extra sanity check: make sure that when re-combined with the wallet,
|
|
# we're still valid
|
|
recombined_wallet = copy.deepcopy(wallet)
|
|
recombined_wallet.update(wallet_enc)
|
|
try:
|
|
jsonschema.validate(recombined_wallet, WALLET_SCHEMA_CURRENT)
|
|
except ValidationError as ve:
|
|
if test_legacy:
|
|
# no data key is allowed if we're testing the absence of a data key
|
|
jsonschema.validate(recombined_wallet, WALLET_SCHEMA_CURRENT_NODATAKEY)
|
|
else:
|
|
raise
|
|
|
|
# good to go!
|
|
# encrypt secrets
|
|
wallet_secret_str = json.dumps(wallet_enc, sort_keys=True)
|
|
password_hex = hexlify(password)
|
|
encrypted_secret_str = aes_encrypt(wallet_secret_str, password_hex)
|
|
|
|
# fulfill wallet
|
|
wallet['enc'] = encrypted_secret_str
|
|
|
|
# sanity check
|
|
try:
|
|
jsonschema.validate(wallet, ENCRYPTED_WALLET_SCHEMA_CURRENT)
|
|
except ValidationError as ve:
|
|
if test_legacy:
|
|
jsonschema.validate(wallet, ENCRYPTED_WALLET_SCHEMA_CURRENT_NODATAKEY)
|
|
else:
|
|
raise
|
|
|
|
return wallet
|
|
|
|
|
|
def make_wallet(password, config_path=CONFIG_PATH, payment_privkey_info=None, owner_privkey_info=None, data_privkey_info=None, test_legacy=False, encrypt=True):
|
|
"""
|
|
Make a new, encrypted wallet structure.
|
|
The owner and payment keys will be 2-of-3 multisig key bundles.
|
|
The data keypair will be a single-key bundle.
|
|
|
|
Return the new wallet on success.
|
|
Return {'error': ...} on failure
|
|
"""
|
|
|
|
if test_legacy and not BLOCKSTACK_TEST:
|
|
raise Exception("Not in testing but tried to make a legacy wallet")
|
|
|
|
# default to 2-of-3 multisig key info if data isn't given
|
|
payment_privkey_info = virtualchain.make_multisig_wallet(2, 3) if payment_privkey_info is None and not test_legacy else payment_privkey_info
|
|
owner_privkey_info = virtualchain.make_multisig_wallet(2, 3) if owner_privkey_info is None and not test_legacy else owner_privkey_info
|
|
data_privkey_info = ecdsa_private_key().to_hex() if data_privkey_info is None and not test_legacy else data_privkey_info
|
|
|
|
decrypted_wallet = {
|
|
'owner_addresses': [virtualchain.get_privkey_address(owner_privkey_info)],
|
|
'owner_privkey': owner_privkey_info,
|
|
'payment_addresses': [virtualchain.get_privkey_address(payment_privkey_info)],
|
|
'payment_privkey': payment_privkey_info,
|
|
'data_pubkey': ecdsa_private_key(data_privkey_info).public_key().to_hex(),
|
|
'data_pubkeys': [ecdsa_private_key(data_privkey_info).public_key().to_hex()],
|
|
'data_privkey': data_privkey_info,
|
|
'version': SERIES_VERSION,
|
|
}
|
|
|
|
if not test_legacy:
|
|
jsonschema.validate(decrypted_wallet, WALLET_SCHEMA_CURRENT)
|
|
|
|
if encrypt:
|
|
encrypted_wallet = encrypt_wallet(decrypted_wallet, password, test_legacy=test_legacy)
|
|
if 'error' in encrypted_wallet:
|
|
return encrypted_wallet
|
|
|
|
# sanity check
|
|
try:
|
|
jsonschema.validate(encrypted_wallet, ENCRYPTED_WALLET_SCHEMA_CURRENT)
|
|
except ValidationError as ve:
|
|
if test_legacy:
|
|
# no data key is permitted
|
|
assert BLOCKSTACK_TEST
|
|
jsonschema.validate(encrypted_wallet, ENCRYPTED_WALLET_SCHEMA_CURRENT_NODATAKEY)
|
|
else:
|
|
raise
|
|
|
|
return encrypted_wallet
|
|
|
|
else:
|
|
return decrypted_wallet
|
|
|
|
|
|
def make_legacy_wallet_keys(data, password):
|
|
"""
|
|
Given a legacy wallet with a "master private key" (i.e. pre-0.13),
|
|
generate the owner, payment, and data key values
|
|
Return {'payment': priv, 'owner': priv, 'data': priv} on success
|
|
Return {'error': ...} on error
|
|
"""
|
|
legacy_hdwallet = None
|
|
hex_password = hexlify(password)
|
|
try:
|
|
hex_privkey = aes_decrypt(data['encrypted_master_private_key'], hex_password)
|
|
legacy_hdwallet = HDWallet(hex_privkey)
|
|
except Exception as e:
|
|
if BLOCKSTACK_DEBUG is not None:
|
|
log.exception(e)
|
|
|
|
log.debug('Failed to decrypt encrypted_master_private_key: {}'.format(ret['error']))
|
|
return ret
|
|
|
|
# legacy compat: use the master private key to generate child keys.
|
|
# If the specific key they are purposed for is not defined in the wallet,
|
|
# then they are used in its place.
|
|
# This is because originally, the master private key was used to derive
|
|
# the owner, payment, and data private keys; not all wallets define
|
|
# these keys separately (and have instead relied on us being able to
|
|
# generate them from the master private key).
|
|
# These keys were *not* compressed in the past.
|
|
child_keys = legacy_hdwallet.get_child_keypairs(count=3, include_privkey=True, compressed=False)
|
|
|
|
# note: payment_keypair = child[0]; owner_keypair = child[1]
|
|
key_defaults = {
|
|
'payment': child_keys[0][1],
|
|
'owner': child_keys[1][1],
|
|
'data': child_keys[2][1]
|
|
}
|
|
|
|
return key_defaults
|
|
|
|
|
|
def make_legacy_wallet_013_keys(data, password):
|
|
"""
|
|
Given a legacy 0.13 wallet with "owner private key" and "payment private key"
|
|
defined, generate the owner, payment, and data values.
|
|
|
|
In these wallets, the data key is the same as the owner key.
|
|
|
|
Return {'payment': priv, 'owner': priv, 'data': priv} on success
|
|
Return {'error': ...} on error
|
|
"""
|
|
payment_privkey = decrypt_private_key_info(data['encrypted_payment_privkey'], password)
|
|
owner_privkey = decrypt_private_key_info(data['encrypted_owner_privkey'], password)
|
|
|
|
err = None
|
|
if 'error' in payment_privkey:
|
|
err = payment_privkey['error']
|
|
else:
|
|
payment_privkey = payment_privkey.pop('private_key_info')
|
|
|
|
if 'error' in owner_privkey:
|
|
err = owner_privkey['error']
|
|
else:
|
|
owner_privkey = owner_privkey.pop('private_key_info')
|
|
|
|
if err:
|
|
ret = {'error': "Failed to decrypt owner and payment keys"}
|
|
log.debug("Failed to decrypt owner or payment keys: {}".format(err))
|
|
return ret
|
|
|
|
data_privkey = None
|
|
if virtualchain.is_singlesig(owner_privkey):
|
|
data_privkey = owner_privkey
|
|
else:
|
|
# data private key gets instantiated from the first owner private key,
|
|
# if we have a multisig key bundle.
|
|
data_privkey = owner_privkey['private_keys'][0]
|
|
|
|
key_defaults = {
|
|
'payment': payment_privkey,
|
|
'owner': owner_privkey,
|
|
'data': data_privkey
|
|
}
|
|
|
|
return key_defaults
|
|
|
|
|
|
def get_data_key_from_owner_key_LEGACY(owner_privkey):
|
|
"""
|
|
Given the owner private key, select a data private key to use.
|
|
|
|
THIS IS ONLY FOR LEGACY CLIENTS THAT DO NOT HAVE DATA PRIVATE KEYS
|
|
DEFINED IN THEIR WALLETS.
|
|
"""
|
|
data_privkey = None
|
|
if virtualchain.is_singlesig(owner_privkey):
|
|
data_privkey = owner_privkey
|
|
else:
|
|
# data private key gets instantiated from the first owner private key,
|
|
# if we have a multisig key bundle.
|
|
data_privkey = owner_privkey['private_keys'][0]
|
|
|
|
return data_privkey
|
|
|
|
|
|
def decrypt_wallet_legacy(data, key_defaults, password):
|
|
"""
|
|
Decrypt 0.14.1 and earlier wallets, given the wallet data, the default key values,
|
|
and the password.
|
|
|
|
Return {'status': True, 'wallet': wallet} on success
|
|
Raise on error
|
|
"""
|
|
new_wallet = {}
|
|
|
|
# NOTE: 'owner' must come before 'data', since we may use it to generate the data key
|
|
keynames = ['payment', 'owner', 'data']
|
|
for keyname in keynames:
|
|
|
|
# get the key's private key info and address
|
|
keyname_privkey = '{}_privkey'.format(keyname)
|
|
keyname_addresses = '{}_addresses'.format(keyname)
|
|
encrypted_keyname = 'encrypted_{}_privkey'.format(keyname)
|
|
|
|
if encrypted_keyname in data:
|
|
# This key was explicitly defined in the wallet.
|
|
# It is not guaranteed to be a child key of the
|
|
# master private key.
|
|
field = decrypt_private_key_info(data[encrypted_keyname], password)
|
|
|
|
if 'error' in field:
|
|
ret = {'error': "Failed to decrypt {}: {}".format(encrypted_keyname, field['error'])}
|
|
log.debug('Failed to decrypt {}: {}'.format(encrypted_keyname, field['error']))
|
|
return ret
|
|
|
|
new_wallet[keyname_privkey] = field['private_key_info']
|
|
new_wallet[keyname_addresses] = [field['address']]
|
|
|
|
else:
|
|
|
|
# Legacy migration: this key is not defined in the wallet
|
|
# use the appopriate default key
|
|
assert keyname in key_defaults, 'BUG: no legacy private key for {}'.format(keyname)
|
|
|
|
default_privkey = key_defaults[keyname]
|
|
new_wallet[keyname_privkey] = default_privkey
|
|
new_wallet[keyname_addresses] = [
|
|
virtualchain.address_reencode( keylib.ECPrivateKey(default_privkey, compressed=False).public_key().address() )
|
|
]
|
|
|
|
return {'status': True, 'wallet': new_wallet}
|
|
|
|
|
|
def decrypt_wallet_current(data, password):
|
|
"""
|
|
Given a JSON blob that represents a known-current wallet format,
|
|
decrypt it.
|
|
|
|
Return {'status': True, 'wallet': wallet} on success
|
|
Return {'error': ...} on error
|
|
"""
|
|
hex_password = hexlify(password)
|
|
payload = aes_decrypt(data['enc'], hex_password)
|
|
wallet_secrets = None
|
|
|
|
if payload is None:
|
|
return {'error': 'Failed to decrypt encrypted wallet portions'}
|
|
|
|
try:
|
|
wallet_secrets = json.loads(payload)
|
|
except ValueError:
|
|
return {'error': 'Failed to deserialize wallet secrets'}
|
|
|
|
# should be mergeable into the wallet's public components
|
|
new_wallet = copy.deepcopy(data)
|
|
del new_wallet['enc']
|
|
|
|
new_wallet.update(wallet_secrets)
|
|
|
|
try:
|
|
jsonschema.validate(new_wallet, WALLET_SCHEMA_CURRENT)
|
|
except ValidationError, ve:
|
|
# maybe one without a data key?
|
|
try:
|
|
jsonschema.validate(new_wallet, WALLET_SCHEMA_CURRENT_NODATAKEY)
|
|
except ValidationError, ve:
|
|
if BLOCKSTACK_DEBUG:
|
|
log.exception(ve)
|
|
return {'error': 'Wallet secrets do not match wallet schema'}
|
|
|
|
# no data key. Give one and revalidate.
|
|
# data key defaults to owner private key
|
|
data_privkey = get_data_key_from_owner_key_LEGACY(new_wallet['owner_privkey'])
|
|
new_wallet['data_privkey'] = data_privkey
|
|
new_wallet['data_pubkey'] = get_pubkey_hex(data_privkey)
|
|
new_wallet['data_pubkeys'] = [new_wallet['data_pubkey']]
|
|
|
|
jsonschema.validate(new_wallet, WALLET_SCHEMA_CURRENT)
|
|
|
|
return {'status': True, 'wallet': new_wallet}
|
|
|
|
|
|
def inspect_wallet_data(data):
|
|
"""
|
|
Inspect the encrypted wallet structure. Determine:
|
|
* which format it has
|
|
* whether or not it needs to be migrated
|
|
|
|
Return {'status': True, 'format': ..., 'migrate': True/False} on success
|
|
Return {'error': ...} on failure
|
|
"""
|
|
ret = {}
|
|
legacy = False
|
|
legacy_013 = False
|
|
legacy_014 = False
|
|
migrated = False
|
|
|
|
# must match either current schema or legacy schema
|
|
try:
|
|
jsonschema.validate(data, ENCRYPTED_WALLET_SCHEMA_CURRENT)
|
|
except ValidationError as ve:
|
|
# maybe legacy?
|
|
try:
|
|
jsonschema.validate(data, ENCRYPTED_WALLET_SCHEMA_LEGACY)
|
|
legacy = True
|
|
except ValidationError, ve2:
|
|
try:
|
|
jsonschema.validate(data, ENCRYPTED_WALLET_SCHEMA_LEGACY_013)
|
|
legacy_013 = True
|
|
except ValidationError, ve3:
|
|
try:
|
|
jsonschema.validate(data, ENCRYPTED_WALLET_SCHEMA_LEGACY_014)
|
|
legacy_014 = True
|
|
except ValidationError, ve4:
|
|
if BLOCKSTACK_TEST:
|
|
log.exception(ve2)
|
|
log.exception(ve3)
|
|
log.exception(ve4)
|
|
|
|
log.error('Invalid wallet data')
|
|
return {'error': 'Invalid wallet data'}
|
|
|
|
any_legacy = (legacy or legacy_013 or legacy_014)
|
|
|
|
# version check
|
|
# if the version has changed, we'll need to potentially migrate
|
|
# to e.g. trigger a re-encryption
|
|
if not data.has_key('version'):
|
|
log.debug("Wallet has no version; triggering migration")
|
|
migrated = True
|
|
|
|
elif data['version'] != SERIES_VERSION:
|
|
log.debug("Wallet series has changed from {} to {}; triggerring migration".format(data['version'], SERIES_VERSION))
|
|
migrated = True
|
|
|
|
if any_legacy:
|
|
migrated = True
|
|
|
|
wallet_format = "current"
|
|
if legacy:
|
|
wallet_format = "legacy" # pre-0.13
|
|
elif legacy_013:
|
|
wallet_format = "legacy_013"
|
|
elif legacy_014:
|
|
wallet_format = "legacy_014"
|
|
|
|
return {'status': True, 'format': wallet_format, 'migrate': migrated}
|
|
|
|
|
|
def inspect_wallet(wallet_path=None, config_path=CONFIG_PATH):
|
|
"""
|
|
Inspect a wallet file. Determine its format and whether or not we need to migrate it.
|
|
Return {'status': True, 'format': ..., 'migrate': True/False} on success
|
|
Return {'error': ...} on failure
|
|
"""
|
|
if wallet_path is None:
|
|
wallet_path = os.path.join(os.path.dirname(config_path), WALLET_FILENAME)
|
|
|
|
wallet_str = None
|
|
with open(wallet_path, 'r') as f:
|
|
wallet_str = f.read()
|
|
|
|
try:
|
|
wallet_data = json.loads(wallet_str)
|
|
except ValueError:
|
|
return {'error': 'Invalid wallet dta'}
|
|
|
|
return inspect_wallet_data(wallet_data)
|
|
|
|
|
|
def decrypt_wallet(data, password, config_path=CONFIG_PATH):
|
|
"""
|
|
Decrypt a wallet's encrypted fields. The wallet will be migrated to the current schema.
|
|
|
|
Migrate the wallet from a legacy format to the latest format, if needed.
|
|
|
|
Return {'status': True, 'migrated': True|False, 'wallet': wallet} on success.
|
|
Return {'error': ...} on failure
|
|
"""
|
|
|
|
wallet_info = inspect_wallet_data(data)
|
|
if 'error' in wallet_info:
|
|
return wallet_info
|
|
|
|
legacy = (wallet_info['format'] == 'legacy')
|
|
legacy_013 = (wallet_info['format'] == 'legacy_013')
|
|
legacy_014 = (wallet_info['format'] == 'legacy_014')
|
|
migrated = wallet_info['migrate']
|
|
|
|
any_legacy = (legacy or legacy_013 or legacy_014)
|
|
|
|
legacy_hdwallet = None
|
|
key_defaults = {}
|
|
new_wallet = {}
|
|
ret = {}
|
|
|
|
# version check
|
|
# if the version has changed, we'll need to potentially migrate
|
|
# to e.g. trigger a re-encryption
|
|
if not data.has_key('version'):
|
|
log.debug("Wallet has no version; triggering migration")
|
|
|
|
elif data['version'] != SERIES_VERSION:
|
|
log.debug("Wallet series has changed from {} to {}; triggerring migration".format(data['version'], SERIES_VERSION))
|
|
|
|
# legacy check
|
|
if legacy:
|
|
# legacy wallets use a hierarchical deterministic private key for owner, payment, and data keys.
|
|
# get that key first, if needed.
|
|
key_defaults = make_legacy_wallet_keys(data, password)
|
|
if 'error' in key_defaults:
|
|
log.error("Failed to migrate legacy wallet: {}".format(key_defaults['error']))
|
|
return key_defaults
|
|
|
|
elif legacy_013:
|
|
# legacy 0.13 wallets have an owner_privkey and a payment_privkey, but not a data_privkey
|
|
key_defaults = make_legacy_wallet_013_keys(data, password)
|
|
if 'error' in key_defaults:
|
|
log.error("Failed to migrate legacy 0.13 wallet: {}".format(key_defaults['error']))
|
|
return key_defaults
|
|
|
|
if any_legacy:
|
|
wallet_info = decrypt_wallet_legacy(data, key_defaults, password)
|
|
|
|
else:
|
|
wallet_info = decrypt_wallet_current(data, password)
|
|
|
|
# No matter what we do, do not save this wallet if it is current.
|
|
# First, it's not necessary if the wallet is not legacy.
|
|
# Second, the data private key is dynamically filled-in for data-key-less wallets,
|
|
# and we do not want to preserve this (i.e. we want the user to select a data key
|
|
# and switch over to using it).
|
|
migrated = False
|
|
|
|
if 'error' in wallet_info:
|
|
log.error("Failed to decrypt wallet; {}".format(wallet_info['error']))
|
|
return {'error': 'Failed to decrypt wallet'}
|
|
|
|
new_wallet = wallet_info['wallet']
|
|
|
|
# post-decryption formatting
|
|
# make sure data key is an uncompressed public key
|
|
assert new_wallet.has_key('data_privkey')
|
|
data_pubkey = ecdsa_private_key(str(new_wallet['data_privkey'])).public_key().to_hex()
|
|
if keylib.key_formatting.get_pubkey_format(data_pubkey) == 'hex_compressed':
|
|
data_pubkey = keylib.key_formatting.decompress(data_pubkey)
|
|
|
|
data_pubkey = str(data_pubkey)
|
|
|
|
new_wallet['data_pubkeys'] = [data_pubkey]
|
|
new_wallet['data_pubkey'] = data_pubkey
|
|
|
|
# pass along version
|
|
new_wallet['version'] = SERIES_VERSION
|
|
|
|
# sanity check--must be decrypted properly
|
|
try:
|
|
jsonschema.validate(new_wallet, WALLET_SCHEMA_CURRENT)
|
|
except ValidationError as e:
|
|
log.exception(e)
|
|
log.error("FATAL: BUG: invalid wallet generated")
|
|
os.abort()
|
|
|
|
ret = {
|
|
'status': True,
|
|
'wallet': new_wallet,
|
|
'migrated': migrated
|
|
}
|
|
|
|
return ret
|
|
|
|
|
|
def write_wallet(data, path=None, config_path=CONFIG_PATH, test_legacy=False):
|
|
"""
|
|
Generate and save the wallet to disk.
|
|
"""
|
|
config_dir = os.path.dirname(config_path)
|
|
if path is None:
|
|
path = os.path.join(config_dir, WALLET_FILENAME)
|
|
|
|
if test_legacy:
|
|
assert BLOCKSTACK_TEST, 'test_legacy only works in test mode'
|
|
|
|
if not test_legacy:
|
|
# must be a current schema
|
|
try:
|
|
jsonschema.validate(data, ENCRYPTED_WALLET_SCHEMA_CURRENT)
|
|
except ValidationError as ve:
|
|
if test_legacy:
|
|
# allow no-data-key wallets
|
|
jsonschema.validate(data, ENCRYPTED_WALLET_SCHEMA_CURRENT_NODATAKEY)
|
|
|
|
else:
|
|
if BLOCKSTACK_DEBUG:
|
|
log.exception(ve)
|
|
|
|
return {'error': 'Invalid wallet data'}
|
|
|
|
data = json.dumps(data)
|
|
with open(path, 'w') as f:
|
|
f.write(data)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
|
|
return {'status': True}
|
|
|
|
|
|
def make_wallet_password(prompt=None, password=None):
|
|
"""
|
|
Make a wallet password:
|
|
prompt for a wallet, and ensure it's the right length.
|
|
If @password is not None, verify that it's the right length.
|
|
Return {'status': True, 'password': ...} on success
|
|
Return {'error': ...} on error
|
|
"""
|
|
if password is not None and password:
|
|
if len(password) < WALLET_PASSWORD_LENGTH:
|
|
msg = 'Password not long enough ({}-character minimum)'
|
|
return {'error': msg.format(WALLET_PASSWORD_LENGTH)}
|
|
return {'status': True, 'password': password}
|
|
|
|
if prompt:
|
|
print(prompt)
|
|
|
|
p1 = getpass('Enter new password: ')
|
|
p2 = getpass('Confirm new password: ')
|
|
if p1 != p2:
|
|
return {'error': 'Passwords do not match'}
|
|
|
|
if len(p1) < WALLET_PASSWORD_LENGTH:
|
|
msg = 'Password not long enough ({}-character minimum)'
|
|
return {'error': msg.format(WALLET_PASSWORD_LENGTH)}
|
|
|
|
return {'status': True, 'password': p1}
|
|
|
|
|
|
def initialize_wallet(password='', wallet_path=None, interactive=True, config_dir=CONFIG_DIR):
|
|
"""
|
|
Initialize a wallet, interatively if need be.
|
|
Save it to @wallet_path, if successfully generated.
|
|
|
|
Return {'status': True, 'wallet': ..., 'wallet_password': ...} on success.
|
|
Return {'error': ...} on error
|
|
"""
|
|
|
|
config_path = os.path.join(config_dir, CONFIG_FILENAME)
|
|
wallet_path = os.path.join(config_dir, WALLET_FILENAME) if wallet_path is None else wallet_path
|
|
|
|
if not interactive and not password:
|
|
msg = ('Non-interactive wallet initialization '
|
|
'requires a password of length {} or greater')
|
|
raise Exception(msg.format(WALLET_PASSWORD_LENGTH))
|
|
|
|
result = {}
|
|
|
|
try:
|
|
if interactive:
|
|
print('Initializing new wallet ...')
|
|
while password is None or len(password) < WALLET_PASSWORD_LENGTH:
|
|
res = make_wallet_password(password)
|
|
if 'error' in res:
|
|
print(res['error'])
|
|
continue
|
|
|
|
password = res['password']
|
|
break
|
|
|
|
wallet = make_wallet(password, config_path=config_path)
|
|
if 'error' in wallet:
|
|
log.error('make_wallet failed: {}'.format(wallet['error']))
|
|
return wallet
|
|
|
|
try:
|
|
write_wallet(wallet, path=wallet_path)
|
|
except Exception as e:
|
|
log.exception(e)
|
|
return {'error': 'Failed to write wallet'}
|
|
|
|
result['status'] = True
|
|
result['wallet'] = wallet
|
|
result['wallet_password'] = password
|
|
|
|
if not interactive:
|
|
return result
|
|
|
|
if interactive:
|
|
print('Wallet created. Make sure to backup the following:')
|
|
output = {
|
|
'wallet_password': password,
|
|
'wallet': wallet
|
|
}
|
|
print_result(output)
|
|
|
|
input_prompt = 'Have you backed up the above information? (y/n): '
|
|
user_input = raw_input(input_prompt)
|
|
user_input = user_input.lower()
|
|
|
|
if user_input != 'y':
|
|
return {'error': 'Please back up your private key first'}
|
|
|
|
except KeyboardInterrupt:
|
|
return {'error': 'Interrupted'}
|
|
|
|
return result
|
|
|
|
|
|
def get_wallet_path(config_path=CONFIG_PATH):
|
|
"""
|
|
Get the path to the wallet
|
|
"""
|
|
return os.path.join( os.path.dirname(config_path), WALLET_FILENAME )
|
|
|
|
|
|
def wallet_exists(config_path=CONFIG_PATH, wallet_path=None):
|
|
"""
|
|
Does a wallet exist?
|
|
Return True if so
|
|
Return False if not
|
|
"""
|
|
config_dir = os.path.dirname(config_path)
|
|
if wallet_path is None:
|
|
wallet_path = os.path.join(config_dir, WALLET_FILENAME)
|
|
|
|
return os.path.exists(wallet_path)
|
|
|
|
|
|
def prompt_wallet_password(prompt='Enter wallet password: '):
|
|
"""
|
|
Get the wallet password from the user
|
|
"""
|
|
password = getpass(prompt)
|
|
return password
|
|
|
|
|
|
def load_wallet(password=None, config_path=CONFIG_PATH, wallet_path=None, interactive=True, include_private=False):
|
|
"""
|
|
Get a wallet from disk, and unlock it.
|
|
Requries either a password, or interactive=True
|
|
Return {'status': True, 'migrated': ..., 'wallet': ..., 'password': ...} on success
|
|
Return {'error': ...} on error
|
|
"""
|
|
config_dir = os.path.dirname(config_path)
|
|
if wallet_path is None:
|
|
wallet_path = os.path.join(config_dir, WALLET_FILENAME)
|
|
|
|
if password is None:
|
|
password = prompt_wallet_password()
|
|
|
|
if not os.path.exists(wallet_path):
|
|
return {'error': 'No wallet found'}
|
|
|
|
with open(wallet_path, 'r') as f:
|
|
data = f.read()
|
|
data = json.loads(data)
|
|
|
|
res = decrypt_wallet(data, password, config_path=config_path)
|
|
if 'error' in res:
|
|
return res
|
|
|
|
res['password'] = password
|
|
return res
|
|
|
|
|
|
def backup_wallet(wallet_path):
|
|
"""
|
|
Given the path to an on-disk wallet, back it up.
|
|
Return the new path, or None if there is no such wallet.
|
|
"""
|
|
if not os.path.exists(wallet_path):
|
|
return None
|
|
|
|
legacy_path = wallet_path + ".legacy.{}".format(int(time.time()))
|
|
while os.path.exists(legacy_path):
|
|
time.sleep(1.0)
|
|
legacy_path = wallet_path + ".legacy.{}".format(int(time.time()))
|
|
|
|
log.warning('Back up old wallet from {} to {}'.format(wallet_path, legacy_path))
|
|
shutil.move(wallet_path, legacy_path)
|
|
return legacy_path
|
|
|
|
|
|
def migrate_wallet(password=None, config_path=CONFIG_PATH):
|
|
"""
|
|
Migrate the wallet to the latest format.
|
|
Back up the old wallet.
|
|
|
|
Return {'status': True, 'backup_wallet': ..., 'wallet': ..., 'wallet_password': ..., 'migrated': True} on success
|
|
Return {'status': True, 'wallet': ..., 'wallet_password': ..., 'migrated': False} if no migration was necessary.
|
|
Return {'error': ...} on error
|
|
"""
|
|
config_dir = os.path.dirname(config_path)
|
|
wallet_path = os.path.join(config_dir, WALLET_FILENAME)
|
|
|
|
wallet_info = load_wallet(password=password, wallet_path=wallet_path, config_path=config_path, include_private=True)
|
|
if 'error' in wallet_info:
|
|
return wallet_info
|
|
|
|
wallet = wallet_info['wallet']
|
|
password = wallet_info['password']
|
|
|
|
if not wallet_info['migrated']:
|
|
return {'status': True, 'migrated': False, 'wallet': wallet, 'wallet_password': password}
|
|
|
|
encrypted_wallet = encrypt_wallet(wallet, password)
|
|
if 'error' in encrypted_wallet:
|
|
return encrypted_wallet
|
|
|
|
# back up
|
|
old_path = backup_wallet(wallet_path)
|
|
|
|
# store
|
|
res = write_wallet(encrypted_wallet, path=wallet_path)
|
|
if not res:
|
|
# try to restore
|
|
shutil.copy(old_path, wallet_path)
|
|
return {'error': 'Failed to store migrated wallet.'}
|
|
|
|
return {'status': True, 'migrated': True, 'backup_wallet': old_path, 'wallet': wallet, 'wallet_password': password}
|
|
|
|
|
|
def unlock_wallet(password=None, config_dir=CONFIG_DIR, wallet_path=None):
|
|
"""
|
|
Unlock the wallet, and store it to the running RPC daemon.
|
|
|
|
This will only work if the wallet is in the latest supported state.
|
|
Otherwise, the caller may need to migrate the wallet first.
|
|
|
|
Return {'status': True} on success
|
|
return {'error': ...} on error
|
|
"""
|
|
config_path = os.path.join(config_dir, CONFIG_FILENAME)
|
|
wallet_path = os.path.join(config_dir, WALLET_FILENAME) if wallet_path is None else wallet_path
|
|
|
|
if is_wallet_unlocked(config_dir):
|
|
return {'status': True}
|
|
|
|
try:
|
|
if password is None:
|
|
password = prompt_wallet_password()
|
|
|
|
with open(wallet_path, "r") as f:
|
|
data = f.read()
|
|
data = json.loads(data)
|
|
|
|
# decrypt...
|
|
wallet_info = decrypt_wallet( data, password, config_path=config_path )
|
|
if 'error' in wallet_info:
|
|
log.error('Failed to decrypt wallet: {}'.format(wallet_info['error']))
|
|
return wallet_info
|
|
|
|
wallet = wallet_info['wallet']
|
|
if wallet_info['migrated']:
|
|
# need to have the user migrate the wallet first
|
|
return {'error': 'Wallet is in legacy format. Please migrate it with the `setup_wallet` command.', 'legacy': True}
|
|
|
|
# save to RPC daemon
|
|
try:
|
|
res = save_keys_to_memory( wallet, config_path=config_path )
|
|
except KeyError as ke:
|
|
if BLOCKSACK_DEBUG is not None:
|
|
data = json.dumps(wallet, indent=4, sort_keys=True)
|
|
log.error('data:\n{}\n'.format(data))
|
|
raise
|
|
|
|
if 'error' in res:
|
|
return res
|
|
|
|
addresses = {
|
|
'payment_address': virtualchain.address_reencode(wallet['payment_addresses'][0]),
|
|
'owner_address': virtualchain.address_reencode(wallet['owner_addresses'][0]),
|
|
'data_pubkey': virtualchain.address_reencode(wallet['data_pubkeys'][0])
|
|
}
|
|
|
|
return {'status': True, 'addresses': addresses}
|
|
except KeyboardInterrupt:
|
|
return {'error': 'Interrupted'}
|
|
|
|
|
|
def is_wallet_unlocked(config_dir=CONFIG_DIR):
|
|
"""
|
|
Determine whether or not the wallet is unlocked.
|
|
Do so by asking the local RPC backend daemon
|
|
"""
|
|
from .rpc import local_api_connect
|
|
|
|
config_path = os.path.join(config_dir, CONFIG_FILENAME)
|
|
local_proxy = local_api_connect(config_path=config_path)
|
|
conf = config.get_config(config_path)
|
|
|
|
if not local_proxy:
|
|
return False
|
|
|
|
try:
|
|
wallet_data = local_proxy.backend_get_wallet()
|
|
except (IOError, OSError):
|
|
return False
|
|
except Exception as e:
|
|
log.exception(e)
|
|
return False
|
|
|
|
if 'error' in wallet_data:
|
|
return False
|
|
|
|
return wallet_data['payment_address'] is not None
|
|
|
|
|
|
def get_wallet(config_path=CONFIG_PATH):
|
|
"""
|
|
Get the decrypted wallet from the running RPC backend daemon.
|
|
Returns the wallet data on success
|
|
Returns None on error
|
|
"""
|
|
from .rpc import local_api_connect
|
|
|
|
local_proxy = local_api_connect(config_path=config_path)
|
|
conf = config.get_config(config_path)
|
|
|
|
if not local_proxy:
|
|
return None
|
|
|
|
try:
|
|
wallet_data = local_proxy.backend_get_wallet()
|
|
if 'error' in wallet_data:
|
|
msg = 'RPC error: {}'
|
|
log.error(msg.format(wallet_data['error']))
|
|
raise Exception(msg.format(wallet_data['error']))
|
|
except Exception as e:
|
|
log.exception(e)
|
|
return {'error': 'Failed to get wallet'}
|
|
|
|
if 'error' in wallet_data:
|
|
return None
|
|
|
|
return wallet_data
|
|
|
|
|
|
def get_names_owned(address, proxy=None):
|
|
"""
|
|
Get names owned by address
|
|
"""
|
|
|
|
proxy = get_default_proxy() if proxy is None else proxy
|
|
|
|
try:
|
|
names_owned = get_names_owned_by_address(address, proxy=proxy)
|
|
except socket_error:
|
|
names_owned = 'Error connecting to server'
|
|
|
|
return names_owned
|
|
|
|
|
|
def save_keys_to_memory( wallet_keys, config_path=CONFIG_PATH ):
|
|
"""
|
|
Save keys to the running RPC backend
|
|
Each keypair must be a list or tuple with 2 items: the address, and the private key information.
|
|
(Note that the private key information can be a multisig info dict).
|
|
|
|
Return {'status': True} on success
|
|
Return {'error': ...} on error
|
|
"""
|
|
from .rpc import local_api_connect
|
|
|
|
proxy = local_api_connect(config_path=config_path)
|
|
|
|
log.debug('Saving keys to memory')
|
|
try:
|
|
data = proxy.backend_set_wallet(wallet_keys)
|
|
return data
|
|
except Exception as e:
|
|
log.exception(e)
|
|
return {'error': 'Failed to save keys'}
|
|
|
|
return
|
|
|
|
|
|
def get_addresses_from_file(config_dir=CONFIG_DIR, wallet_path=None):
|
|
"""
|
|
Load up the set of addresses from the wallet
|
|
Not all fields may be set in older wallets.
|
|
"""
|
|
|
|
data_pubkey = None
|
|
payment_address = None
|
|
owner_address = None
|
|
|
|
if wallet_path is None:
|
|
wallet_path = os.path.join(config_dir, WALLET_FILENAME)
|
|
|
|
if not os.path.exists(wallet_path):
|
|
log.error('No such file or directory: {}'.format(wallet_path))
|
|
return None, None, None
|
|
|
|
with open(wallet_path, 'r') as f:
|
|
data = f.read()
|
|
|
|
try:
|
|
data = json.loads(data)
|
|
|
|
# best we can do is guarantee that this is a dict
|
|
assert isinstance(data, dict)
|
|
except:
|
|
log.error('Invalid wallet data: not a JSON object (in {})'.format(wallet_path))
|
|
return None, None, None
|
|
|
|
# extract addresses
|
|
if data.has_key('payment_addresses'):
|
|
payment_address = virtualchain.address_reencode(str(data['payment_addresses'][0]))
|
|
if data.has_key('owner_addresses'):
|
|
owner_address = virtualchain.address_reencode(str(data['owner_addresses'][0]))
|
|
if data.has_key('data_pubkeys'):
|
|
data_pubkey = str(data['data_pubkeys'][0])
|
|
|
|
return payment_address, owner_address, data_pubkey
|
|
|
|
|
|
def get_payment_addresses_and_balances(config_path=CONFIG_PATH, wallet_path=None):
|
|
"""
|
|
Get payment addresses and balances.
|
|
Each payment address will have a balance in satoshis.
|
|
Returns [{'address', 'balance'}] on success
|
|
If the wallet is a legacy wallet, returns [{'error': ...}]
|
|
"""
|
|
config_dir = os.path.dirname(config_path)
|
|
if wallet_path is None:
|
|
wallet_path = os.path.join(config_dir, WALLET_FILENAME)
|
|
|
|
payment_addresses = []
|
|
|
|
# currently only using one
|
|
payment_address, owner_address, data_pubkey = (
|
|
get_addresses_from_file(wallet_path=wallet_path)
|
|
)
|
|
|
|
if payment_address is not None:
|
|
balance = get_balance(payment_address, config_path=config_path)
|
|
if balance is None:
|
|
payment_addresses.append( {'error': 'Failed to get balance for {}'.format(payment_address)} )
|
|
|
|
else:
|
|
payment_addresses.append({'address': payment_address,
|
|
'balance': balance})
|
|
|
|
else:
|
|
payment_addresses.append({'error': 'Legacy wallet; payment address is not visible'})
|
|
|
|
return payment_addresses
|
|
|
|
|
|
def get_owner_addresses_and_names(wallet_path=WALLET_PATH):
|
|
"""
|
|
Get owner addresses
|
|
"""
|
|
owner_addresses = []
|
|
|
|
# currently only using one
|
|
payment_address, owner_address, data_pubkey = (
|
|
get_addresses_from_file(wallet_path=wallet_path)
|
|
)
|
|
|
|
if owner_address is not None:
|
|
owner_addresses.append({'address': owner_address,
|
|
'names_owned': get_names_owned(owner_address)})
|
|
else:
|
|
owner_addresses.append({'error': 'Legacy wallet; owner address is not visible'})
|
|
|
|
return owner_addresses
|
|
|
|
|
|
def get_all_names_owned(wallet_path=WALLET_PATH):
|
|
"""
|
|
Get back the list of all names owned by the given wallet.
|
|
Return [names] on success
|
|
Return [{'error': ...}] on failure
|
|
"""
|
|
owner_addresses = get_owner_addresses_and_names(wallet_path)
|
|
names_owned = []
|
|
|
|
for entry in owner_addresses:
|
|
if 'address' in entry.keys():
|
|
additional_names = get_names_owned(entry['address'])
|
|
for name in additional_names:
|
|
names_owned.append(name)
|
|
|
|
elif 'error' in entry.keys():
|
|
# failed to get owner address
|
|
return [entry]
|
|
|
|
return names_owned
|
|
|
|
|
|
def get_total_balance(config_path=CONFIG_PATH, wallet_path=WALLET_PATH):
|
|
"""
|
|
Get the total balance for the wallet's payment address.
|
|
Units will be in satoshis.
|
|
|
|
Returns units, addresses on success
|
|
Returns None, {'error': ...} on error
|
|
"""
|
|
payment_addresses = get_payment_addresses_and_balances(wallet_path=wallet_path, config_path=config_path)
|
|
total_balance = 0.0
|
|
|
|
for entry in payment_addresses:
|
|
if 'balance' in entry.keys():
|
|
total_balance += entry['balance']
|
|
|
|
elif 'error' in entry:
|
|
# failed to look up
|
|
return None, entry
|
|
|
|
return total_balance, payment_addresses
|
|
|
|
|
|
def wallet_setup(config_path=CONFIG_PATH, interactive=True, wallet_data=None, wallet_path=None, password=None, test_legacy=False):
|
|
"""
|
|
Do one-time wallet setup.
|
|
* make sure the wallet exists (creating it if need be)
|
|
* migrate the wallet if it is in legacy format
|
|
|
|
Return {'status': True, 'created': False, 'migrated': False, 'password': ..., 'wallet'; ...} on success
|
|
Return {'status': True, 'created'; True, 'migrated': False, 'password': ..., 'wallet': ...} if we had to create the wallet
|
|
Return {'status': True, 'created': False, 'migrated': True, 'password': ..., 'wallet': ...} if we had to migrate the wallet
|
|
Optionally also include 'backup_wallet': ... if the wallet was migrated
|
|
"""
|
|
|
|
config_dir = os.path.dirname(config_path)
|
|
if wallet_path is None:
|
|
wallet_path = os.path.join(config_dir, WALLET_FILENAME)
|
|
|
|
wallet = None
|
|
created = False
|
|
migrated = False
|
|
backup_path = None
|
|
|
|
if not wallet_exists(wallet_path=wallet_path):
|
|
# create
|
|
if wallet_data is None:
|
|
res = initialize_wallet(wallet_path=wallet_path, password=password, interactive=interactive)
|
|
if 'error' in res:
|
|
return res
|
|
|
|
created = True
|
|
password = res['wallet_password']
|
|
wallet = res['wallet']
|
|
|
|
else:
|
|
# make sure up-to-date
|
|
wallet = wallet_data
|
|
encrypted_wallet = encrypt_wallet(wallet, password, test_legacy=test_legacy)
|
|
if 'error' in encrypted_wallet:
|
|
return encrypted_wallet
|
|
|
|
res = decrypt_wallet(encrypted_wallet, password, config_path=config_path)
|
|
if 'error' in res:
|
|
return res
|
|
|
|
wallet = res['wallet']
|
|
migrated = res['migrated']
|
|
|
|
res = write_wallet(wallet, path=wallet_path, test_legacy=test_legacy)
|
|
if 'error' in res:
|
|
return res
|
|
|
|
if not created:
|
|
# try to migrate
|
|
res = migrate_wallet(password=password, config_path=config_path)
|
|
if 'error' in res:
|
|
return res
|
|
|
|
if res['migrated']:
|
|
migrated = True
|
|
|
|
password = res['wallet_password']
|
|
wallet = res['wallet']
|
|
backup_path = res.get('backup_wallet', None)
|
|
|
|
res = {
|
|
'status': True,
|
|
'migrated': migrated,
|
|
'created': created,
|
|
'wallet': wallet,
|
|
'password': password,
|
|
}
|
|
|
|
if backup_path:
|
|
res['backup_wallet'] = backup_path
|
|
|
|
return res
|
|
|