diff --git a/lbry/extras/daemon/daemon.py b/lbry/extras/daemon/daemon.py index ab38e8ebc..a497a516a 100644 --- a/lbry/extras/daemon/daemon.py +++ b/lbry/extras/daemon/daemon.py @@ -17,7 +17,6 @@ from binascii import hexlify, unhexlify from traceback import format_exc from functools import wraps, partial -import ecdsa import base58 from aiohttp import web from prometheus_client import generate_latest as prom_generate_latest, Gauge, Histogram, Counter @@ -29,6 +28,7 @@ from lbry.wallet import ( ) from lbry.wallet.dewies import dewies_to_lbc, lbc_to_dewies, dict_values_to_lbc from lbry.wallet.constants import TXO_TYPES, CLAIM_TYPE_NAMES +from lbry.wallet.bip32 import PrivateKey from lbry import utils from lbry.conf import Config, Setting, NOT_SET @@ -3041,7 +3041,7 @@ class Daemon(metaclass=JSONRPCServerType): 'channel_id': channel.claim_id, 'holding_address': address, 'holding_public_key': public_key.extended_key_string(), - 'signing_private_key': channel.private_key.to_pem().decode() + 'signing_private_key': channel.private_key.signing_key.to_pem().decode() } return base58.b58encode(json.dumps(export, separators=(',', ':'))) @@ -3064,15 +3064,14 @@ class Daemon(metaclass=JSONRPCServerType): decoded = base58.b58decode(channel_data) data = json.loads(decoded) - channel_private_key = ecdsa.SigningKey.from_pem( - data['signing_private_key'], hashfunc=hashlib.sha256 + channel_private_key = PrivateKey.from_pem( + self.ledger, data['signing_private_key'] ) - public_key_der = channel_private_key.get_verifying_key().to_der() # check that the holding_address hasn't changed since the export was made holding_address = data['holding_address'] channels, _, _, _ = await self.ledger.claim_search( - wallet.accounts, public_key_id=self.ledger.public_key_to_address(public_key_der) + wallet.accounts, public_key_id=channel_private_key.address ) if channels and channels[0].get_address(self.ledger) != holding_address: holding_address = channels[0].get_address(self.ledger) diff --git a/lbry/extras/daemon/json_response_encoder.py b/lbry/extras/daemon/json_response_encoder.py index 7e56770be..c75e7d512 100644 --- a/lbry/extras/daemon/json_response_encoder.py +++ b/lbry/extras/daemon/json_response_encoder.py @@ -10,7 +10,7 @@ from lbry.schema.claim import Claim from lbry.schema.support import Support from lbry.torrent.torrent_manager import TorrentSource from lbry.wallet import Wallet, Ledger, Account, Transaction, Output -from lbry.wallet.bip32 import PubKey +from lbry.wallet.bip32 import PublicKey from lbry.wallet.dewies import dewies_to_lbc from lbry.stream.managed_stream import ManagedStream @@ -138,7 +138,7 @@ class JSONResponseEncoder(JSONEncoder): return self.encode_claim(obj) if isinstance(obj, Support): return obj.to_dict() - if isinstance(obj, PubKey): + if isinstance(obj, PublicKey): return obj.extended_key_string() if isinstance(obj, datetime): return obj.strftime("%Y%m%dT%H:%M:%S") diff --git a/lbry/schema/claim.py b/lbry/schema/claim.py index b75945480..53565c1e1 100644 --- a/lbry/schema/claim.py +++ b/lbry/schema/claim.py @@ -2,6 +2,9 @@ import logging from typing import List from binascii import hexlify, unhexlify +from asn1crypto.keys import PublicKeyInfo +from coincurve import PublicKey as cPublicKey + from google.protobuf.json_format import MessageToDict from google.protobuf.message import DecodeError from hachoir.core.log import log as hachoir_log @@ -346,7 +349,7 @@ class Channel(BaseClaim): @property def public_key(self) -> str: - return hexlify(self.message.public_key).decode() + return hexlify(self.public_key_bytes).decode() @public_key.setter def public_key(self, sd_public_key: str): @@ -354,7 +357,11 @@ class Channel(BaseClaim): @property def public_key_bytes(self) -> bytes: - return self.message.public_key + if len(self.message.public_key) == 33: + return self.message.public_key + public_key_info = PublicKeyInfo.load(self.message.public_key) + public_key = cPublicKey(public_key_info.native['public_key']) + return public_key.format(compressed=True) @public_key_bytes.setter def public_key_bytes(self, public_key: bytes): diff --git a/lbry/wallet/account.py b/lbry/wallet/account.py index a6f2f795e..06af360d3 100644 --- a/lbry/wallet/account.py +++ b/lbry/wallet/account.py @@ -9,11 +9,10 @@ from hashlib import sha256 from string import hexdigits from typing import Type, Dict, Tuple, Optional, Any, List -import ecdsa from lbry.error import InvalidPasswordError from lbry.crypto.crypt import aes_encrypt, aes_decrypt -from .bip32 import PrivateKey, PubKey, from_extended_key_string +from .bip32 import PrivateKey, PublicKey, KeyPath, from_extended_key_string from .mnemonic import Mnemonic from .constants import COIN, TXO_TYPES from .transaction import Transaction, Input, Output @@ -36,44 +35,39 @@ def validate_claim_id(claim_id): class DeterministicChannelKeyManager: - def __init__(self, account): + def __init__(self, account: 'Account'): self.account = account - self.public_key = account.public_key.child(2) - self.private_key = account.private_key.child(2) if account.private_key else None self.last_known = 0 self.cache = {} + self.private_key: Optional[PrivateKey] = None + if account.private_key is not None: + self.private_key = account.private_key.child(KeyPath.CHANNEL) def maybe_generate_deterministic_key_for_channel(self, txo): if self.private_key is None: return - next_key = self.private_key.child(self.last_known) - signing_key = ecdsa.SigningKey.from_secret_exponent( - next_key.secret_exponent(), ecdsa.SECP256k1 - ) - public_key_bytes = signing_key.get_verifying_key().to_der() + next_private_key = self.private_key.child(self.last_known) + public_key = next_private_key.public_key + public_key_bytes = public_key.pubkey_bytes if txo.claim.channel.public_key_bytes == public_key_bytes: - self.cache[self.account.ledger.public_key_to_address(public_key_bytes)] = signing_key + self.cache[public_key.address] = next_private_key self.last_known += 1 async def ensure_cache_primed(self): if self.private_key is not None: await self.generate_next_key() - async def generate_next_key(self) -> ecdsa.SigningKey: + async def generate_next_key(self) -> PrivateKey: db = self.account.ledger.db while True: - next_key = self.private_key.child(self.last_known) - signing_key = ecdsa.SigningKey.from_secret_exponent( - next_key.secret_exponent(), ecdsa.SECP256k1 - ) - public_key_bytes = signing_key.get_verifying_key().to_der() - key_address = self.account.ledger.public_key_to_address(public_key_bytes) - self.cache[key_address] = signing_key - if not await db.is_channel_key_used(self.account.wallet, signing_key): - return signing_key + next_private_key = self.private_key.child(self.last_known) + public_key = next_private_key.public_key + self.cache[public_key.address] = next_private_key + if not await db.is_channel_key_used(self.account, public_key): + return next_private_key self.last_known += 1 - def get_private_key_from_pubkey_hash(self, pubkey_hash) -> ecdsa.SigningKey: + def get_private_key_from_pubkey_hash(self, pubkey_hash) -> PrivateKey: return self.cache.get(pubkey_hash) @@ -122,7 +116,7 @@ class AddressManager: def get_private_key(self, index: int) -> PrivateKey: raise NotImplementedError - def get_public_key(self, index: int) -> PubKey: + def get_public_key(self, index: int) -> PublicKey: raise NotImplementedError async def get_max_gap(self): @@ -162,8 +156,8 @@ class HierarchicalDeterministic(AddressManager): @classmethod def from_dict(cls, account: 'Account', d: dict) -> Tuple[AddressManager, AddressManager]: return ( - cls(account, 0, **d.get('receiving', {'gap': 20, 'maximum_uses_per_address': 1})), - cls(account, 1, **d.get('change', {'gap': 6, 'maximum_uses_per_address': 1})) + cls(account, KeyPath.RECEIVE, **d.get('receiving', {'gap': 20, 'maximum_uses_per_address': 1})), + cls(account, KeyPath.CHANGE, **d.get('change', {'gap': 6, 'maximum_uses_per_address': 1})) ) def merge(self, d: dict): @@ -176,7 +170,7 @@ class HierarchicalDeterministic(AddressManager): def get_private_key(self, index: int) -> PrivateKey: return self.account.private_key.child(self.chain_number).child(index) - def get_public_key(self, index: int) -> PubKey: + def get_public_key(self, index: int) -> PublicKey: return self.account.public_key.child(self.chain_number).child(index) async def get_max_gap(self) -> int: @@ -236,7 +230,7 @@ class SingleKey(AddressManager): @classmethod def from_dict(cls, account: 'Account', d: dict) \ -> Tuple[AddressManager, AddressManager]: - same_address_manager = cls(account, account.public_key, 0) + same_address_manager = cls(account, account.public_key, KeyPath.RECEIVE) return same_address_manager, same_address_manager def to_dict_instance(self): @@ -245,7 +239,7 @@ class SingleKey(AddressManager): def get_private_key(self, index: int) -> PrivateKey: return self.account.private_key - def get_public_key(self, index: int) -> PubKey: + def get_public_key(self, index: int) -> PublicKey: return self.account.public_key async def get_max_gap(self) -> int: @@ -267,9 +261,6 @@ class SingleKey(AddressManager): class Account: - mnemonic_class = Mnemonic - private_key_class = PrivateKey - public_key_class = PubKey address_generators: Dict[str, Type[AddressManager]] = { SingleKey.name: SingleKey, HierarchicalDeterministic.name: HierarchicalDeterministic, @@ -277,7 +268,7 @@ class Account: def __init__(self, ledger: 'Ledger', wallet: 'Wallet', name: str, seed: str, private_key_string: str, encrypted: bool, - private_key: Optional[PrivateKey], public_key: PubKey, + private_key: Optional[PrivateKey], public_key: PublicKey, address_generator: dict, modified_on: float, channel_keys: dict) -> None: self.ledger = ledger self.wallet = wallet @@ -288,8 +279,8 @@ class Account: self.private_key_string = private_key_string self.init_vectors: Dict[str, bytes] = {} self.encrypted = encrypted - self.private_key = private_key - self.public_key = public_key + self.private_key: Optional[PrivateKey] = private_key + self.public_key: PublicKey = public_key generator_name = address_generator.get('name', HierarchicalDeterministic.name) self.address_generator = self.address_generators[generator_name] self.receiving, self.change = self.address_generator.from_dict(self, address_generator) @@ -310,19 +301,19 @@ class Account: name: str = None, address_generator: dict = None): return cls.from_dict(ledger, wallet, { 'name': name, - 'seed': cls.mnemonic_class().make_seed(), + 'seed': Mnemonic().make_seed(), 'address_generator': address_generator or {} }) @classmethod def get_private_key_from_seed(cls, ledger: 'Ledger', seed: str, password: str): - return cls.private_key_class.from_seed( - ledger, cls.mnemonic_class.mnemonic_to_seed(seed, password or 'lbryum') + return PrivateKey.from_seed( + ledger, Mnemonic.mnemonic_to_seed(seed, password or 'lbryum') ) @classmethod def keys_from_dict(cls, ledger: 'Ledger', d: dict) \ - -> Tuple[str, Optional[PrivateKey], PubKey]: + -> Tuple[str, Optional[PrivateKey], PublicKey]: seed = d.get('seed', '') private_key_string = d.get('private_key', '') private_key = None @@ -493,7 +484,7 @@ class Account: assert not self.encrypted, "Cannot get private key on encrypted wallet account." return self.address_managers[chain].get_private_key(index) - def get_public_key(self, chain: int, index: int) -> PubKey: + def get_public_key(self, chain: int, index: int) -> PublicKey: return self.address_managers[chain].get_public_key(index) def get_balance(self, confirmations=0, include_claims=False, read_only=False, **constraints): @@ -567,34 +558,27 @@ class Account: async def generate_channel_private_key(self): return await self.deterministic_channel_keys.generate_next_key() - def add_channel_private_key(self, private_key): - public_key_bytes = private_key.get_verifying_key().to_der() - channel_pubkey_hash = self.ledger.public_key_to_address(public_key_bytes) - self.channel_keys[channel_pubkey_hash] = private_key.to_pem().decode() + def add_channel_private_key(self, private_key: PrivateKey): + self.channel_keys[private_key.address] = private_key.to_pem().decode() - async def get_channel_private_key(self, public_key_bytes) -> ecdsa.SigningKey: + async def get_channel_private_key(self, public_key_bytes) -> PrivateKey: channel_pubkey_hash = self.ledger.public_key_to_address(public_key_bytes) private_key_pem = self.channel_keys.get(channel_pubkey_hash) if private_key_pem: - return await asyncio.get_event_loop().run_in_executor( - None, ecdsa.SigningKey.from_pem, private_key_pem, sha256 - ) + return PrivateKey.from_pem(self.ledger, private_key_pem) return self.deterministic_channel_keys.get_private_key_from_pubkey_hash(channel_pubkey_hash) async def maybe_migrate_certificates(self): - def to_der(private_key_pem): - return ecdsa.SigningKey.from_pem(private_key_pem, hashfunc=sha256).get_verifying_key().to_der() - if not self.channel_keys: return channel_keys = {} for private_key_pem in self.channel_keys.values(): if not isinstance(private_key_pem, str): continue - if "-----BEGIN EC PRIVATE KEY-----" not in private_key_pem: + if not private_key_pem.startswith("-----BEGIN"): continue - public_key_der = await asyncio.get_event_loop().run_in_executor(None, to_der, private_key_pem) - channel_keys[self.ledger.public_key_to_address(public_key_der)] = private_key_pem + private_key = PrivateKey.from_pem(self.ledger, private_key_pem) + channel_keys[private_key.address] = private_key_pem if self.channel_keys != channel_keys: self.channel_keys = channel_keys self.wallet.save() diff --git a/lbry/wallet/bip32.py b/lbry/wallet/bip32.py index 8b88aa4dd..acf0190e0 100644 --- a/lbry/wallet/bip32.py +++ b/lbry/wallet/bip32.py @@ -1,10 +1,21 @@ -from coincurve import PublicKey, PrivateKey as _PrivateKey +from asn1crypto.keys import PrivateKeyInfo, ECPrivateKey +from coincurve import PublicKey as cPublicKey, PrivateKey as cPrivateKey +from coincurve.utils import ( + pem_to_der, lib as libsecp256k1, ffi as libsecp256k1_ffi +) +from coincurve.ecdsa import CDATA_SIG_LENGTH from lbry.crypto.hash import hmac_sha512, hash160, double_sha256 from lbry.crypto.base58 import Base58 from .util import cachedproperty +class KeyPath: + RECEIVE = 0 + CHANGE = 1 + CHANNEL = 2 + + class DerivationError(Exception): """ Raised when an invalid derivation occurs. """ @@ -71,26 +82,26 @@ class _KeyBase: return Base58.encode_check(self.extended_key()) -class PubKey(_KeyBase): +class PublicKey(_KeyBase): """ A BIP32 public key. """ def __init__(self, ledger, pubkey, chain_code, n, depth, parent=None): super().__init__(ledger, chain_code, n, depth, parent) - if isinstance(pubkey, PublicKey): + if isinstance(pubkey, cPublicKey): self.verifying_key = pubkey else: self.verifying_key = self._verifying_key_from_pubkey(pubkey) @classmethod def _verifying_key_from_pubkey(cls, pubkey): - """ Converts a 33-byte compressed pubkey into an PublicKey object. """ + """ Converts a 33-byte compressed pubkey into an coincurve.PublicKey object. """ if not isinstance(pubkey, (bytes, bytearray)): raise TypeError('pubkey must be raw bytes') if len(pubkey) != 33: raise ValueError('pubkey must be 33 bytes') if pubkey[0] not in (2, 3): raise ValueError('invalid pubkey prefix byte') - return PublicKey(pubkey) + return cPublicKey(pubkey) @cachedproperty def pubkey_bytes(self): @@ -105,7 +116,7 @@ class PubKey(_KeyBase): def ec_point(self): return self.verifying_key.point() - def child(self, n: int): + def child(self, n: int) -> 'PublicKey': """ Return the derived child extended pubkey at index N. """ if not 0 <= n < (1 << 31): raise ValueError('invalid BIP32 public key child number') @@ -113,7 +124,7 @@ class PubKey(_KeyBase): msg = self.pubkey_bytes + n.to_bytes(4, 'big') L_b, R_b = self._hmac_sha512(msg) # pylint: disable=invalid-name derived_key = self.verifying_key.add(L_b) - return PubKey(self.ledger, derived_key, R_b, n, self.depth + 1, self) + return PublicKey(self.ledger, derived_key, R_b, n, self.depth + 1, self) def identifier(self): """ Return the key's identifier as 20 bytes. """ @@ -138,7 +149,7 @@ class PrivateKey(_KeyBase): def __init__(self, ledger, privkey, chain_code, n, depth, parent=None): super().__init__(ledger, chain_code, n, depth, parent) - if isinstance(privkey, _PrivateKey): + if isinstance(privkey, cPrivateKey): self.signing_key = privkey else: self.signing_key = self._signing_key_from_privkey(privkey) @@ -146,7 +157,7 @@ class PrivateKey(_KeyBase): @classmethod def _signing_key_from_privkey(cls, private_key): """ Converts a 32-byte private key into an coincurve.PrivateKey object. """ - return _PrivateKey.from_int(PrivateKey._private_key_secret_exponent(private_key)) + return cPrivateKey.from_int(PrivateKey._private_key_secret_exponent(private_key)) @classmethod def _private_key_secret_exponent(cls, private_key): @@ -158,24 +169,36 @@ class PrivateKey(_KeyBase): return int.from_bytes(private_key, 'big') @classmethod - def from_seed(cls, ledger, seed): + def from_seed(cls, ledger, seed) -> 'PrivateKey': # This hard-coded message string seems to be coin-independent... hmac = hmac_sha512(b'Bitcoin seed', seed) privkey, chain_code = hmac[:32], hmac[32:] return cls(ledger, privkey, chain_code, 0, 0) + @classmethod + def from_pem(cls, ledger, pem) -> 'PrivateKey': + der = pem_to_der(pem.encode()) + try: + key_int = ECPrivateKey.load(der).native['private_key'] + except ValueError: + key_int = PrivateKeyInfo.load(der).native['private_key']['private_key'] + private_key = cPrivateKey.from_int(key_int) + return cls(ledger, private_key, bytes((0,)*32), 0, 0) + @cachedproperty def private_key_bytes(self): """ Return the serialized private key (no leading zero byte). """ return self.signing_key.secret @cachedproperty - def public_key(self): + def public_key(self) -> PublicKey: """ Return the corresponding extended public key. """ verifying_key = self.signing_key.public_key parent_pubkey = self.parent.public_key if self.parent else None - return PubKey(self.ledger, verifying_key, self.chain_code, self.n, self.depth, - parent_pubkey) + return PublicKey( + self.ledger, verifying_key, self.chain_code, + self.n, self.depth, parent_pubkey + ) def ec_point(self): return self.public_key.ec_point() @@ -188,11 +211,12 @@ class PrivateKey(_KeyBase): """ Return the private key encoded in Wallet Import Format. """ return self.ledger.private_key_to_wif(self.private_key_bytes) + @property def address(self): """ The public key as a P2PKH address. """ return self.public_key.address - def child(self, n): + def child(self, n) -> 'PrivateKey': """ Return the derived child extended private key at index N.""" if not 0 <= n < (1 << 32): raise ValueError('invalid BIP32 private key child number') @@ -211,6 +235,28 @@ class PrivateKey(_KeyBase): """ Produce a signature for piece of data by double hashing it and signing the hash. """ return self.signing_key.sign(data, hasher=double_sha256) + def sign_compact(self, digest): + """ Produce a compact signature. """ + key = self.signing_key + + signature = libsecp256k1_ffi.new('secp256k1_ecdsa_signature *') + signed = libsecp256k1.secp256k1_ecdsa_sign( + key.context.ctx, signature, digest, key.secret, + libsecp256k1_ffi.NULL, libsecp256k1_ffi.NULL + ) + + if not signed: + raise ValueError('The private key was invalid.') + + serialized = libsecp256k1_ffi.new('unsigned char[%d]' % CDATA_SIG_LENGTH) + compacted = libsecp256k1.secp256k1_ecdsa_signature_serialize_compact( + key.context.ctx, serialized, signature + ) + if compacted != 1: + raise ValueError('The signature could not be compacted.') + + return bytes(libsecp256k1_ffi.buffer(serialized, CDATA_SIG_LENGTH)) + def identifier(self): """Return the key's identifier as 20 bytes.""" return self.public_key.identifier() @@ -222,9 +268,12 @@ class PrivateKey(_KeyBase): b'\0' + self.private_key_bytes ) + def to_pem(self): + return self.signing_key.to_pem() + def _from_extended_key(ledger, ekey): - """Return a PubKey or PrivateKey from an extended key raw bytes.""" + """Return a PublicKey or PrivateKey from an extended key raw bytes.""" if not isinstance(ekey, (bytes, bytearray)): raise TypeError('extended key must be raw bytes') if len(ekey) != 78: @@ -236,7 +285,7 @@ def _from_extended_key(ledger, ekey): if ekey[:4] == ledger.extended_public_key_prefix: pubkey = ekey[45:] - key = PubKey(ledger, pubkey, chain_code, n, depth) + key = PublicKey(ledger, pubkey, chain_code, n, depth) elif ekey[:4] == ledger.extended_private_key_prefix: if ekey[45] != 0: raise ValueError('invalid extended private key prefix byte') @@ -254,6 +303,6 @@ def from_extended_key_string(ledger, ekey_str): xpub6BsnM1W2Y7qLMiuhi7f7dbAwQZ5Cz5gYJCRzTNainXzQXYjFwtuQXHd 3qfi3t3KJtHxshXezfjft93w4UE7BGMtKwhqEHae3ZA7d823DVrL - return a PubKey or PrivateKey. + return a PublicKey or PrivateKey. """ return _from_extended_key(ledger, Base58.decode_check(ekey_str)) diff --git a/lbry/wallet/database.py b/lbry/wallet/database.py index 3022deca8..d98d42f65 100644 --- a/lbry/wallet/database.py +++ b/lbry/wallet/database.py @@ -10,11 +10,10 @@ from contextvars import ContextVar from typing import Tuple, List, Union, Callable, Any, Awaitable, Iterable, Dict, Optional from datetime import date -import ecdsa from prometheus_client import Gauge, Counter, Histogram from lbry.utils import LockWithMetrics -from .bip32 import PubKey +from .bip32 import PublicKey, PrivateKey from .transaction import Transaction, Output, OutputScript, TXRefImmutable, Input from .constants import TXO_TYPES, CLAIM_TYPES from .util import date_to_julian_day @@ -977,7 +976,9 @@ class Database(SQLiteMixin): sql.append("LEFT JOIN txi ON (txi.position=0 AND txi.txid=txo.txid)") return await self.db.execute_fetchall(*query(' '.join(sql), **constraints), read_only=read_only) - async def get_txos(self, wallet=None, no_tx=False, no_channel_info=False, read_only=False, **constraints): + async def get_txos( + self, wallet=None, no_tx=False, no_channel_info=False, read_only=False, **constraints + ) -> List[Output]: include_is_spent = constraints.get('include_is_spent', False) include_is_my_input = constraints.get('include_is_my_input', False) include_is_my_output = constraints.pop('include_is_my_output', False) @@ -1203,7 +1204,7 @@ class Database(SQLiteMixin): addresses = await self.select_addresses(', '.join(cols), read_only=read_only, **constraints) if 'pubkey' in cols: for address in addresses: - address['pubkey'] = PubKey( + address['pubkey'] = PublicKey( self.ledger, address.pop('pubkey'), address.pop('chain_code'), address.pop('n'), address.pop('depth') ) @@ -1243,11 +1244,15 @@ class Database(SQLiteMixin): async def set_address_history(self, address, history): await self._set_address_history(address, history) - async def is_channel_key_used(self, wallet, key: ecdsa.SigningKey): - channels = await self.get_txos(wallet, txo_type=TXO_TYPES['channel']) - other_key_string = key.to_string() + async def is_channel_key_used(self, account, key: PublicKey): + channels = await self.get_txos( + accounts=[account], txo_type=TXO_TYPES['channel'], + no_tx=True, no_channel_info=True + ) + other_key_bytes = key.pubkey_bytes for channel in channels: - if channel.private_key is not None and channel.private_key.to_string() == other_key_string: + claim = channel.can_decode_claim + if claim and claim.channel.public_key_bytes == other_key_bytes: return True return False diff --git a/lbry/wallet/ledger.py b/lbry/wallet/ledger.py index 09a649474..652c764d4 100644 --- a/lbry/wallet/ledger.py +++ b/lbry/wallet/ledger.py @@ -26,7 +26,7 @@ from .transaction import Transaction, Output from .header import Headers, UnvalidatedHeaders from .checkpoints import HASHES from .constants import TXO_TYPES, CLAIM_TYPES, COIN, NULL_HASH32 -from .bip32 import PubKey, PrivateKey +from .bip32 import PublicKey, PrivateKey from .coinselection import CoinSelector log = logging.getLogger(__name__) @@ -226,7 +226,7 @@ class Ledger(metaclass=LedgerRegistry): return account.get_private_key(address_info['chain'], address_info['pubkey'].n) return None - async def get_public_key_for_address(self, wallet, address) -> Optional[PubKey]: + async def get_public_key_for_address(self, wallet, address) -> Optional[PublicKey]: match = await self._get_account_and_address_info_for_address(wallet, address) if match: _, address_info = match diff --git a/lbry/wallet/server/block_processor.py b/lbry/wallet/server/block_processor.py index 28a2f68e9..bb233e2d5 100644 --- a/lbry/wallet/server/block_processor.py +++ b/lbry/wallet/server/block_processor.py @@ -507,7 +507,7 @@ class BlockProcessor: channel_pub_key_bytes = channel_meta.channel.public_key_bytes if channel_pub_key_bytes: channel_signature_is_valid = Output.is_signature_valid( - txo.get_encoded_signature(), txo.get_signature_digest(self.ledger), channel_pub_key_bytes + txo.signable.signature, txo.get_signature_digest(self.ledger), channel_pub_key_bytes ) if channel_signature_is_valid: self.pending_channel_counts[signing_channel_hash] += 1 diff --git a/lbry/wallet/transaction.py b/lbry/wallet/transaction.py index c9ae7fa20..7399ae674 100644 --- a/lbry/wallet/transaction.py +++ b/lbry/wallet/transaction.py @@ -1,11 +1,11 @@ import struct -import hashlib import logging import typing from binascii import hexlify, unhexlify from typing import List, Iterable, Optional, Tuple -import ecdsa +from coincurve import PublicKey as cPublicKey +from coincurve.ecdsa import deserialize_compact, cdata_to_der from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.serialization import load_der_public_key from cryptography.hazmat.primitives import hashes @@ -27,6 +27,7 @@ from .constants import COIN, NULL_HASH32 from .bcd_data_stream import BCDataStream from .hash import TXRef, TXRefImmutable from .util import ReadOnlyList +from .bip32 import PrivateKey, PublicKey if typing.TYPE_CHECKING: from lbry.wallet.account import Account @@ -221,7 +222,8 @@ class Output(InputOutput): is_my_output: Optional[bool] = None, is_my_input: Optional[bool] = None, sent_supports: Optional[int] = None, sent_tips: Optional[int] = None, received_tips: Optional[int] = None, - channel: Optional['Output'] = None, private_key: Optional[str] = None + channel: Optional['Output'] = None, + private_key: Optional[PrivateKey] = None ) -> None: super().__init__(tx_ref, position) self.amount = amount @@ -234,7 +236,7 @@ class Output(InputOutput): self.sent_tips = sent_tips self.received_tips = received_tips self.channel = channel - self.private_key = private_key + self.private_key: PrivateKey = private_key self.purchase: 'Output' = None # txo containing purchase metadata self.purchased_claim: 'Output' = None # resolved claim pointed to by purchase self.purchase_receipt: 'Output' = None # txo representing purchase receipt for this claim @@ -424,25 +426,24 @@ class Output(InputOutput): ] return sha256(b''.join(pieces)) - def get_encoded_signature(self): - signature = hexlify(self.signable.signature) - r = int(signature[:int(len(signature)/2)], 16) - s = int(signature[int(len(signature)/2):], 16) - return ecdsa.util.sigencode_der(r, s, len(signature)*4) - @staticmethod - def is_signature_valid(encoded_signature, signature_digest, public_key_bytes): - try: - public_key = load_der_public_key(public_key_bytes, default_backend()) - public_key.verify(encoded_signature, signature_digest, ec.ECDSA(Prehashed(hashes.SHA256()))) - return True - except (ValueError, InvalidSignature): - pass - return False + def is_signature_valid(signature, digest, public_key_bytes): + signature = cdata_to_der(deserialize_compact(signature)) + public_key = cPublicKey(public_key_bytes) + is_valid = public_key.verify(signature, digest, None) + if not is_valid: # try old way + # ytsync signed claims don't seem to validate with coincurve + try: + pk = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256K1(), public_key_bytes) + pk.verify(signature, digest, ec.ECDSA(Prehashed(hashes.SHA256()))) + return True + except (ValueError, InvalidSignature): + pass + return is_valid def is_signed_by(self, channel: 'Output', ledger=None): return self.is_signature_valid( - self.get_encoded_signature(), + self.signable.signature, self.get_signature_digest(ledger), channel.claim.channel.public_key_bytes ) @@ -455,27 +456,27 @@ class Output(InputOutput): self.signable.signing_channel_hash, self.signable.to_message_bytes() ])) - self.signable.signature = channel.private_key.sign_digest_deterministic(digest, hashfunc=hashlib.sha256) + self.signable.signature = channel.private_key.sign_compact(digest) self.script.generate() - def sign_data(self, data:bytes, timestamp:str) -> str: + def sign_data(self, data: bytes, timestamp: str) -> str: pieces = [timestamp.encode(), self.claim_hash, data] digest = sha256(b''.join(pieces)) - signature = self.private_key.sign_digest_deterministic(digest, hashfunc=hashlib.sha256) + signature = self.private_key.sign_compact(digest) return hexlify(signature).decode() def clear_signature(self): self.channel = None self.signable.clear_signature() - def set_channel_private_key(self, private_key: ecdsa.SigningKey): + def set_channel_private_key(self, private_key: PrivateKey): self.private_key = private_key - self.claim.channel.public_key_bytes = self.private_key.get_verifying_key().to_der() + self.claim.channel.public_key_bytes = private_key.public_key.pubkey_bytes self.script.generate() return self.private_key - def is_channel_private_key(self, private_key: ecdsa.SigningKey): - return self.claim.channel.public_key_bytes == private_key.get_verifying_key().to_der() + def is_channel_private_key(self, private_key: PrivateKey): + return self.claim.channel.public_key_bytes == private_key.public_key.pubkey_bytes @classmethod def pay_claim_name_pubkey_hash( diff --git a/tests/integration/blockchain/test_account_commands.py b/tests/integration/blockchain/test_account_commands.py index 979f74ec0..75e65229f 100644 --- a/tests/integration/blockchain/test_account_commands.py +++ b/tests/integration/blockchain/test_account_commands.py @@ -1,7 +1,8 @@ from binascii import unhexlify from lbry.testcase import CommandTestCase -from lbry.wallet.dewies import dewies_to_lbc +from lbry.schema.claim import Claim +from lbry.wallet.dewies import dewies_to_lbc, lbc_to_dewies from lbry.wallet.account import DeterministicChannelKeyManager from lbry.wallet.transaction import Transaction @@ -63,6 +64,32 @@ class AccountManagement(CommandTestCase): accounts = await self.daemon.jsonrpc_account_list(account_id, include_claims=True) self.assertEqual(accounts['items'][0]['name'], 'recreated account') + async def test_wallet_migration(self): + old_id, new_id, valid_key = ( + 'mi9E8KqFfW5ngktU22pN2jpgsdf81ZbsGY', + 'mqs77XbdnuxWN4cXrjKbSoGLkvAHa4f4B8', + '-----BEGIN EC PRIVATE KEY-----\nMHQCAQEEIBZRTZ7tHnYCH3IE9mCo95' + '466L/ShYFhXGrjmSMFJw8eoAcGBSuBBAAK\noUQDQgAEmucoPz9nI+ChZrfhnh' + '0RZ/bcX0r2G0pYBmoNKovtKzXGa8y07D66MWsW\nqXptakqO/9KddIkBu5eJNS' + 'UZzQCxPQ==\n-----END EC PRIVATE KEY-----\n' + ) + # null certificates should get deleted + self.account.channel_keys = { + new_id: 'not valid key', + 'foo': 'bar', + } + await self.account.maybe_migrate_certificates() + self.assertEqual(self.account.channel_keys, {}) + self.account.channel_keys = { + new_id: 'not valid key', + 'foo': 'bar', + 'invalid address': valid_key, + } + await self.account.maybe_migrate_certificates() + self.assertEqual(self.account.channel_keys, { + new_id: valid_key + }) + async def assertFindsClaims(self, claim_names, awaitable): self.assertEqual(claim_names, [txo.claim_name for txo in (await awaitable)['items']]) @@ -168,6 +195,51 @@ class AccountManagement(CommandTestCase): with self.assertRaisesRegex(Exception, f"'{bad_address}' is not a valid address"): await self.daemon.jsonrpc_account_send('0.1', addresses=[bad_address]) + async def create_nondeterministic_channel(self, name, pubkey_bytes): + claim_address = await self.account.receiving.get_or_create_usable_address() + claim = Claim() + claim.channel.public_key_bytes = pubkey_bytes + tx = await Transaction.claim_create( + name, claim, lbc_to_dewies('1.0'), + claim_address, [self.account], self.account + ) + await tx.sign([self.account]) + + async def command(): + await self.daemon.broadcast_or_release(tx, False) + return tx + + return await self.confirm_and_render(command(), True) + + async def test_hybrid_channel_keys(self): + # non-deterministic channel + self.account.channel_keys = { + 'mqs77XbdnuxWN4cXrjKbSoGLkvAHa4f4B8': + '-----BEGIN EC PRIVATE KEY-----\nMHQCAQEEIBZRTZ7tHnYCH3IE9mCo95' + '466L/ShYFhXGrjmSMFJw8eoAcGBSuBBAAK\noUQDQgAEmucoPz9nI+ChZrfhnh' + '0RZ/bcX0r2G0pYBmoNKovtKzXGa8y07D66MWsW\nqXptakqO/9KddIkBu5eJNS' + 'UZzQCxPQ==\n-----END EC PRIVATE KEY-----\n' + } + channel1 = await self.create_nondeterministic_channel('@foo1', unhexlify( + '3056301006072a8648ce3d020106052b8104000a034200049ae7283f3f6723e0a1' + '66b7e19e1d1167f6dc5f4af61b4a58066a0d2a8bed2b35c66bccb4ec3eba316b16' + 'a97a6d6a4a8effd29d748901bb9789352519cd00b13d' + )) + + # deterministic channel + channel2 = await self.channel_create('@foo2') + + stream1 = await self.stream_create('stream-in-channel1', '0.01', channel_id=self.get_claim_id(channel1)) + stream2 = await self.stream_create('stream-in-channel2', '0.01', channel_id=self.get_claim_id(channel2)) + + resolved_stream1 = await self.resolve('@foo1/stream-in-channel1') + self.assertEqual('stream-in-channel1', resolved_stream1['name']) + self.assertTrue(resolved_stream1['is_channel_signature_valid']) + + resolved_stream2 = await self.resolve('@foo2/stream-in-channel2') + self.assertEqual('stream-in-channel2', resolved_stream2['name']) + self.assertTrue(resolved_stream2['is_channel_signature_valid']) + async def test_deterministic_channel_keys(self): seed = self.account.seed keys = self.account.deterministic_channel_keys @@ -188,33 +260,33 @@ class AccountManagement(CommandTestCase): self.assertTrue(channel1b.has_private_key) self.assertEqual( channel1a['outputs'][0]['value']['public_key_id'], - self.ledger.public_key_to_address(channel1b.private_key.verifying_key.to_der()) + channel1b.private_key.address ) self.assertTrue(channel2b.has_private_key) self.assertEqual( channel2a['outputs'][0]['value']['public_key_id'], - self.ledger.public_key_to_address(channel2b.private_key.verifying_key.to_der()) + channel2b.private_key.address ) # repeatedly calling next channel key returns the same key when not used current_known = keys.last_known next_key = await keys.generate_next_key() self.assertEqual(current_known, keys.last_known) - self.assertEqual(next_key.to_string(), (await keys.generate_next_key()).to_string()) + self.assertEqual(next_key.address, (await keys.generate_next_key()).address) # again, should be idempotent next_key = await keys.generate_next_key() self.assertEqual(current_known, keys.last_known) - self.assertEqual(next_key.to_string(), (await keys.generate_next_key()).to_string()) + self.assertEqual(next_key.address, (await keys.generate_next_key()).address) # create third channel while both daemons running, second daemon should pick it up channel3a = await self.channel_create('@foo3') self.assertEqual(current_known+1, keys.last_known) - self.assertNotEqual(next_key.to_string(), (await keys.generate_next_key()).to_string()) + self.assertNotEqual(next_key.address, (await keys.generate_next_key()).address) channel3b, = (await self.daemon2.jsonrpc_channel_list(name='@foo3'))['items'] self.assertTrue(channel3b.has_private_key) self.assertEqual( channel3a['outputs'][0]['value']['public_key_id'], - self.ledger.public_key_to_address(channel3b.private_key.verifying_key.to_der()) + channel3b.private_key.address ) # channel key cache re-populated after simulated restart diff --git a/tests/integration/claims/test_claim_commands.py b/tests/integration/claims/test_claim_commands.py index 6e0a71ba5..c0abff140 100644 --- a/tests/integration/claims/test_claim_commands.py +++ b/tests/integration/claims/test_claim_commands.py @@ -19,12 +19,6 @@ from lbry.crypto.hash import sha256 log = logging.getLogger(__name__) -def get_encoded_signature(signature): - signature = signature.encode() if isinstance(signature, str) else signature - r = int(signature[:int(len(signature) / 2)], 16) - s = int(signature[int(len(signature) / 2):], 16) - return ecdsa.util.sigencode_der(r, s, len(signature) * 4) - def verify(channel, data, signature, channel_hash=None): pieces = [ @@ -33,7 +27,7 @@ def verify(channel, data, signature, channel_hash=None): data ] return Output.is_signature_valid( - get_encoded_signature(signature['signature']), + unhexlify(signature['signature']), sha256(b''.join(pieces)), channel.claim.channel.public_key_bytes ) @@ -1123,17 +1117,17 @@ class ChannelCommands(CommandTestCase): tx = await self.channel_update(claim_id, bid='4.0') self.assertEqual(tx['outputs'][0]['amount'], '4.0') - await self.assertBalance(self.account, '5.991447') + await self.assertBalance(self.account, '5.991503') # not enough funds with self.assertRaisesRegex( InsufficientFundsError, "Not enough funds to cover this transaction."): await self.channel_create('@foo2', '9.0') self.assertItemCount(await self.daemon.jsonrpc_channel_list(), 1) - await self.assertBalance(self.account, '5.991447') + await self.assertBalance(self.account, '5.991503') # spend exactly amount available, no change - tx = await self.channel_create('@foo3', '5.981266') + tx = await self.channel_create('@foo3', '5.981322') await self.assertBalance(self.account, '0.0') self.assertEqual(len(tx['outputs']), 1) # no change self.assertItemCount(await self.daemon.jsonrpc_channel_list(), 2) @@ -1249,7 +1243,7 @@ class ChannelCommands(CommandTestCase): await daemon2.jsonrpc_channel_import(exported_data) channels = (await daemon2.jsonrpc_channel_list())['items'] self.assertEqual(1, len(channels)) - self.assertEqual(channel_private_key.to_string(), channels[0].private_key.to_string()) + self.assertEqual(channel_private_key.private_key_bytes, channels[0].private_key.private_key_bytes) # second wallet can't update until channel is sent to it with self.assertRaisesRegex(AssertionError, 'Cannot find private key for signing output.'): diff --git a/tests/unit/wallet/test_schema_signing.py b/tests/unit/wallet/test_schema_signing.py index c3ff5bdae..2b2519d69 100644 --- a/tests/unit/wallet/test_schema_signing.py +++ b/tests/unit/wallet/test_schema_signing.py @@ -1,10 +1,8 @@ from binascii import unhexlify -import ecdsa - from lbry.testcase import AsyncioTestCase from lbry.wallet.constants import CENT, NULL_HASH32 -from lbry.wallet.bip32 import PrivateKey +from lbry.wallet.bip32 import PrivateKey, KeyPath from lbry.wallet.mnemonic import Mnemonic from lbry.wallet import Ledger, Database, Headers, Transaction, Input, Output from lbry.schema.claim import Claim @@ -27,10 +25,10 @@ def get_tx(): async def get_channel(claim_name='@foo'): seed = Mnemonic.mnemonic_to_seed(Mnemonic().make_seed(), '') - bip32_key = PrivateKey.from_seed(Ledger, seed) - signing_key = ecdsa.SigningKey.from_secret_exponent(bip32_key.secret_exponent(), ecdsa.SECP256k1) + key = PrivateKey.from_seed(Ledger, seed) + channel_key = key.child(KeyPath.CHANNEL).child(0) channel_txo = Output.pay_claim_name_pubkey_hash(CENT, claim_name, Claim(), b'abc') - channel_txo.set_channel_private_key(signing_key) + channel_txo.set_channel_private_key(channel_key) get_tx().add_outputs([channel_txo]) return channel_txo @@ -160,13 +158,10 @@ class TestValidateSignContent(AsyncioTestCase): some_content = "MEANINGLESS CONTENT AEE3353320".encode() timestamp_str = "1630564175" channel = await get_channel() - stream = get_stream() signature = channel.sign_data(some_content, timestamp_str) - stream.signable.signature = unhexlify(signature.encode()) - encoded_signature = stream.get_encoded_signature() pieces = [timestamp_str.encode(), channel.claim_hash, some_content] self.assertTrue(Output.is_signature_valid( - encoded_signature, + unhexlify(signature.encode()), sha256(b''.join(pieces)), channel.claim.channel.public_key_bytes ))