diff --git a/src/password_manager/password_generation.py b/src/password_manager/password_generation.py index 670416c..52970ca 100644 --- a/src/password_manager/password_generation.py +++ b/src/password_manager/password_generation.py @@ -335,12 +335,6 @@ class PasswordGenerator: raise -def derive_totp_secret(bip85: BIP85, idx: int) -> str: - """Derive a TOTP secret for the given index using BIP85.""" - entropy = bip85.derive_entropy(index=idx, bytes_len=10, app_no=2) - return base64.b32encode(entropy).decode("utf-8") - - 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) diff --git a/src/tests/test_bip85_vectors.py b/src/tests/test_bip85_vectors.py index 21f872b..8d62aa3 100644 --- a/src/tests/test_bip85_vectors.py +++ b/src/tests/test_bip85_vectors.py @@ -6,10 +6,10 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from local_bip85.bip85 import BIP85, Bip85Error from password_manager.password_generation import ( - derive_totp_secret, derive_ssh_key, derive_seed_phrase, ) +from utils.key_derivation import derive_totp_secret MASTER_XPRV = "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb" @@ -18,7 +18,7 @@ EXPECTED_12 = "girl mad pet galaxy egg matter matrix prison refuse sense ordinar EXPECTED_24 = "puppy ocean match cereal symbol another shed magic wrap hammer bulb intact gadget divorce twin tonight reason outdoor destroy simple truth cigar social volcano" EXPECTED_SYMM_KEY = "7040bb53104f27367f317558e78a994ada7296c6fde36a364e5baf206e502bb1" -EXPECTED_TOTP_SECRET = "OBALWUYQJ4TTM7ZR" +EXPECTED_TOTP_SECRET = "VQYTWDNEWYBY2G3LOGGCEKR4LZ3LNEYY" EXPECTED_SSH_KEY = "52405cd0dd21c5be78314a7c1a3c65ffd8d896536cc7dee3157db5824f0c92e2" @@ -39,8 +39,8 @@ def test_bip85_symmetric_key(bip85): assert bip85.derive_symmetric_key(index=0).hex() == EXPECTED_SYMM_KEY -def test_derive_totp_secret(bip85): - assert derive_totp_secret(bip85, 0) == EXPECTED_TOTP_SECRET +def test_derive_totp_secret(): + assert derive_totp_secret(EXPECTED_24, 0) == EXPECTED_TOTP_SECRET def test_derive_ssh_key(bip85): diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 7ea671d..5a96481 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -11,8 +11,10 @@ try: derive_key_from_password, derive_key_from_parent_seed, derive_index_key, + derive_totp_secret, EncryptionMode, DEFAULT_ENCRYPTION_MODE, + TOTP_PURPOSE, ) from .checksum import ( calculate_checksum, @@ -33,8 +35,10 @@ __all__ = [ "derive_key_from_password", "derive_key_from_parent_seed", "derive_index_key", + "derive_totp_secret", "EncryptionMode", "DEFAULT_ENCRYPTION_MODE", + "TOTP_PURPOSE", "calculate_checksum", "verify_checksum", "json_checksum", diff --git a/src/utils/key_derivation.py b/src/utils/key_derivation.py index 8c164dc..d71b26c 100644 --- a/src/utils/key_derivation.py +++ b/src/utils/key_derivation.py @@ -20,6 +20,7 @@ import base64 import unicodedata import logging import traceback +import hmac from enum import Enum from typing import Optional, Union from bip_utils import Bip39SeedGenerator @@ -40,6 +41,9 @@ class EncryptionMode(Enum): DEFAULT_ENCRYPTION_MODE = EncryptionMode.SEED_ONLY +# Purpose constant for TOTP secret derivation using BIP85 +TOTP_PURPOSE = 39 + def derive_key_from_password(password: str, iterations: int = 100_000) -> bytes: """ @@ -159,10 +163,22 @@ def derive_totp_secret(seed: str, index: int) -> str: try: from local_bip85 import BIP85 + # Initialize BIP85 from the BIP39 seed bytes seed_bytes = Bip39SeedGenerator(seed).Generate() bip85 = BIP85(seed_bytes) - entropy = bip85.derive_entropy(index=index, bytes_len=10, app_no=2) - secret = base64.b32encode(entropy).decode("utf-8") + + # Build the BIP32 path m/83696968'/39'/TOTP'/{index}' + totp_int = int.from_bytes(b"TOTP", "big") + path = f"m/83696968'/{TOTP_PURPOSE}'/{totp_int}'/{index}'" + + # Derive entropy using the same scheme as BIP85 + child_key = bip85.bip32_ctx.DerivePath(path) + key_bytes = child_key.PrivateKey().Raw().ToBytes() + entropy = hmac.new(b"bip-entropy-from-k", key_bytes, hashlib.sha512).digest() + + # Hash the first 32 bytes of entropy and encode the first 20 bytes + hashed = hashlib.sha256(entropy[:32]).digest() + secret = base64.b32encode(hashed[:20]).decode("utf-8") logger.debug(f"Derived TOTP secret for index {index}: {secret}") return secret except Exception as e: