diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 15819cd..6fab0a5 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -246,16 +246,48 @@ class EntryManager: key_index = int(entry.get("index", index)) return derive_ssh_key_pair(parent_seed, key_index) - def add_seed(self, notes: str = "") -> int: - """Placeholder for adding a seed entry.""" - index = self.get_next_index() + def add_seed( + self, + parent_seed: str, + index: int | None = None, + words_num: int = 24, + notes: str = "", + ) -> int: + """Add a new derived seed phrase entry.""" + + if index is None: + index = self.get_next_index() + data = self.vault.load_index() data.setdefault("entries", {}) - data["entries"][str(index)] = {"type": EntryType.SEED.value, "notes": notes} + data["entries"][str(index)] = { + "type": EntryType.SEED.value, + "index": index, + "words": words_num, + "notes": notes, + } self._save_index(data) self.update_checksum() self.backup_manager.create_backup() - raise NotImplementedError("Seed entry support not implemented yet") + return index + + def get_seed_phrase(self, index: int, parent_seed: str) -> str: + """Return the mnemonic seed phrase for the given entry.""" + + entry = self.retrieve_entry(index) + if not entry or entry.get("type") != EntryType.SEED.value: + raise ValueError("Entry is not a seed entry") + + from password_manager.password_generation import derive_seed_phrase + from local_bip85.bip85 import BIP85 + from bip_utils import Bip39SeedGenerator + + seed_bytes = Bip39SeedGenerator(parent_seed).Generate() + bip85 = BIP85(seed_bytes) + + words = int(entry.get("words", 24)) + seed_index = int(entry.get("index", index)) + return derive_seed_phrase(bip85, seed_index, words) def get_totp_code( self, index: int, parent_seed: str | None = None, timestamp: int | None = None diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 01ff8f4..bd607ad 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1121,6 +1121,42 @@ class PasswordManager: logging.error(f"Error deriving SSH key pair: {e}", exc_info=True) print(colored(f"Error: Failed to derive SSH keys: {e}", "red")) return + if entry_type == EntryType.SEED.value: + notes = entry.get("notes", "") + try: + phrase = self.entry_manager.get_seed_phrase(index, self.parent_seed) + if self.secret_mode_enabled: + copy_to_clipboard(phrase, self.clipboard_clear_delay) + print( + colored( + f"[+] Seed phrase copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored("\n[+] Retrieved Seed Phrase:\n", "green")) + print(colored(phrase, "yellow")) + if confirm_action("Show derived entropy as hex? (Y/N): "): + from local_bip85.bip85 import BIP85 + from bip_utils import Bip39SeedGenerator + + words = int(entry.get("words", 24)) + bytes_len = {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, + app_no=39, + words_len=words, + ) + print(colored(f"Entropy: {entropy.hex()}", "cyan")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) + except Exception as e: + logging.error(f"Error deriving seed phrase: {e}", exc_info=True) + print(colored(f"Error: Failed to derive seed phrase: {e}", "red")) + return website_name = entry.get("website") length = entry.get("length") diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index 5540851..75fecab 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -63,10 +63,10 @@ def test_round_trip_entry_types(method, expected_type): else: if method == "add_ssh_key": index = entry_mgr.add_ssh_key(TEST_SEED) + elif method == "add_seed": + index = entry_mgr.add_seed(TEST_SEED) else: - with pytest.raises(NotImplementedError): - getattr(entry_mgr, method)() - index = 0 + index = getattr(entry_mgr, method)() entry = entry_mgr.retrieve_entry(index) assert entry["type"] == expected_type diff --git a/src/tests/test_seed_entry.py b/src/tests/test_seed_entry.py new file mode 100644 index 0000000..332240b --- /dev/null +++ b/src/tests/test_seed_entry.py @@ -0,0 +1,41 @@ +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import create_vault, TEST_SEED, TEST_PASSWORD + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager +from password_manager.config_manager import ConfigManager +from password_manager.password_generation import derive_seed_phrase +from local_bip85.bip85 import BIP85 +from bip_utils import Bip39SeedGenerator + + +def test_seed_phrase_determinism(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + idx_12 = entry_mgr.add_seed(TEST_SEED, words_num=12) + idx_24 = entry_mgr.add_seed(TEST_SEED, words_num=24) + + phrase12_a = entry_mgr.get_seed_phrase(idx_12, TEST_SEED) + phrase12_b = entry_mgr.get_seed_phrase(idx_12, TEST_SEED) + phrase24_a = entry_mgr.get_seed_phrase(idx_24, TEST_SEED) + phrase24_b = entry_mgr.get_seed_phrase(idx_24, TEST_SEED) + + seed_bytes = Bip39SeedGenerator(TEST_SEED).Generate() + bip85 = BIP85(seed_bytes) + expected12 = derive_seed_phrase(bip85, idx_12, 12) + expected24 = derive_seed_phrase(bip85, idx_24, 24) + + assert phrase12_a == phrase12_b == expected12 + assert phrase24_a == phrase24_b == expected24 + assert len(phrase12_a.split()) == 12 + assert len(phrase24_a.split()) == 24