diff --git a/README.md b/README.md index bfcaee4..6626ce6 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - [Usage](#usage) - [Running the Application](#running-the-application) - [Managing Multiple Seeds](#managing-multiple-seeds) + - [Additional Entry Types](#additional-entry-types) - [Security Considerations](#security-considerations) - [Contributing](#contributing) - [License](#license) @@ -256,6 +257,14 @@ When **Secret Mode** is enabled, SeedPass copies retrieved passwords directly to 2. Choose how many seconds to keep passwords on the clipboard. 3. Retrieve an entry and SeedPass will confirm the password was copied. +### Additional Entry Types + +SeedPass supports storing more than just passwords and 2FA secrets. You can also create entries for: +- **SSH Key** – deterministically derive an Ed25519 key pair for servers or git hosting platforms. +- **Seed Phrase** – generate a BIP-39 mnemonic and keep it encrypted until needed. +- **PGP Key** – derive an OpenPGP key pair from your master seed. +- **Nostr Key Pair** – store the index used to derive an `npub`/`nsec` pair for Nostr clients. + ### Managing Multiple Seeds diff --git a/docs/json_entries.md b/docs/json_entries.md index 1225f90..9d09834 100644 --- a/docs/json_entries.md +++ b/docs/json_entries.md @@ -87,12 +87,13 @@ Each SeedPass entry is stored as an individual JSON file, promoting isolated man - **fingerprint** (`string`): A unique identifier generated from the seed associated with the entry. This fingerprint ensures that each seed's data is isolated and securely managed. -- **kind** (`string`): Specifies the type of entry. Initial kinds include: - - `generated_password` - - `stored_password` - - `managed_user` - - `12_word_seed` - - `nostr_keys` +- **kind** (`string`): Specifies the type of entry. Supported kinds include: + - `password` + - `totp` + - `ssh` + - `seed` + - `pgp` + - `nostr` - `note` - **data** (`object`): Contains fields specific to the `kind`. This allows for extensibility as new kinds are introduced. @@ -104,6 +105,17 @@ Each SeedPass entry is stored as an individual JSON file, promoting isolated man - **updated_at** (`string`): ISO 8601 format timestamp of the last update. - **checksum** (`string`): A checksum value to ensure data integrity. +- **custom_fields** (`array`, optional): A list of user-defined name/value pairs + to store extra information. + Example: + + ```json + "custom_fields": [ + {"name": "account_id", "value": "123"}, + {"name": "recovery_hint", "value": "mother's maiden name"} + ] + ``` + ### Example Entries #### 1. Generated Password @@ -121,6 +133,9 @@ Each SeedPass entry is stored as an individual JSON file, promoting isolated man "url": "https://example.com", "password": "" }, + "custom_fields": [ + {"name": "department", "value": "finance"} + ], "timestamp": "2024-04-27T12:34:56Z", "metadata": { "created_at": "2024-04-27T12:34:56Z", diff --git a/src/main.py b/src/main.py index 2e3a493..0b56a64 100644 --- a/src/main.py +++ b/src/main.py @@ -731,8 +731,9 @@ def display_menu( print("2. 2FA (TOTP)") print("3. SSH Key") print("4. Seed Phrase") - print("5. PGP Key") - print("6. Back") + print("5. Nostr Key Pair") + print("6. PGP Key") + print("7. Back") sub_choice = input("Select entry type: ").strip() password_manager.update_activity() if sub_choice == "1": @@ -748,9 +749,12 @@ def display_menu( password_manager.handle_add_seed() break elif sub_choice == "5": - password_manager.handle_add_pgp() + password_manager.handle_add_nostr_key() break elif sub_choice == "6": + password_manager.handle_add_pgp() + break + elif sub_choice == "7": break else: print(colored("Invalid choice.", "red")) diff --git a/src/nostr/key_manager.py b/src/nostr/key_manager.py index 2436914..ed6ca88 100644 --- a/src/nostr/key_manager.py +++ b/src/nostr/key_manager.py @@ -128,3 +128,14 @@ class KeyManager: except Exception as e: logger.error(f"Failed to generate npub: {e}", exc_info=True) raise + + def get_nsec(self) -> str: + """Return the nsec (Bech32 encoded private key).""" + try: + priv_hex = self.get_private_key_hex() + priv_bytes = bytes.fromhex(priv_hex) + data = convertbits(priv_bytes, 8, 5, True) + return bech32_encode("nsec", data) + except Exception as e: + logger.error(f"Failed to generate nsec: {e}", exc_info=True) + raise diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 7f1aee7..48a6081 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -112,6 +112,7 @@ class EntryManager: url: Optional[str] = None, blacklisted: bool = False, notes: str = "", + custom_fields: List[Dict[str, Any]] | None = None, ) -> int: """ Adds a new entry to the encrypted JSON index file. @@ -138,6 +139,7 @@ class EntryManager: "type": EntryType.PASSWORD.value, "kind": EntryType.PASSWORD.value, "notes": notes, + "custom_fields": custom_fields or [], } logger.debug(f"Added entry at index {index}: {data['entries'][str(index)]}") @@ -308,6 +310,56 @@ class EntryManager: user_id = entry.get("user_id", "") return derive_pgp_key(bip85, key_idx, key_type, user_id) + def add_nostr_key( + self, + label: str, + index: int | None = None, + notes: str = "", + ) -> int: + """Add a new Nostr key pair entry.""" + + if index is None: + index = self.get_next_index() + + data = self.vault.load_index() + data.setdefault("entries", {}) + data["entries"][str(index)] = { + "type": EntryType.NOSTR.value, + "kind": EntryType.NOSTR.value, + "index": index, + "label": label, + "notes": notes, + } + self._save_index(data) + self.update_checksum() + self.backup_manager.create_backup() + return index + + def get_nostr_key_pair(self, index: int, parent_seed: str) -> tuple[str, str]: + """Return the npub and nsec for the specified entry.""" + + entry = self.retrieve_entry(index) + etype = entry.get("type") if entry else None + kind = entry.get("kind") if entry else None + if not entry or ( + etype != EntryType.NOSTR.value and kind != EntryType.NOSTR.value + ): + raise ValueError("Entry is not a Nostr key entry") + + from local_bip85.bip85 import BIP85 + from bip_utils import Bip39SeedGenerator + from nostr.coincurve_keys import Keys + + seed_bytes = Bip39SeedGenerator(parent_seed).Generate() + bip85 = BIP85(seed_bytes) + + key_idx = int(entry.get("index", index)) + entropy = bip85.derive_entropy(index=key_idx, bytes_len=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") + return npub, nsec + def add_seed( self, parent_seed: str, @@ -414,6 +466,8 @@ class EntryManager: entry = data.get("entries", {}).get(str(index)) if entry: + if entry.get("type", entry.get("kind")) == EntryType.PASSWORD.value: + entry.setdefault("custom_fields", []) logger.debug(f"Retrieved entry at index {index}: {entry}") return entry else: @@ -441,6 +495,7 @@ class EntryManager: label: Optional[str] = None, period: Optional[int] = None, digits: Optional[int] = None, + custom_fields: List[Dict[str, Any]] | None = None, ) -> None: """ Modifies an existing entry based on the provided index and new values. @@ -500,6 +555,12 @@ class EntryManager: entry["notes"] = notes logger.debug(f"Updated notes for index {index}.") + if custom_fields is not None: + entry["custom_fields"] = custom_fields + logger.debug( + f"Updated custom fields for index {index}: {custom_fields}" + ) + data["entries"][str(index)] = entry logger.debug(f"Modified entry at index {index}: {entry}") @@ -629,11 +690,18 @@ class EntryManager: username = entry.get("username", "") url = entry.get("url", "") notes = entry.get("notes", "") + custom_fields = entry.get("custom_fields", []) + custom_match = any( + query_lower in str(cf.get("label", "")).lower() + or query_lower in str(cf.get("value", "")).lower() + for cf in custom_fields + ) if ( query_lower in website.lower() or query_lower in username.lower() or query_lower in url.lower() or query_lower in notes.lower() + or custom_match ): results.append( ( diff --git a/src/password_manager/entry_types.py b/src/password_manager/entry_types.py index 186180b..5108925 100644 --- a/src/password_manager/entry_types.py +++ b/src/password_manager/entry_types.py @@ -12,3 +12,4 @@ class EntryType(str, Enum): SSH = "ssh" SEED = "seed" PGP = "pgp" + NOSTR = "nostr" diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index a9a8d90..9f0ebc4 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -867,6 +867,18 @@ class PasswordManager: url = input("Enter the URL (optional): ").strip() notes = input("Enter notes (optional): ").strip() + custom_fields: list[dict[str, object]] = [] + while True: + add_field = input("Add custom field? (y/N): ").strip().lower() + if add_field != "y": + break + label = input(" Field label: ").strip() + value = input(" Field value: ").strip() + hidden = input(" Hidden field? (y/N): ").strip().lower() == "y" + custom_fields.append( + {"label": label, "value": value, "is_hidden": hidden} + ) + length_input = input( f"Enter desired password length (default {DEFAULT_PASSWORD_LENGTH}): " ).strip() @@ -893,6 +905,7 @@ class PasswordManager: url, blacklisted=False, notes=notes, + custom_fields=custom_fields, ) # Mark database as dirty for background sync @@ -1110,6 +1123,38 @@ class PasswordManager: logging.error(f"Error during PGP key setup: {e}", exc_info=True) print(colored(f"Error: Failed to add PGP key: {e}", "red")) + def handle_add_nostr_key(self) -> None: + """Add a Nostr key entry and display the derived keys.""" + try: + label = input("Label (optional): ").strip() + notes = input("Notes (optional): ").strip() + index = self.entry_manager.add_nostr_key(label, notes=notes) + npub, nsec = self.entry_manager.get_nostr_key_pair(index, self.parent_seed) + self.is_dirty = True + self.last_update = time.time() + print(colored(f"\n[+] Nostr key entry added with ID {index}.\n", "green")) + print(colored(f"npub: {npub}", "cyan")) + if self.secret_mode_enabled: + copy_to_clipboard(nsec, self.clipboard_clear_delay) + print( + colored( + f"[+] nsec copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored(f"nsec: {nsec}", "cyan")) + try: + self.sync_vault() + except Exception as nostr_error: # pragma: no cover - best effort + logging.error( + f"Failed to post updated index to Nostr: {nostr_error}", + exc_info=True, + ) + except Exception as e: + logging.error(f"Error during Nostr key setup: {e}", exc_info=True) + print(colored(f"Error: Failed to add Nostr key: {e}", "red")) + def handle_retrieve_entry(self) -> None: """ Handles retrieving a password from the index by prompting the user for the index number @@ -1270,6 +1315,32 @@ class PasswordManager: logging.error(f"Error deriving PGP key: {e}", exc_info=True) print(colored(f"Error: Failed to derive PGP key: {e}", "red")) return + if entry_type == EntryType.NOSTR.value: + label = entry.get("label", "") + notes = entry.get("notes", "") + try: + npub, nsec = self.entry_manager.get_nostr_key_pair( + index, self.parent_seed + ) + print(colored("\n[+] Retrieved Nostr Keys:\n", "green")) + print(colored(f"Label: {label}", "cyan")) + print(colored(f"npub: {npub}", "cyan")) + if self.secret_mode_enabled: + copy_to_clipboard(nsec, self.clipboard_clear_delay) + print( + colored( + f"[+] nsec copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored(f"nsec: {nsec}", "cyan")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) + except Exception as e: + logging.error(f"Error deriving Nostr keys: {e}", exc_info=True) + print(colored(f"Error: Failed to derive Nostr keys: {e}", "red")) + return website_name = entry.get("website") length = entry.get("length") @@ -1323,6 +1394,36 @@ class PasswordManager: "cyan", ) ) + custom_fields = entry.get("custom_fields", []) + if custom_fields: + print(colored("Additional Fields:", "cyan")) + hidden_fields = [] + for field in custom_fields: + label = field.get("label", "") + value = field.get("value", "") + if field.get("is_hidden"): + hidden_fields.append((label, value)) + print(colored(f" {label}: [hidden]", "cyan")) + else: + print(colored(f" {label}: {value}", "cyan")) + if hidden_fields: + show = ( + input("Reveal hidden fields? (y/N): ").strip().lower() + ) + if show == "y": + for label, value in hidden_fields: + if self.secret_mode_enabled: + copy_to_clipboard( + value, self.clipboard_clear_delay + ) + print( + colored( + f"[+] {label} copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored(f" {label}: {value}", "cyan")) else: print(colored("Error: Failed to retrieve the password.", "red")) except Exception as e: @@ -1429,6 +1530,20 @@ class PasswordManager: or notes ) + edit_fields = input("Edit custom fields? (y/N): ").strip().lower() + custom_fields = None + if edit_fields == "y": + custom_fields = [] + while True: + label = input(" Field label (leave blank to finish): ").strip() + if not label: + break + value = input(" Field value: ").strip() + hidden = input(" Hidden field? (y/N): ").strip().lower() == "y" + custom_fields.append( + {"label": label, "value": value, "is_hidden": hidden} + ) + self.entry_manager.modify_entry( index, blacklisted=new_blacklisted, @@ -1436,6 +1551,7 @@ class PasswordManager: label=new_label, period=new_period, digits=new_digits, + custom_fields=custom_fields, ) else: website_name = entry.get("website") @@ -1500,12 +1616,27 @@ class PasswordManager: or notes ) + edit_fields = input("Edit custom fields? (y/N): ").strip().lower() + custom_fields = None + if edit_fields == "y": + custom_fields = [] + while True: + label = input(" Field label (leave blank to finish): ").strip() + if not label: + break + value = input(" Field value: ").strip() + hidden = input(" Hidden field? (y/N): ").strip().lower() == "y" + custom_fields.append( + {"label": label, "value": value, "is_hidden": hidden} + ) + self.entry_manager.modify_entry( index, new_username, new_url, new_blacklisted, new_notes, + custom_fields=custom_fields, ) # Mark database as dirty for background sync diff --git a/src/password_manager/migrations.py b/src/password_manager/migrations.py index 3e20295..dbcb16a 100644 --- a/src/password_manager/migrations.py +++ b/src/password_manager/migrations.py @@ -39,7 +39,18 @@ def _v1_to_v2(data: dict) -> dict: return data -LATEST_VERSION = 2 +@migration(2) +def _v2_to_v3(data: dict) -> dict: + """Add custom_fields and origin defaults to each entry.""" + entries = data.get("entries", {}) + for entry in entries.values(): + entry.setdefault("custom_fields", []) + entry.setdefault("origin", "") + data["schema_version"] = 3 + return data + + +LATEST_VERSION = 3 def apply_migrations(data: dict) -> dict: diff --git a/src/tests/test_backup_restore.py b/src/tests/test_backup_restore.py index 312f14a..1c349e1 100644 --- a/src/tests/test_backup_restore.py +++ b/src/tests/test_backup_restore.py @@ -22,7 +22,7 @@ def test_backup_restore_workflow(monkeypatch): index_file = fp_dir / "seedpass_entries_db.json.enc" data1 = { - "schema_version": 2, + "schema_version": 3, "entries": { "0": { "website": "a", @@ -30,6 +30,8 @@ def test_backup_restore_workflow(monkeypatch): "type": "password", "kind": "password", "notes": "", + "custom_fields": [], + "origin": "", } }, } @@ -43,7 +45,7 @@ def test_backup_restore_workflow(monkeypatch): assert backup1.stat().st_mode & 0o777 == 0o600 data2 = { - "schema_version": 2, + "schema_version": 3, "entries": { "0": { "website": "b", @@ -51,6 +53,8 @@ def test_backup_restore_workflow(monkeypatch): "type": "password", "kind": "password", "notes": "", + "custom_fields": [], + "origin": "", } }, } @@ -63,11 +67,11 @@ def test_backup_restore_workflow(monkeypatch): assert backup2.exists() assert backup2.stat().st_mode & 0o777 == 0o600 - vault.save_index({"schema_version": 2, "entries": {"temp": {}}}) + vault.save_index({"schema_version": 3, "entries": {"temp": {}}}) backup_mgr.restore_latest_backup() assert vault.load_index()["entries"] == data2["entries"] - vault.save_index({"schema_version": 2, "entries": {}}) + vault.save_index({"schema_version": 3, "entries": {}}) backup_mgr.restore_backup_by_timestamp(1111) assert vault.load_index()["entries"] == data1["entries"] @@ -85,7 +89,7 @@ def test_additional_backup_location(monkeypatch): cfg_mgr.set_additional_backup_path(extra) backup_mgr = BackupManager(fp_dir, cfg_mgr) - vault.save_index({"schema_version": 2, "entries": {"a": {}}}) + vault.save_index({"schema_version": 3, "entries": {"a": {}}}) monkeypatch.setattr(time, "time", lambda: 3333) backup_mgr.create_backup() diff --git a/src/tests/test_cli_invalid_input.py b/src/tests/test_cli_invalid_input.py index c3c1f20..f16a147 100644 --- a/src/tests/test_cli_invalid_input.py +++ b/src/tests/test_cli_invalid_input.py @@ -77,7 +77,7 @@ def test_out_of_range_menu(monkeypatch, capsys): def test_invalid_add_entry_submenu(monkeypatch, capsys): called = {"add": False, "retrieve": False, "modify": False} pm, _ = _make_pm(called) - inputs = iter(["1", "7", "6", "7"]) + inputs = iter(["1", "8", "7", "7"]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) with pytest.raises(SystemExit): diff --git a/src/tests/test_custom_fields_display.py b/src/tests/test_custom_fields_display.py new file mode 100644 index 0000000..f8966fc --- /dev/null +++ b/src/tests/test_custom_fields_display.py @@ -0,0 +1,53 @@ +import sys +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace + +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.manager import PasswordManager, EncryptionMode +from password_manager.config_manager import ConfigManager + + +def test_retrieve_entry_shows_custom_fields(monkeypatch, capsys): + 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) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw") + pm.parent_seed = TEST_SEED + pm.nostr_client = SimpleNamespace() + pm.fingerprint_dir = tmp_path + pm.secret_mode_enabled = False + + entry_mgr.add_entry( + "example", + 8, + custom_fields=[ + {"label": "visible", "value": "shown", "is_hidden": False}, + {"label": "token", "value": "secret", "is_hidden": True}, + ], + ) + + inputs = iter(["0", "y"]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) + + pm.handle_retrieve_entry() + out = capsys.readouterr().out + assert "Additional Fields:" in out + assert "visible: shown" in out + assert "token" in out + assert "secret" in out diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index 8f6eb52..d323191 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -21,7 +21,12 @@ def test_add_and_retrieve_entry(): backup_mgr = BackupManager(Path(tmpdir), cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) - index = entry_mgr.add_entry("example.com", 12, "user") + custom = [ + {"label": "api", "value": "123", "is_hidden": True}, + {"label": "note", "value": "hello", "is_hidden": False}, + ] + + index = entry_mgr.add_entry("example.com", 12, "user", custom_fields=custom) entry = entry_mgr.retrieve_entry(index) assert entry == { @@ -33,6 +38,7 @@ def test_add_and_retrieve_entry(): "type": "password", "kind": "password", "notes": "", + "custom_fields": custom, } data = enc_mgr.load_json_data(entry_mgr.index_file) diff --git a/src/tests/test_index_import_export.py b/src/tests/test_index_import_export.py index fce78c2..5742cf3 100644 --- a/src/tests/test_index_import_export.py +++ b/src/tests/test_index_import_export.py @@ -31,8 +31,16 @@ def test_index_export_import_round_trip(): vault = setup_vault(tmp) original = { - "schema_version": 2, - "entries": {"0": {"website": "example", "type": "password", "notes": ""}}, + "schema_version": 3, + "entries": { + "0": { + "website": "example", + "type": "password", + "notes": "", + "custom_fields": [], + "origin": "", + } + }, } vault.save_index(original) @@ -41,9 +49,15 @@ def test_index_export_import_round_trip(): vault.save_index( { - "schema_version": 2, + "schema_version": 3, "entries": { - "0": {"website": "changed", "type": "password", "notes": ""} + "0": { + "website": "changed", + "type": "password", + "notes": "", + "custom_fields": [], + "origin": "", + } }, } ) diff --git a/src/tests/test_manager_workflow.py b/src/tests/test_manager_workflow.py index 60ccf14..3a1a59e 100644 --- a/src/tests/test_manager_workflow.py +++ b/src/tests/test_manager_workflow.py @@ -52,14 +52,16 @@ def test_manager_workflow(monkeypatch): "example.com", "", # username "", # url - "", # length (default) "", # notes + "n", # add custom field + "", # length (default) "0", # retrieve index "0", # modify index "user", # new username "", # new url "", # blacklist status "", # new notes + "n", # edit custom fields ] ) monkeypatch.setattr("builtins.input", lambda *args, **kwargs: next(inputs)) diff --git a/src/tests/test_migrations.py b/src/tests/test_migrations.py index a1cd7e4..87106cc 100644 --- a/src/tests/test_migrations.py +++ b/src/tests/test_migrations.py @@ -13,17 +13,24 @@ def setup(tmp_path: Path): return enc_mgr, vault -def test_migrate_v0_to_v2(tmp_path: Path): +def test_migrate_v0_to_v3(tmp_path: Path): enc_mgr, vault = setup(tmp_path) legacy = {"passwords": {"0": {"website": "a", "length": 8}}} enc_mgr.save_json_data(legacy) data = vault.load_index() assert data["schema_version"] == LATEST_VERSION - expected_entry = {"website": "a", "length": 8, "type": "password", "notes": ""} + expected_entry = { + "website": "a", + "length": 8, + "type": "password", + "notes": "", + "custom_fields": [], + "origin": "", + } assert data["entries"]["0"] == expected_entry -def test_migrate_v1_to_v2(tmp_path: Path): +def test_migrate_v1_to_v3(tmp_path: Path): enc_mgr, vault = setup(tmp_path) legacy = {"schema_version": 1, "passwords": {"0": {"website": "b", "length": 10}}} enc_mgr.save_json_data(legacy) @@ -34,6 +41,30 @@ def test_migrate_v1_to_v2(tmp_path: Path): "length": 10, "type": "password", "notes": "", + "custom_fields": [], + "origin": "", + } + assert data["entries"]["0"] == expected_entry + + +def test_migrate_v2_to_v3(tmp_path: Path): + enc_mgr, vault = setup(tmp_path) + legacy = { + "schema_version": 2, + "entries": { + "0": {"website": "c", "length": 5, "type": "password", "notes": ""} + }, + } + enc_mgr.save_json_data(legacy) + data = vault.load_index() + assert data["schema_version"] == LATEST_VERSION + expected_entry = { + "website": "c", + "length": 5, + "type": "password", + "notes": "", + "custom_fields": [], + "origin": "", } assert data["entries"]["0"] == expected_entry diff --git a/src/tests/test_nostr_entry.py b/src/tests/test_nostr_entry.py new file mode 100644 index 0000000..5e8102b --- /dev/null +++ b/src/tests/test_nostr_entry.py @@ -0,0 +1,38 @@ +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.vault import Vault +from password_manager.config_manager import ConfigManager + + +def test_nostr_key_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 = entry_mgr.add_nostr_key("main") + entry = entry_mgr.retrieve_entry(idx) + assert entry == { + "type": "nostr", + "kind": "nostr", + "index": idx, + "label": "main", + "notes": "", + } + + npub1, nsec1 = entry_mgr.get_nostr_key_pair(idx, TEST_SEED) + npub2, nsec2 = entry_mgr.get_nostr_key_pair(idx, TEST_SEED) + assert npub1 == npub2 + assert nsec1 == nsec2 + assert npub1.startswith("npub") + assert nsec1.startswith("nsec") diff --git a/src/tests/test_search_entries.py b/src/tests/test_search_entries.py index b8e163f..7f133ed 100644 --- a/src/tests/test_search_entries.py +++ b/src/tests/test_search_entries.py @@ -71,6 +71,21 @@ def test_search_by_notes_and_totp(): assert res_totp == [(idx_totp, "GH", None, None, False)] +def test_search_by_custom_field(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + entry_mgr = setup_entry_manager(tmp_path) + + custom = [ + {"label": "api", "value": "secret123", "is_hidden": True}, + {"label": "note", "value": "visible", "is_hidden": False}, + ] + idx = entry_mgr.add_entry("Example", 8, custom_fields=custom) + + result = entry_mgr.search_entries("secret123") + assert result == [(idx, "Example", "", "", False)] + + def test_search_no_results(): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir)