Source code for btclib_wallet.bip32.key_origin

# Copyright (c) The btclib developers
# Distributed under the MIT software license, see the accompanying
# LICENSE file or https://opensource.org/license/mit for the full text.

"""The BIP32KeyOrigin dataclass and the bip32_derivs codecs."""

from __future__ import annotations

from collections.abc import Mapping, MutableMapping, Sequence
from dataclasses import dataclass
from io import BytesIO

from btclib.alias import Octets
from btclib.exceptions import BTClibValueError
from btclib.utils import (
    bytes_from_octets,
    fields_from_json_object,
    list_from_json_array,
    read_exactly,
)
from typing_extensions import override

from btclib_wallet.bip32.der_path import (
    DerPath,
    bytes_from_der_path,
    indexes_from_der_path,
    str_from_der_path,
)

__all__ = [
    "BIP32KeyOrigin",
    "HdKeyPaths",
    "assert_valid_hd_key_paths",
    "decode_from_bip32_derivs",
    "decode_hd_key_paths",
    "encode_to_bip32_derivs",
]


[docs] @dataclass(frozen=True) class BIP32KeyOrigin: """Where a key comes from: master fingerprint and derivation path. What BIP174's bip32_derivs and a descriptor's [fingerprint/path] prefix carry; the path is held as indexes, hardened ones offset by 0x80000000. The wire form is serialize and parse, the bracketed text form description and from_description. """ master_fingerprint: bytes der_path: Sequence[int] @property def description(self) -> str: """Return the descriptor spelling, fingerprint/path, h for hardened.""" return str_from_der_path(self.der_path, self.master_fingerprint) def __init__( self, master_fingerprint: Octets, der_path: DerPath, *, check_validity: bool = True, ) -> None: object.__setattr__( self, "master_fingerprint", bytes_from_octets(master_fingerprint) ) object.__setattr__(self, "der_path", indexes_from_der_path(der_path)) if check_validity: self.assert_valid() def __len__(self) -> int: return len(self.der_path)
[docs] def assert_valid(self) -> None: """Refuse a fingerprint not of 4 bytes, a path too long or out of range. Btclib bounds a BIP32 path at 255 indexes; each serialized index occupies 4 bytes. """ if len(self.master_fingerprint) != 4: err_msg = "invalid master fingerprint length: " err_msg += f"{len(self.master_fingerprint)}" raise BTClibValueError(err_msg) if len(self) > 255: raise BTClibValueError(f"invalid der_path size: {len(self.der_path)}") if any(not 0 <= i <= 0xFFFFFFFF for i in self.der_path): raise BTClibValueError("invalid der_path element")
[docs] def to_dict(self, *, check_validity: bool = True) -> dict[str, str]: """Return {"master_fingerprint", "path"} as hex and m/ text.""" if check_validity: self.assert_valid() return { "master_fingerprint": self.master_fingerprint.hex(), "path": str_from_der_path(self.der_path), }
[docs] @classmethod def from_dict( cls: type[BIP32KeyOrigin], dict_: Mapping[str, str], *, check_validity: bool = True, ) -> BIP32KeyOrigin: """Build a BIP32KeyOrigin from the dict shape to_dict writes.""" dict_ = fields_from_json_object(dict_, "key origin") return cls( dict_["master_fingerprint"], dict_["path"], check_validity=check_validity, )
[docs] def serialize(self, *, check_validity: bool = True) -> bytes: """Return BIP174's key-origin bytes: fingerprint, then the indexes.""" if check_validity: self.assert_valid() return self.master_fingerprint + bytes_from_der_path(self.der_path)
[docs] @classmethod def parse( cls: type[BIP32KeyOrigin], data: Octets, *, check_validity: bool = True ) -> BIP32KeyOrigin: """Return a BIP32KeyOrigin by parsing binary data. The four fingerprint octets are the encoding's boundary and not an opinion about what it means, so they are required whatever `check_validity` says: a slice of a shorter buffer answers with whatever is there, and the object serializes back longer than what it was parsed from. The other half of the same boundary is already unconditional -- `indexes_from_der_path` refuses a remainder that is not a whole number of 4-octet indexes. """ data = bytes_from_octets(data) stream = BytesIO(data) master_fingerprint = read_exactly(stream, 4, "master fingerprint") der_path = indexes_from_der_path(stream.read()) return cls(master_fingerprint, der_path, check_validity=check_validity)
[docs] @classmethod def from_description( cls: type[BIP32KeyOrigin], data: str, *, check_validity: bool = True ) -> BIP32KeyOrigin: """Build a BIP32KeyOrigin from its fingerprint/path spelling.""" data = data.strip() return cls(data[:8], data[9:], check_validity=check_validity)
@override def __hash__(self: BIP32KeyOrigin) -> int: return hash(self.serialize())
# a mutable mapping from pub_key to fingerprint and derivation path HdKeyPaths = MutableMapping[bytes, BIP32KeyOrigin]
[docs] def assert_valid_hd_key_paths(hd_key_paths: Mapping[bytes, BIP32KeyOrigin]) -> None: """Raise an exception if the dataclass element is not valid.""" if len(hd_key_paths.values()) > len( {der_path.serialize() for der_path in hd_key_paths.values()} ): raise BTClibValueError("Duplicated key origin values in hd_key_paths") for pub_key, key_origin in hd_key_paths.items(): # the length and not the point: BIP174 test vector 6 carries a # pub_key that is not on the curve, so parsing it here would # refuse a psbt the specification calls valid if len(pub_key) not in {78, 33, 65}: err_msg = f"invalid public key length: {len(pub_key)}" raise BTClibValueError(err_msg) key_origin.assert_valid()
[docs] def decode_hd_key_paths(map_: Mapping[Octets, BIP32KeyOrigin] | None) -> HdKeyPaths: """Return the dataclass element from its json representation.""" hd_key_paths = {bytes_from_octets(k): v for k, v in map_.items()} if map_ else {} hd_key_paths = dict(sorted(hd_key_paths.items())) # the keys went through bytes_from_octets and the values came across # untouched, so this is a public entry through which an unchecked # origin enters a psbt's hd_key_paths -- and travels on to the json # `encode_to_bip32_derivs` writes. The validating counterpart sits # right above assert_valid_hd_key_paths(hd_key_paths) return hd_key_paths
_BIP32Deriv = Mapping[str, str]
[docs] def encode_to_bip32_derivs( hd_key_paths: Mapping[bytes, BIP32KeyOrigin], ) -> list[_BIP32Deriv]: """Return the json representation of the dataclass element.""" assert_valid_hd_key_paths(hd_key_paths) return [ { "pub_key": pub_key.hex(), "master_fingerprint": key_origin.master_fingerprint.hex(), "path": str_from_der_path(key_origin.der_path), } for pub_key, key_origin in sorted(hd_key_paths.items()) ]
def _decode_from_bip32_deriv( bip32_deriv: Mapping[str, str], *, check_validity: bool, ) -> tuple[bytes, BIP32KeyOrigin]: bip32_deriv = fields_from_json_object(bip32_deriv, "bip32 derivation") master_fingerprint = bytes_from_octets( bip32_deriv["master_fingerprint"], 4 if check_validity else None ) der_path = indexes_from_der_path(bip32_deriv["path"]) key_origin = BIP32KeyOrigin( master_fingerprint, der_path, check_validity=check_validity ) return bytes_from_octets(bip32_deriv["pub_key"]), key_origin
[docs] def decode_from_bip32_derivs( bip32_derivs: Sequence[Mapping[str, str]], *, check_validity: bool = True, ) -> HdKeyPaths: """Return the dataclass element from its json representation.""" return dict( sorted( _decode_from_bip32_deriv(item, check_validity=check_validity) for item in list_from_json_array(bip32_derivs, "bip32 derivations") ) )