diff --git a/src/local_bip85/bip85.py b/src/local_bip85/bip85.py index 39ff364..665b5f3 100644 --- a/src/local_bip85/bip85.py +++ b/src/local_bip85/bip85.py @@ -51,26 +51,34 @@ class BIP85: raise Bip85Error(f"Error initializing BIP32 context: {e}") def derive_entropy( - self, index: int, bytes_len: int, app_no: int = 39, words_len: int | None = None + self, + index: int, + entropy_bytes: int, + app_no: int = 39, + word_count: int | None = None, ) -> bytes: - """ - Derives entropy using BIP-85 HMAC-SHA512 method. + """Derive entropy using the BIP-85 HMAC-SHA512 method. Parameters: index (int): Index for the child entropy. - bytes_len (int): Number of bytes to derive for the entropy. - app_no (int): Application number (default 39 for BIP39) + entropy_bytes (int): Number of bytes of entropy to derive. + app_no (int): Application number (default 39 for BIP39). + word_count (int | None): Number of words used in the derivation path + for BIP39. If ``None`` and ``app_no`` is ``39``, ``word_count`` + defaults to ``entropy_bytes``. The final segment of the + derivation path becomes ``m/83696968'/39'/0'/word_count'/index'``. Returns: - bytes: Derived entropy. + bytes: Derived entropy of length ``entropy_bytes``. Raises: - SystemExit: If derivation fails or entropy length is invalid. + SystemExit: If derivation fails or the derived entropy length is + invalid. """ if app_no == 39: - if words_len is None: - words_len = bytes_len - path = f"m/83696968'/{app_no}'/0'/{words_len}'/{index}'" + if word_count is None: + word_count = entropy_bytes + path = f"m/83696968'/{app_no}'/0'/{word_count}'/{index}'" elif app_no == 32: path = f"m/83696968'/{app_no}'/{index}'" else: @@ -86,17 +94,17 @@ class BIP85: hmac_result = hmac.new(hmac_key, k, hashlib.sha512).digest() logging.debug(f"HMAC-SHA512 result: {hmac_result.hex()}") - entropy = hmac_result[:bytes_len] + entropy = hmac_result[:entropy_bytes] - if len(entropy) != bytes_len: + if len(entropy) != entropy_bytes: logging.error( - f"Derived entropy length is {len(entropy)} bytes; expected {bytes_len} bytes." + f"Derived entropy length is {len(entropy)} bytes; expected {entropy_bytes} bytes." ) print( - f"{Fore.RED}Error: Derived entropy length is {len(entropy)} bytes; expected {bytes_len} bytes." + f"{Fore.RED}Error: Derived entropy length is {len(entropy)} bytes; expected {entropy_bytes} bytes." ) raise Bip85Error( - f"Derived entropy length is {len(entropy)} bytes; expected {bytes_len} bytes." + f"Derived entropy length is {len(entropy)} bytes; expected {entropy_bytes} bytes." ) logging.debug(f"Derived entropy: {entropy.hex()}") @@ -107,14 +115,17 @@ class BIP85: raise Bip85Error(f"Error deriving entropy: {e}") def derive_mnemonic(self, index: int, words_num: int) -> str: - bytes_len = {12: 16, 18: 24, 24: 32}.get(words_num) - if not bytes_len: + entropy_bytes = {12: 16, 18: 24, 24: 32}.get(words_num) + if not entropy_bytes: logging.error(f"Unsupported number of words: {words_num}") print(f"{Fore.RED}Error: Unsupported number of words: {words_num}") raise Bip85Error(f"Unsupported number of words: {words_num}") entropy = self.derive_entropy( - index=index, bytes_len=bytes_len, app_no=39, words_len=words_num + index=index, + entropy_bytes=entropy_bytes, + app_no=39, + word_count=words_num, ) try: mnemonic = Bip39MnemonicGenerator(Bip39Languages.ENGLISH).FromEntropy( @@ -130,7 +141,7 @@ class BIP85: def derive_symmetric_key(self, index: int = 0, app_no: int = 2) -> bytes: """Derive 32 bytes of entropy for symmetric key usage.""" try: - key = self.derive_entropy(index=index, bytes_len=32, app_no=app_no) + key = self.derive_entropy(index=index, entropy_bytes=32, app_no=app_no) logging.debug(f"Derived symmetric key: {key.hex()}") return key except Exception as e: diff --git a/src/nostr/key_manager.py b/src/nostr/key_manager.py index 51db186..c68be10 100644 --- a/src/nostr/key_manager.py +++ b/src/nostr/key_manager.py @@ -85,7 +85,7 @@ class KeyManager: # Derive entropy for Nostr key (32 bytes) entropy_bytes = self.bip85.derive_entropy( index=index, - bytes_len=32, + entropy_bytes=32, app_no=NOSTR_KEY_APP_ID, ) @@ -102,7 +102,7 @@ class KeyManager: """Derive Nostr keys using the legacy application ID.""" try: entropy = self.bip85.derive_entropy( - index=0, bytes_len=32, app_no=LEGACY_NOSTR_KEY_APP_ID + index=0, entropy_bytes=32, app_no=LEGACY_NOSTR_KEY_APP_ID ) return Keys(priv_k=entropy.hex()) except Exception as e: diff --git a/src/seedpass/core/entry_management.py b/src/seedpass/core/entry_management.py index c81645a..fa81591 100644 --- a/src/seedpass/core/entry_management.py +++ b/src/seedpass/core/entry_management.py @@ -461,7 +461,7 @@ class EntryManager: seed_bytes = Bip39SeedGenerator(parent_seed).Generate() bip85 = BIP85(seed_bytes) - entropy = bip85.derive_entropy(index=index, bytes_len=32) + entropy = bip85.derive_entropy(index=index, entropy_bytes=32) keys = Keys(priv_k=entropy.hex()) npub = Keys.hex_to_bech32(keys.public_key_hex(), "npub") nsec = Keys.hex_to_bech32(keys.private_key_hex(), "nsec") @@ -539,7 +539,7 @@ class EntryManager: bip85 = BIP85(seed_bytes) key_idx = int(entry.get("index", index)) - entropy = bip85.derive_entropy(index=key_idx, bytes_len=32) + entropy = bip85.derive_entropy(index=key_idx, entropy_bytes=32) keys = Keys(priv_k=entropy.hex()) npub = Keys.hex_to_bech32(keys.public_key_hex(), "npub") nsec = Keys.hex_to_bech32(keys.private_key_hex(), "nsec") diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index f898ba0..92db5fe 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -280,13 +280,15 @@ class PasswordManager: ) @requires_unlocked - def get_bip85_entropy(self, purpose: int, index: int, bytes_len: int = 32) -> bytes: + def get_bip85_entropy( + self, purpose: int, index: int, entropy_bytes: int = 32 + ) -> bytes: """Return deterministic entropy via the cached BIP-85 function.""" if self.bip85 is None: raise RuntimeError("BIP-85 is not initialized") return self.bip85.derive_entropy( - index=index, bytes_len=bytes_len, app_no=purpose + index=index, entropy_bytes=entropy_bytes, app_no=purpose ) @requires_unlocked @@ -1243,11 +1245,13 @@ class PasswordManager: self._bip85_cache = {} orig_derive = self.bip85.derive_entropy - def cached_derive(index: int, bytes_len: int, app_no: int = 39) -> bytes: + def cached_derive( + index: int, entropy_bytes: int, app_no: int = 39 + ) -> bytes: key = (app_no, index) if key not in self._bip85_cache: self._bip85_cache[key] = orig_derive( - index=index, bytes_len=bytes_len, app_no=app_no + index=index, entropy_bytes=entropy_bytes, app_no=app_no ) return self._bip85_cache[key] @@ -2727,14 +2731,14 @@ class PasswordManager: from bip_utils import Bip39SeedGenerator words = int(entry.get("word_count", entry.get("words", 24))) - bytes_len = {12: 16, 18: 24, 24: 32}.get(words, 32) + entropy_bytes = {12: 16, 18: 24, 24: 32}.get(words, 32) seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate() bip85 = BIP85(seed_bytes) entropy = bip85.derive_entropy( index=int(entry.get("index", index)), - bytes_len=bytes_len, + entropy_bytes=entropy_bytes, app_no=39, - words_len=words, + word_count=words, ) print(color_text(f"Entropy: {entropy.hex()}", "deterministic")) except Exception as e: # pragma: no cover - best effort diff --git a/src/seedpass/core/password_generation.py b/src/seedpass/core/password_generation.py index 0e45a4c..26b1e6e 100644 --- a/src/seedpass/core/password_generation.py +++ b/src/seedpass/core/password_generation.py @@ -126,7 +126,7 @@ class PasswordGenerator: def _derive_password_entropy(self, index: int) -> bytes: """Derive deterministic entropy for password generation.""" - entropy = self.bip85.derive_entropy(index=index, bytes_len=64, app_no=32) + entropy = self.bip85.derive_entropy(index=index, entropy_bytes=64, app_no=32) logger.debug("Entropy derived for password generation.") hkdf = HKDF( @@ -433,7 +433,7 @@ class PasswordGenerator: def derive_ssh_key(bip85: BIP85, idx: int) -> bytes: """Derive 32 bytes of entropy suitable for an SSH key.""" - return bip85.derive_entropy(index=idx, bytes_len=32, app_no=32) + return bip85.derive_entropy(index=idx, entropy_bytes=32, app_no=32) def derive_ssh_key_pair(parent_seed: str, index: int) -> tuple[str, str]: @@ -499,7 +499,7 @@ def derive_pgp_key( import hashlib import datetime - entropy = bip85.derive_entropy(index=idx, bytes_len=32, app_no=32) + entropy = bip85.derive_entropy(index=idx, entropy_bytes=32, app_no=32) created = datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc) if key_type.lower() == "rsa": diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index c3e2de1..22eec24 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -501,8 +501,10 @@ async def test_generate_password_no_special_chars(client): return b"\x00" * 32 class DummyBIP85: - def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: - return bytes(range(bytes_len)) + def derive_entropy( + self, index: int, entropy_bytes: int, app_no: int = 32 + ) -> bytes: + return bytes(range(entropy_bytes)) api.app.state.pm.password_generator = PasswordGenerator( DummyEnc(), "seed", DummyBIP85() @@ -529,8 +531,10 @@ async def test_generate_password_allowed_chars(client): return b"\x00" * 32 class DummyBIP85: - def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: - return bytes((index + i) % 256 for i in range(bytes_len)) + def derive_entropy( + self, index: int, entropy_bytes: int, app_no: int = 32 + ) -> bytes: + return bytes((index + i) % 256 for i in range(entropy_bytes)) api.app.state.pm.password_generator = PasswordGenerator( DummyEnc(), "seed", DummyBIP85() diff --git a/src/tests/test_bip85_derivation_path.py b/src/tests/test_bip85_derivation_path.py new file mode 100644 index 0000000..1280703 --- /dev/null +++ b/src/tests/test_bip85_derivation_path.py @@ -0,0 +1,52 @@ +from local_bip85.bip85 import BIP85 + + +class DummyChild: + def PrivateKey(self): + return self + + def Raw(self): + return self + + def ToBytes(self): + return b"\x00" * 32 + + +class DummyCtx: + def __init__(self): + self.last_path = None + + def DerivePath(self, path: str): + self.last_path = path + return DummyChild() + + +def test_derivation_paths_for_entropy_lengths(): + bip85 = BIP85(b"\x00" * 64) + ctx = DummyCtx() + bip85.bip32_ctx = ctx + + vectors = [ + (16, 12), + (24, 18), + (32, 24), + ] + + for entropy_bytes, word_count in vectors: + bip85.derive_entropy( + index=0, + entropy_bytes=entropy_bytes, + app_no=39, + word_count=word_count, + ) + assert ctx.last_path == f"m/83696968'/39'/0'/{word_count}'/0'" + + +def test_default_word_count_from_entropy_bytes(): + bip85 = BIP85(b"\x00" * 64) + ctx = DummyCtx() + bip85.bip32_ctx = ctx + + bip85.derive_entropy(index=5, entropy_bytes=20, app_no=39) + + assert ctx.last_path == "m/83696968'/39'/0'/20'/5'" diff --git a/src/tests/test_entry_policy_override.py b/src/tests/test_entry_policy_override.py index c23c1c8..79b31ff 100644 --- a/src/tests/test_entry_policy_override.py +++ b/src/tests/test_entry_policy_override.py @@ -21,8 +21,8 @@ class DummyEnc: class DummyBIP85: - def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: - return bytes((index + i) % 256 for i in range(bytes_len)) + def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes: + return bytes((index + i) % 256 for i in range(entropy_bytes)) def make_manager(tmp_path: Path) -> PasswordManager: diff --git a/src/tests/test_password_generation_policy.py b/src/tests/test_password_generation_policy.py index 0a90101..62a2095 100644 --- a/src/tests/test_password_generation_policy.py +++ b/src/tests/test_password_generation_policy.py @@ -13,8 +13,8 @@ class DummyEnc: class DummyBIP85: - def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: - return bytes((index + i) % 256 for i in range(bytes_len)) + def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes: + return bytes((index + i) % 256 for i in range(entropy_bytes)) def make_generator(policy=None): diff --git a/src/tests/test_password_helpers.py b/src/tests/test_password_helpers.py index b41f186..fb80683 100644 --- a/src/tests/test_password_helpers.py +++ b/src/tests/test_password_helpers.py @@ -8,8 +8,8 @@ class DummyEnc: class DummyBIP85: - def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: - return bytes((index + i) % 256 for i in range(bytes_len)) + def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes: + return bytes((index + i) % 256 for i in range(entropy_bytes)) def make_generator(): diff --git a/src/tests/test_password_length_constraints.py b/src/tests/test_password_length_constraints.py index a800f9f..a6490ee 100644 --- a/src/tests/test_password_length_constraints.py +++ b/src/tests/test_password_length_constraints.py @@ -14,8 +14,8 @@ class DummyEnc: class DummyBIP85: - def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: - return bytes((index + i) % 256 for i in range(bytes_len)) + def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes: + return bytes((index + i) % 256 for i in range(entropy_bytes)) def make_generator(): diff --git a/src/tests/test_password_properties.py b/src/tests/test_password_properties.py index 0c5f2ba..ca0e748 100644 --- a/src/tests/test_password_properties.py +++ b/src/tests/test_password_properties.py @@ -15,8 +15,8 @@ class DummyEnc: class DummyBIP85: - def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: - return bytes((index + i) % 256 for i in range(bytes_len)) + def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes: + return bytes((index + i) % 256 for i in range(entropy_bytes)) def make_generator(): diff --git a/src/tests/test_password_shuffle_consistency.py b/src/tests/test_password_shuffle_consistency.py index 78f4235..8436d71 100644 --- a/src/tests/test_password_shuffle_consistency.py +++ b/src/tests/test_password_shuffle_consistency.py @@ -12,8 +12,8 @@ class DummyEnc: class DummyBIP85: - def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: - return bytes((index + i) % 256 for i in range(bytes_len)) + def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes: + return bytes((index + i) % 256 for i in range(entropy_bytes)) def make_generator(): diff --git a/src/tests/test_password_special_chars.py b/src/tests/test_password_special_chars.py index 38d6639..83ec587 100644 --- a/src/tests/test_password_special_chars.py +++ b/src/tests/test_password_special_chars.py @@ -15,8 +15,8 @@ class DummyEnc: class DummyBIP85: - def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: - return bytes((index + i) % 256 for i in range(bytes_len)) + def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes: + return bytes((index + i) % 256 for i in range(entropy_bytes)) def make_generator(policy=None): diff --git a/src/tests/test_password_special_modes.py b/src/tests/test_password_special_modes.py index 50a4019..f2871c2 100644 --- a/src/tests/test_password_special_modes.py +++ b/src/tests/test_password_special_modes.py @@ -14,8 +14,8 @@ class DummyEnc: class DummyBIP85: - def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: - return bytes((index + i) % 256 for i in range(bytes_len)) + def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes: + return bytes((index + i) % 256 for i in range(entropy_bytes)) def make_generator(policy=None): diff --git a/tests/perf/test_bip85_cache.py b/tests/perf/test_bip85_cache.py index 6c1af04..eea4663 100644 --- a/tests/perf/test_bip85_cache.py +++ b/tests/perf/test_bip85_cache.py @@ -9,10 +9,10 @@ class SlowBIP85: def __init__(self): self.calls = 0 - def derive_entropy(self, index: int, bytes_len: int, app_no: int = 39) -> bytes: + def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 39) -> bytes: self.calls += 1 time.sleep(0.01) - return b"\x00" * bytes_len + return b"\x00" * entropy_bytes def _setup_manager(bip85: SlowBIP85) -> PasswordManager: @@ -21,10 +21,12 @@ def _setup_manager(bip85: SlowBIP85) -> PasswordManager: pm.bip85 = bip85 orig = bip85.derive_entropy - def cached(index: int, bytes_len: int, app_no: int = 39) -> bytes: + def cached(index: int, entropy_bytes: int, app_no: int = 39) -> bytes: key = (app_no, index) if key not in pm._bip85_cache: - pm._bip85_cache[key] = orig(index=index, bytes_len=bytes_len, app_no=app_no) + pm._bip85_cache[key] = orig( + index=index, entropy_bytes=entropy_bytes, app_no=app_no + ) return pm._bip85_cache[key] bip85.derive_entropy = cached