diff --git a/CHANGELOG.md b/CHANGELOG.md index abbcd8e..cb797fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## [Unreleased] + +### Added + +- A new method `get_fingerprint()` was added. + +### Changed + +- Drop base58 dependency. Port base58 code directly in-tree as a `base58` module. + ## 1.0.1 - Support Python 3.8 diff --git a/README.md b/README.md index 0907819..b61736b 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ Equivalent to `get_xpriv_from_path([])`. #### get_xpriv_bytes() -Equivalent to `get_xpriv([])`, but not serialized in base58 +Equivalent to `get_xpriv([])`, but not serialized in base58. #### get_xpub() @@ -134,4 +134,8 @@ Equivalent to `get_xpub_from_path([])`. #### get_xpub_bytes() -Equivalent to `get_xpub([])`, but not serialized in base58 +Equivalent to `get_xpub([])`, but not serialized in base58. + +#### get_fingerprint() + +Returns `fingerprint (bytes)`, equivalent to `utils._pubkey_to_fingerprint(self.pubkey)`. diff --git a/pyproject.toml b/pyproject.toml index 37d9ca3..0ba9472 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,6 @@ keywords = ["bitcoin", "slip10", "hdwallet"] [tool.poetry.dependencies] cryptography = "*" ecdsa = "*" -base58 = "^2" python = ">=3.8,<4.0" [tool.poetry.group.dev.dependencies] diff --git a/slip10/base58.py b/slip10/base58.py new file mode 100644 index 0000000..bb0be21 --- /dev/null +++ b/slip10/base58.py @@ -0,0 +1,164 @@ +"""Base58 encoding + +Implementations of Base58 and Base58Check encodings that are compatible +with the bitcoin network. + +This file was copied over and added to the bip32 project from David Keijser's https://github.com/keis/base58 (https://pypi.org/project/base58/). This +package is released under an MIT licensed. The code was copied in this file and left untouched. Here is a copy of the MIT license accompanying the +code: + Copyright (c) 2015 David Keijser + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +""" + +# This module is based upon base58 snippets found scattered over many bitcoin +# tools written in python. From what I gather the original source is from a +# forum post by Gavin Andresen, so direct your praise to him. +# This module adds shiny packaging and support for python3. + +from functools import lru_cache +from hashlib import sha256 +from typing import Mapping, Union + +# 58 character alphabet used +BITCOIN_ALPHABET = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +RIPPLE_ALPHABET = b"rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz" +XRP_ALPHABET = RIPPLE_ALPHABET + +# Retro compatibility +alphabet = BITCOIN_ALPHABET + + +def scrub_input(v: Union[str, bytes]) -> bytes: + if isinstance(v, str): + v = v.encode("ascii") + + return v + + +def b58encode_int( + i: int, default_one: bool = True, alphabet: bytes = BITCOIN_ALPHABET +) -> bytes: + """ + Encode an integer using Base58 + """ + if not i and default_one: + return alphabet[0:1] + string = b"" + base = len(alphabet) + while i: + i, idx = divmod(i, base) + string = alphabet[idx : idx + 1] + string + return string + + +def b58encode(v: Union[str, bytes], alphabet: bytes = BITCOIN_ALPHABET) -> bytes: + """ + Encode a string using Base58 + """ + v = scrub_input(v) + + origlen = len(v) + v = v.lstrip(b"\0") + newlen = len(v) + + acc = int.from_bytes(v, byteorder="big") # first byte is most significant + + result = b58encode_int(acc, default_one=False, alphabet=alphabet) + return alphabet[0:1] * (origlen - newlen) + result + + +@lru_cache() +def _get_base58_decode_map(alphabet: bytes, autofix: bool) -> Mapping[int, int]: + invmap = {char: index for index, char in enumerate(alphabet)} + + if autofix: + groups = [b"0Oo", b"Il1"] + for group in groups: + pivots = [c for c in group if c in invmap] + if len(pivots) == 1: + for alternative in group: + invmap[alternative] = invmap[pivots[0]] + + return invmap + + +def b58decode_int( + v: Union[str, bytes], alphabet: bytes = BITCOIN_ALPHABET, *, autofix: bool = False +) -> int: + """ + Decode a Base58 encoded string as an integer + """ + if b" " not in alphabet: + v = v.rstrip() + v = scrub_input(v) + + map = _get_base58_decode_map(alphabet, autofix=autofix) + + decimal = 0 + base = len(alphabet) + try: + for char in v: + decimal = decimal * base + map[char] + except KeyError as e: + raise ValueError("Invalid character {!r}".format(chr(e.args[0]))) from None + return decimal + + +def b58decode( + v: Union[str, bytes], alphabet: bytes = BITCOIN_ALPHABET, *, autofix: bool = False +) -> bytes: + """ + Decode a Base58 encoded string + """ + v = v.rstrip() + v = scrub_input(v) + + origlen = len(v) + v = v.lstrip(alphabet[0:1]) + newlen = len(v) + + acc = b58decode_int(v, alphabet=alphabet, autofix=autofix) + + return acc.to_bytes(origlen - newlen + (acc.bit_length() + 7) // 8, "big") + + +def b58encode_check(v: Union[str, bytes], alphabet: bytes = BITCOIN_ALPHABET) -> bytes: + """ + Encode a string using Base58 with a 4 character checksum + """ + v = scrub_input(v) + + digest = sha256(sha256(v).digest()).digest() + return b58encode(v + digest[:4], alphabet=alphabet) + + +def b58decode_check( + v: Union[str, bytes], alphabet: bytes = BITCOIN_ALPHABET, *, autofix: bool = False +) -> bytes: + """Decode and verify the checksum of a Base58 encoded string""" + + result = b58decode(v, alphabet=alphabet, autofix=autofix) + result, check = result[:-4], result[-4:] + digest = sha256(sha256(result).digest()).digest() + + if check != digest[:4]: + raise ValueError("Invalid checksum") + + return result diff --git a/slip10/slip10.py b/slip10/slip10.py index 26129ea..e0b4cc8 100644 --- a/slip10/slip10.py +++ b/slip10/slip10.py @@ -1,8 +1,7 @@ import hashlib import hmac -import base58 - +from .base58 import b58decode_check, b58encode_check from .utils import ( HARDENED_INDEX, _deriv_path_str_to_list, @@ -263,7 +262,7 @@ def get_xpriv_from_path(self, path): self.network, ) - return base58.b58encode_check(extended_key).decode() + return b58encode_check(extended_key).decode() def get_xpub_from_path(self, path): """Get an encoded extended pubkey from a derivation path. @@ -299,11 +298,11 @@ def get_xpub_from_path(self, path): self.network, ) - return base58.b58encode_check(extended_key).decode() + return b58encode_check(extended_key).decode() def get_xpriv(self): """Get the base58 encoded extended private key.""" - return base58.b58encode_check(self.get_xpriv_bytes()).decode() + return b58encode_check(self.get_xpriv_bytes()).decode() def get_xpriv_bytes(self): """Get the encoded extended private key.""" @@ -325,7 +324,7 @@ def get_xpriv_bytes(self): def get_xpub(self): """Get the encoded extended public key.""" - return base58.b58encode_check(self.get_xpub_bytes()).decode() + return b58encode_check(self.get_xpub_bytes()).decode() def get_xpub_bytes(self): """Get the encoded extended public key.""" @@ -343,6 +342,10 @@ def get_xpub_bytes(self): self.network, ) + def get_fingerprint(self): + """Get the public key fingerprint.""" + return _pubkey_to_fingerprint(self.pubkey) + @classmethod def from_xpriv(cls, xpriv): """Get a SLIP10 "wallet" out of this xpriv @@ -352,7 +355,7 @@ def from_xpriv(cls, xpriv): if not isinstance(xpriv, str): raise InvalidInputError("'xpriv' must be a string") - extended_key = base58.b58decode_check(xpriv) + extended_key = b58decode_check(xpriv) ( network, depth, @@ -380,7 +383,7 @@ def from_xpub(cls, xpub): if not isinstance(xpub, str): raise InvalidInputError("'xpub' must be a string") - extended_key = base58.b58decode_check(xpub) + extended_key = b58decode_check(xpub) ( network, depth,