diff --git a/README.md b/README.md index f5fdb94..aa88e8d 100644 --- a/README.md +++ b/README.md @@ -254,9 +254,11 @@ When choosing **Add Entry**, you can now select **Password**, **2FA (TOTP)**, ### Modifying a 2FA Entry 1. From the main menu choose **Modify an Existing Entry** and enter the index of the 2FA code you want to edit. -2. SeedPass will show the current label, period, digit count, and blacklist status. +2. SeedPass will show the current label, period, digit count, and archived status. 3. Enter new values or press **Enter** to keep the existing settings. 4. The updated entry is saved back to your encrypted vault. +5. Archived entries are hidden from lists but can be viewed or restored from the **List Archived** menu. +6. When editing an archived entry you'll be prompted to restore it after saving your changes. ### Using Secret Mode @@ -270,12 +272,26 @@ When **Secret Mode** is enabled, SeedPass copies retrieved passwords directly to 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. +- **Seed Phrase** – store only the BIP-85 index and word count. The mnemonic is regenerated on demand. - **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. When you retrieve one of these entries, SeedPass can display QR codes for the keys. The `npub` is wrapped in the `nostr:` URI scheme so any client can scan it, while the `nsec` QR is shown only after a security warning. +- **Key/Value** – store a simple key and value for miscellaneous secrets or configuration data. + +The table below summarizes the extra fields stored for each entry type. Every +entry includes a `label`, while only password entries track a `url`. + +| Entry Type | Extra Fields | +|---------------|---------------------------------------------------------------------------------------------------------------------------------------| +| Password | `username`, `url`, `length`, `archived`, optional `notes`, optional `custom_fields` (may include hidden fields) | +| 2FA (TOTP) | `index` or `secret`, `period`, `digits`, `archived`, optional `notes` | +| SSH Key | `index`, `archived`, optional `notes` | +| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes` | +| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes` | +| Nostr Key Pair| `index`, `archived`, optional `notes` | +| Key/Value | `value`, `archived`, optional `notes`, optional `custom_fields` | ### Managing Multiple Seeds diff --git a/docs/README.md b/docs/README.md index 42dad0d..bd25730 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,12 +4,14 @@ This directory contains supplementary guides for using SeedPass. ## Quick Example: Get a TOTP Code -Run `seedpass get-code` to retrieve a time-based one-time password (TOTP). A progress bar shows the remaining seconds in the current period. +Run `seedpass totp ` to retrieve a time-based one-time password (TOTP). The +`` can be a label, title, or index. A progress bar shows the remaining +seconds in the current period. ```bash -$ seedpass get-code --index 0 +$ seedpass totp "email" [##########----------] 15s Code: 123456 ``` -See [advanced_cli.md](advanced_cli.md) for a full command reference. +See [advanced_cli.md](advanced_cli.md) (future feature set) for details on the upcoming advanced CLI. diff --git a/docs/advanced_cli.md b/docs/advanced_cli.md index 2f5aeda..6694314 100644 --- a/docs/advanced_cli.md +++ b/docs/advanced_cli.md @@ -1,8 +1,10 @@ -# Advanced CLI Commands Documentation +# Advanced CLI Commands Documentation (Future Feature Set) ## Overview The **Advanced CLI Commands** document provides an in-depth guide to the various command-line functionalities available in **SeedPass**, a secure password manager built on Bitcoin's BIP-85 standard. Designed for power users and developers, this guide outlines each command's purpose, usage, options, and examples to facilitate efficient and effective password management through the CLI. +> **Note:** This documentation describes planned functionality. The advanced CLI is not yet part of the stable release. It will align with the current SeedPass design, using fingerprint-based profiles and a forthcoming API to allow secure integration for power users and external applications. + --- @@ -36,9 +38,10 @@ The **Advanced CLI Commands** document provides an in-depth guide to the various - [24. Show All Passwords](#22-show-all-passwords) - [23. Add Notes to an Entry](#23-add-notes-to-an-entry) - [24. Add Tags to an Entry](#24-add-tags-to-an-entry) - - [25. Search by Tag or Title](#25-search-by-tag-or-title) - - [26. Automatically Post Deltas to Nostr After Edit](#26-automatically-post-deltas-to-nostr-after-edit) - - [27. Initial Setup Prompt for Seed Generation/Import](#27-initial-setup-prompt-for-seed-generationimport) + - [25. Add Key/Value Entry](#25-add-keyvalue-entry) + - [26. Search by Tag or Title](#26-search-by-tag-or-title) + - [27. Automatically Post Deltas to Nostr After Edit](#27-automatically-post-deltas-to-nostr-after-edit) + - [28. Initial Setup Prompt for Seed Generation/Import](#28-initial-setup-prompt-for-seed-generationimport) 3. [Notes on New CLI Commands](#notes-on-new-cli-commands) --- @@ -76,6 +79,7 @@ The following table provides a quick reference to all available advanced CLI com | Show All Passwords | `show-all` | `-SA` | `--show-all` | `seedpass show-all` | | Add Notes to an Entry | `add-notes` | `-AN` | `--add-notes` | `seedpass add-notes --index 3 --notes "This is a secured account"` | | Add Tags to an Entry | `add-tags` | `-AT` | `--add-tags` | `seedpass add-tags --index 3 --tags "personal,finance"` | +| Add Key/Value entry | `add-kv` | `-KV` | `--add-kv` | `seedpass add-kv --label "API" --value "secret"` | Search by Tag or Title | `search-by` | `-SB` | `--search-by` | `seedpass search-by --tag "work"` or `seedpass search-by --title "GitHub"` | | Automatically Post Deltas After Edit | `auto-post` | `-AP` | `--auto-post` | `seedpass auto-post --enable` or `seedpass auto-post --disable` | | Initial Setup Prompt for Seed Generation/Import | `setup` | `-ST` | `--setup` | `seedpass setup` | @@ -576,7 +580,28 @@ seedpass add-tags --index 3 --tags "personal,finance" --- -### 25. Search by Tag or Title +### 25. Add Key/Value Entry + +**Command:** `add-kv` +**Short Flag:** `-KV` +**Long Flag:** `--add-kv` + +**Description:** +Creates a simple key/value entry for storing items like API keys or configuration snippets. The value can be copied to the clipboard when secret mode is enabled. + +**Usage Example:** +```bash +seedpass add-kv --label "API" --value "secret" --notes "Service token" +``` + +**Options:** +- `--label` (`-L`): Descriptive label for the entry. +- `--value` (`-V`): The secret value to store. +- `--notes` (`-N`): Optional notes about the entry. + +--- + +### 26. Search by Tag or Title **Command:** `search-by` **Short Flag:** `-SB` @@ -597,7 +622,7 @@ seedpass search-by --title "GitHub" --- -### 26. Automatically Post Deltas to Nostr After Edit +### 27. Automatically Post Deltas to Nostr After Edit **Command:** `auto-post` **Short Flag:** `-AP` @@ -618,7 +643,7 @@ seedpass auto-post --disable --- -### 27. Initial Setup Prompt for Seed Generation/Import +### 28. Initial Setup Prompt for Seed Generation/Import **Command:** `setup` **Short Flag:** `-ST` @@ -845,6 +870,10 @@ seedpass fingerprint rename A1B2C3D4 PersonalProfile - **Nostr Integration:** - Ensure that your Nostr relays are reliable and secure. - Regularly verify your Nostr public key and manage relays through the `set-relays` command. +## Planned API Integration + +SeedPass will expose a local API that allows third-party applications and browser extensions to interact with your encrypted vault. The advanced CLI will act as a power-user client for these endpoints, mapping commands directly to the API while preserving the fingerprint-based security model. This design enables automation and programmatic access without compromising security. + --- diff --git a/docs/json_entries.md b/docs/json_entries.md index 1fd1e0b..263f0ea 100644 --- a/docs/json_entries.md +++ b/docs/json_entries.md @@ -29,23 +29,29 @@ ## Introduction -**SeedPass** is a secure password generator and manager leveraging **Bitcoin's BIP-85 standard** and integrating with the **Nostr network** for decentralized synchronization. Instead of pushing one large index file, SeedPass posts **snapshot chunks** of the index followed by lightweight **delta events** whenever changes occur. This chunked approach improves reliability and keeps bandwidth usage minimal. To enhance modularity, scalability, and security, SeedPass now manages each password or data entry as a separate JSON file within a **Fingerprint-Based Backup and Local Storage** system. This document outlines the new entry management system, ensuring that new `kind` types can be added seamlessly without disrupting existing functionalities. +**SeedPass** is a secure password generator and manager leveraging **Bitcoin's BIP-85 standard** and integrating with the **Nostr network** for decentralized synchronization. Instead of pushing one large index file, SeedPass posts **snapshot chunks** of the index followed by lightweight **delta events** whenever changes occur. This chunked approach improves reliability and keeps bandwidth usage minimal. To enhance modularity, scalability, and security, SeedPass stores all entries in a single encrypted index file named `seedpass_entries_db.json.enc`. This document outlines the entry management system, ensuring that new `kind` types can be added seamlessly without disrupting existing functionalities. --- ## Index File Format -All entries belonging to a seed profile are summarized in an encrypted file named `seedpass_entries_db.json.enc`. This index starts with `schema_version` `2` and contains an `entries` object keyed by entry numbers. +All entries belonging to a seed profile are stored in an encrypted file named `seedpass_entries_db.json.enc`. This index uses `schema_version` `3` and contains an `entries` object keyed by numeric identifiers. ```json { - "schema_version": 2, + "schema_version": 3, "entries": { "0": { "label": "example.com", "length": 8, + "username": "user", + "url": "https://example.com", + "archived": false, "type": "password", - "notes": "" + "kind": "password", + "notes": "", + "custom_fields": [], + "origin": "" } } } @@ -55,58 +61,40 @@ All entries belonging to a seed profile are summarized in an encrypted file name ## JSON Schema for Individual Entries -Each SeedPass entry is stored as an individual JSON file, promoting isolated management and easy synchronization with Nostr. This structure supports diverse entry types (`kind`) and allows for future expansions. +Each entry is stored within `seedpass_entries_db.json.enc` under the `entries` dictionary. The structure supports diverse entry types (`kind`) and allows for future expansions. ### General Structure ```json { - "entry_num": 0, - "index_num": 0, - "fingerprint": "a1b2c3d4", - "kind": "generated_password", - "data": { - // Fields specific to the kind - }, - "timestamp": "2024-04-27T12:34:56Z", - "metadata": { - "created_at": "2024-04-27T12:34:56Z", - "updated_at": "2024-04-27T12:34:56Z", - "checksum": "" - } + "label": "Example", + "length": 8, + "username": "user@example.com", + "url": "https://example.com", + "archived": false, + "type": "password", + "kind": "password", + "notes": "", + "custom_fields": [], + "origin": "", + "index": 0 } ``` ### Field Descriptions -- **entry_num** (`integer`): Sequential number of the entry starting from 0. Maintains the order of entries. - -- **index_num** (`integer` or `string`): - - For `generated_password` kind: Starts from 0 and increments sequentially. - - For other kinds: A secure random hexadecimal string (e.g., a hash of the content) used as the BIP-85 index. - -- **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. 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. - -- **timestamp** (`string`): ISO 8601 format timestamp indicating when the entry was created. - -- **metadata** (`object`): - - **created_at** (`string`): ISO 8601 format timestamp of creation. - - **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. +- **label** (`string`): Descriptive name for the entry (e.g., website or service). +- **length** (`integer`, optional): Desired password length for generated passwords. +- **username** (`string`, optional): Username associated with the entry. +- **url** (`string`, optional): Website or service URL. +- **archived** (`boolean`): Marks the entry as archived when `true`. +- **type** (`string`): The entry type (`password`, `totp`, `ssh`, `seed`, `pgp`, `nostr`, `note`, `key_value`). +- **kind** (`string`): Synonym for `type` kept for backward compatibility. +- **notes** (`string`): Free-form notes. +- **custom_fields** (`array`, optional): Additional user-defined fields. +- **origin** (`string`, optional): Source identifier for imported data. +- **value** (`string`, optional): For `key_value` entries, stores the secret value. +- **index** (`integer`, optional): BIP-85 derivation index for entries that derive material from a seed. Example: ```json @@ -249,6 +237,25 @@ Each SeedPass entry is stored as an individual JSON file, promoting isolated man } ``` +#### 7. Key/Value + +```json +{ + "entry_num": 6, + "fingerprint": "a1b2c3d4", + "kind": "key_value", + "data": { + "key": "api_key", + "value": "" + }, + "timestamp": "2024-04-27T12:40:56Z" +} +``` + +The `key` field is purely descriptive, while `value` holds the sensitive string +such as an API token. Notes and custom fields may also be included alongside the +standard metadata. + --- ## Handling `kind` Types and Extensibility diff --git a/landing/index.html b/landing/index.html index 90c4649..fe8958c 100644 --- a/landing/index.html +++ b/landing/index.html @@ -69,6 +69,7 @@ flowchart TB seed --> pgp["🔒 PGP Key"] seed --> mn["🌱 Seed Phrase"] seed --> nostr["⚡ Nostr Keys"] + seed --> kv["🔑 Key/Value"] classDef default fill:#ffffff,stroke:#e94a39,stroke-width:2px,color:#283c4f; Get Started @@ -110,13 +111,14 @@ flowchart TD end A["Parent Seed
(BIP-39 Mnemonic)"] --> B["Seed Bytes
(BIP-39 → 512-bit)"] B --> C["BIP-85 Derivation
(local_bip85.BIP85)"] - C --> D1["Password Entropy
(password_generation)"] & D2["TOTP Secret
(utils.key_derivation.derive_totp_secret)"] & D3["SSH Key Entropy
(password_generation.derive_ssh_key)"] & D4["PGP Key Entropy
(entry_management.add_pgp_key)"] & D5["Child Mnemonic
(BIP-85 derive_mnemonic)"] & D6["Nostr Key Entropy
(nostr.KeyManager)"] + C --> D1["Password Entropy
(password_generation)"] & D2["TOTP Secret
(utils.key_derivation.derive_totp_secret)"] & D3["SSH Key Entropy
(password_generation.derive_ssh_key)"] & D4["PGP Key Entropy
(entry_management.add_pgp_key)"] & D5["Child Mnemonic
(BIP-85 derive_mnemonic)"] & D6["Nostr Key Entropy
(nostr.KeyManager)"] & D7["Key/Value Data
(entry_management.add_key_value)"] D1 --> E1["Passwords"] D2 --> E2["2FA Codes"] D3 --> E3["SSH Key Pair"] D4 --> E4["PGP Key"] D5 --> E5["Seed Phrase"] D6 --> E6["Nostr Keys
(npub / nsec)"] + D7 --> E7["Key/Value"] E1 --> V E2 --> V E3 --> V @@ -129,7 +131,7 @@ flowchart TD R3 --> R4 R4 --> V A -. "Same seed ⇒ re-derive any artifact on demand" .- E1 - A -.-> E2 & E3 & E4 & E5 & E6 + A -.-> E2 & E3 & E4 & E5 & E6 & E7 @@ -145,6 +147,7 @@ flowchart TD
  • Checksum verification to ensure script integrity
  • Interactive TUI for managing entries and settings
  • Issue or import TOTP secrets for 2FA
  • +
  • Store arbitrary secrets as key/value pairs
  • Export your 2FA codes to an encrypted file
  • Optional external backup location
  • Auto-lock after inactivity
  • @@ -198,6 +201,7 @@ Enter your choice (1-7) or press Enter to exit:

    Disclaimer

    ⚠️ Disclaimer: This software was not developed by an experienced security expert and should be used with caution. There may be bugs and missing features. Additionally, the security of the program's memory management and logs has not been evaluated and may leak sensitive information.

    +

    Snapshot chunks are limited to 50 KB and rotated when deltas accumulate.

    diff --git a/src/main.py b/src/main.py index 35f1298..d9afad2 100644 --- a/src/main.py +++ b/src/main.py @@ -269,6 +269,8 @@ def print_matches( print(color_text(" Type: PGP Key", "index")) elif etype == EntryType.NOSTR.value: print(color_text(" Type: Nostr Key", "index")) + elif etype == EntryType.KEY_VALUE.value: + print(color_text(" Type: Key/Value", "index")) else: if website: print(color_text(f" Label: {website}", "index")) @@ -276,9 +278,7 @@ def print_matches( print(color_text(f" Username: {username}", "index")) if url: print(color_text(f" URL: {url}", "index")) - print( - color_text(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "index") - ) + print(color_text(f" Archived: {'Yes' if blacklisted else 'No'}", "index")) print("-" * 40) @@ -685,10 +685,8 @@ def handle_settings(password_manager: PasswordManager) -> None: choice = input("Select an option or press Enter to go back: ").strip() if choice == "1": handle_profiles_menu(password_manager) - pause() elif choice == "2": handle_nostr_menu(password_manager) - pause() elif choice == "3": password_manager.change_password() pause() @@ -752,6 +750,7 @@ def display_menu( 5. Modify an Existing Entry 6. 2FA Codes 7. Settings + 8. List Archived """ display_fn = getattr(password_manager, "display_stats", None) if callable(display_fn): @@ -781,7 +780,7 @@ def display_menu( print(color_text(menu, "menu")) try: choice = timed_input( - "Enter your choice (1-7) or press Enter to exit: ", + "Enter your choice (1-8) or press Enter to exit: ", inactivity_timeout, ).strip() except TimeoutError: @@ -808,6 +807,7 @@ def display_menu( print(color_text("4. Seed Phrase", "menu")) print(color_text("5. Nostr Key Pair", "menu")) print(color_text("6. PGP Key", "menu")) + print(color_text("7. Key/Value", "menu")) sub_choice = input( "Select entry type or press Enter to go back: " ).strip() @@ -830,6 +830,9 @@ def display_menu( elif sub_choice == "6": password_manager.handle_add_pgp() break + elif sub_choice == "7": + password_manager.handle_add_key_value() + break elif not sub_choice: break else: @@ -856,6 +859,9 @@ def display_menu( elif choice == "7": password_manager.update_activity() handle_settings(password_manager) + elif choice == "8": + password_manager.update_activity() + password_manager.handle_view_archived_entries() else: print(colored("Invalid choice. Please select a valid option.", "red")) diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 80acb09..5a2149b 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -72,6 +72,12 @@ class EntryManager: and entry.get("type") == EntryType.PASSWORD.value ): entry.pop("website", None) + if "archived" not in entry and "blacklisted" in entry: + entry["archived"] = entry["blacklisted"] + entry.pop("blacklisted", None) + if "word_count" not in entry and "words" in entry: + entry["word_count"] = entry["words"] + entry.pop("words", None) logger.debug("Index loaded successfully.") return data except Exception as e: @@ -117,7 +123,7 @@ class EntryManager: length: int, username: Optional[str] = None, url: Optional[str] = None, - blacklisted: bool = False, + archived: bool = False, notes: str = "", custom_fields: List[Dict[str, Any]] | None = None, ) -> int: @@ -128,7 +134,7 @@ class EntryManager: :param length: The desired length of the password. :param username: (Optional) The username associated with the website. :param url: (Optional) The URL of the website. - :param blacklisted: (Optional) Whether the password is blacklisted. Defaults to False. + :param archived: (Optional) Whether the entry is archived. Defaults to False. :param notes: (Optional) Extra notes to attach to the entry. :return: The assigned index of the new entry. """ @@ -142,7 +148,7 @@ class EntryManager: "length": length, "username": username if username else "", "url": url if url else "", - "blacklisted": blacklisted, + "archived": archived, "type": EntryType.PASSWORD.value, "kind": EntryType.PASSWORD.value, "notes": notes, @@ -184,10 +190,12 @@ class EntryManager: label: str, parent_seed: str, *, + archived: bool = False, secret: str | None = None, index: int | None = None, period: int = 30, digits: int = 6, + notes: str = "", ) -> str: """Add a new TOTP entry and return the provisioning URI.""" entry_id = self.get_next_index() @@ -205,6 +213,8 @@ class EntryManager: "index": index, "period": period, "digits": digits, + "archived": archived, + "notes": notes, } else: entry = { @@ -214,6 +224,8 @@ class EntryManager: "secret": secret, "period": period, "digits": digits, + "archived": archived, + "notes": notes, } data["entries"][str(entry_id)] = entry @@ -234,6 +246,7 @@ class EntryManager: parent_seed: str, index: int | None = None, notes: str = "", + archived: bool = False, ) -> int: """Add a new SSH key pair entry. @@ -253,6 +266,7 @@ class EntryManager: "index": index, "label": label, "notes": notes, + "archived": archived, } self._save_index(data) self.update_checksum() @@ -281,6 +295,7 @@ class EntryManager: key_type: str = "ed25519", user_id: str = "", notes: str = "", + archived: bool = False, ) -> int: """Add a new PGP key entry.""" @@ -297,6 +312,7 @@ class EntryManager: "key_type": key_type, "user_id": user_id, "notes": notes, + "archived": archived, } self._save_index(data) self.update_checksum() @@ -329,6 +345,7 @@ class EntryManager: label: str, index: int | None = None, notes: str = "", + archived: bool = False, ) -> int: """Add a new Nostr key pair entry.""" @@ -343,12 +360,43 @@ class EntryManager: "index": index, "label": label, "notes": notes, + "archived": archived, } self._save_index(data) self.update_checksum() self.backup_manager.create_backup() return index + def add_key_value( + self, + label: str, + value: str, + *, + notes: str = "", + custom_fields=None, + archived: bool = False, + ) -> int: + """Add a new generic key/value entry.""" + + index = self.get_next_index() + + data = self.vault.load_index() + data.setdefault("entries", {}) + data["entries"][str(index)] = { + "type": EntryType.KEY_VALUE.value, + "kind": EntryType.KEY_VALUE.value, + "label": label, + "value": value, + "notes": notes, + "archived": archived, + "custom_fields": custom_fields or [], + } + + 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.""" @@ -381,6 +429,7 @@ class EntryManager: index: int | None = None, words_num: int = 24, notes: str = "", + archived: bool = False, ) -> int: """Add a new derived seed phrase entry.""" @@ -394,8 +443,9 @@ class EntryManager: "kind": EntryType.SEED.value, "index": index, "label": label, - "words": words_num, + "word_count": words_num, "notes": notes, + "archived": archived, } self._save_index(data) self.update_checksum() @@ -420,7 +470,7 @@ class EntryManager: seed_bytes = Bip39SeedGenerator(parent_seed).Generate() bip85 = BIP85(seed_bytes) - words = int(entry.get("words", 24)) + words = int(entry.get("word_count", entry.get("words", 24))) seed_index = int(entry.get("index", index)) return derive_seed_phrase(bip85, seed_index, words) @@ -482,7 +532,8 @@ class EntryManager: entry = data.get("entries", {}).get(str(index)) if entry: - if entry.get("type", entry.get("kind")) == EntryType.PASSWORD.value: + etype = entry.get("type", entry.get("kind")) + if etype in (EntryType.PASSWORD.value, EntryType.KEY_VALUE.value): entry.setdefault("custom_fields", []) logger.debug(f"Retrieved entry at index {index}: {entry}") return entry @@ -505,13 +556,15 @@ class EntryManager: index: int, username: Optional[str] = None, url: Optional[str] = None, - blacklisted: Optional[bool] = None, + archived: Optional[bool] = None, notes: Optional[str] = None, *, label: Optional[str] = None, period: Optional[int] = None, digits: Optional[int] = None, + value: Optional[str] = None, custom_fields: List[Dict[str, Any]] | None = None, + **legacy, ) -> None: """ Modifies an existing entry based on the provided index and new values. @@ -519,11 +572,12 @@ class EntryManager: :param index: The index number of the entry to modify. :param username: (Optional) The new username (password entries). :param url: (Optional) The new URL (password entries). - :param blacklisted: (Optional) The new blacklist status. + :param archived: (Optional) The new archived status. :param notes: (Optional) New notes to attach to the entry. :param label: (Optional) The new label for the entry. :param period: (Optional) The new TOTP period in seconds. :param digits: (Optional) The new number of digits for TOTP codes. + :param value: (Optional) New value for key/value entries. """ try: data = self.vault.load_index() @@ -557,17 +611,29 @@ class EntryManager: if label is not None: entry["label"] = label logger.debug(f"Updated label to '{label}' for index {index}.") - if username is not None: - entry["username"] = username - logger.debug(f"Updated username to '{username}' for index {index}.") - if url is not None: - entry["url"] = url - logger.debug(f"Updated URL to '{url}' for index {index}.") + if entry_type == EntryType.PASSWORD.value: + if username is not None: + entry["username"] = username + logger.debug( + f"Updated username to '{username}' for index {index}." + ) + if url is not None: + entry["url"] = url + logger.debug(f"Updated URL to '{url}' for index {index}.") + elif entry_type == EntryType.KEY_VALUE.value: + if value is not None: + entry["value"] = value + logger.debug(f"Updated value for index {index}.") - if blacklisted is not None: - entry["blacklisted"] = blacklisted + if archived is None and "blacklisted" in legacy: + archived = legacy["blacklisted"] + + if archived is not None: + entry["archived"] = archived + if "blacklisted" in entry: + entry.pop("blacklisted", None) logger.debug( - f"Updated blacklist status to '{blacklisted}' for index {index}." + f"Updated archived status to '{archived}' for index {index}." ) if notes is not None: @@ -598,10 +664,26 @@ class EntryManager: colored(f"Error: Failed to modify entry at index {index}: {e}", "red") ) + def archive_entry(self, index: int) -> None: + """Mark the specified entry as archived.""" + self.modify_entry(index, archived=True) + + def restore_entry(self, index: int) -> None: + """Unarchive the specified entry.""" + self.modify_entry(index, archived=False) + def list_entries( - self, sort_by: str = "index", filter_kind: str | None = None + self, + sort_by: str = "index", + filter_kind: str | None = None, + *, + include_archived: bool = False, ) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]: - """List entries in the index with optional sorting and filtering.""" + """List entries in the index with optional sorting and filtering. + + By default archived entries are omitted unless ``include_archived`` is + ``True``. + """ try: data = self.vault.load_index() entries_data = data.get("entries", {}) @@ -631,6 +713,10 @@ class EntryManager: != filter_kind ): continue + if not include_archived and entry.get( + "archived", entry.get("blacklisted", False) + ): + continue filtered_items.append((int(idx_str), entry)) entries: List[Tuple[int, str, Optional[str], Optional[str], bool]] = [] @@ -644,11 +730,19 @@ class EntryManager: label, entry.get("username", ""), entry.get("url", ""), - entry.get("blacklisted", False), + entry.get("archived", entry.get("blacklisted", False)), ) ) else: - entries.append((idx, label, None, None, False)) + entries.append( + ( + idx, + label, + None, + None, + entry.get("archived", entry.get("blacklisted", False)), + ) + ) logger.debug(f"Total entries found: {len(entries)}") for idx, entry in filtered_items: @@ -677,7 +771,7 @@ class EntryManager: print(colored(f" URL: {entry.get('url') or 'N/A'}", "cyan")) print( colored( - f" Blacklisted: {'Yes' if entry.get('blacklisted', False) else 'No'}", + f" Archived: {'Yes' if entry.get('archived', entry.get('blacklisted', False)) else 'No'}", "cyan", ) ) @@ -685,7 +779,8 @@ class EntryManager: print(colored(f" Label: {entry.get('label', '')}", "cyan")) print( colored( - f" Derivation Index: {entry.get('index', index)}", "cyan" + f" Derivation Index: {entry.get('index', idx)}", + "cyan", ) ) print("-" * 40) @@ -739,12 +834,43 @@ class EntryManager: label, username, url, - entry.get("blacklisted", False), + entry.get("archived", entry.get("blacklisted", False)), + ) + ) + elif etype == EntryType.KEY_VALUE.value: + value_field = str(entry.get("value", "")) + 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 ( + label_match + or query_lower in value_field.lower() + or notes_match + or custom_match + ): + results.append( + ( + int(idx), + label, + None, + None, + entry.get("archived", entry.get("blacklisted", False)), ) ) else: if label_match or notes_match: - results.append((int(idx), label, None, None, False)) + results.append( + ( + int(idx), + label, + None, + None, + entry.get("archived", entry.get("blacklisted", False)), + ) + ) return results @@ -849,11 +975,19 @@ class EntryManager: ) def list_all_entries( - self, sort_by: str = "index", filter_kind: str | None = None + self, + sort_by: str = "index", + filter_kind: str | None = None, + *, + include_archived: bool = False, ) -> None: """Display all entries using :meth:`list_entries`.""" try: - entries = self.list_entries(sort_by=sort_by, filter_kind=filter_kind) + entries = self.list_entries( + sort_by=sort_by, + filter_kind=filter_kind, + include_archived=include_archived, + ) if not entries: print(colored("No entries to display.", "yellow")) return @@ -865,9 +999,7 @@ class EntryManager: print(colored(f" Label: {website}", "cyan")) print(colored(f" Username: {username or 'N/A'}", "cyan")) print(colored(f" URL: {url or 'N/A'}", "cyan")) - print( - colored(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "cyan") - ) + print(colored(f" Archived: {'Yes' if blacklisted else 'No'}", "cyan")) print("-" * 40) except Exception as e: @@ -876,7 +1008,10 @@ class EntryManager: return def get_entry_summaries( - self, filter_kind: str | None = None + self, + filter_kind: str | None = None, + *, + include_archived: bool = False, ) -> list[tuple[int, str, str]]: """Return a list of entry index, type, and display labels.""" try: @@ -888,6 +1023,10 @@ class EntryManager: etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) if filter_kind and etype != filter_kind: continue + if not include_archived and entry.get( + "archived", entry.get("blacklisted", False) + ): + continue if etype == EntryType.PASSWORD.value: label = entry.get("label", entry.get("website", "")) else: diff --git a/src/password_manager/entry_types.py b/src/password_manager/entry_types.py index 5108925..24aa9e7 100644 --- a/src/password_manager/entry_types.py +++ b/src/password_manager/entry_types.py @@ -13,3 +13,4 @@ class EntryType(str, Enum): SEED = "seed" PGP = "pgp" NOSTR = "nostr" + KEY_VALUE = "key_value" diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index cb76a02..4698272 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -939,7 +939,7 @@ class PasswordManager: length, username, url, - blacklisted=False, + archived=False, notes=notes, custom_fields=custom_fields, ) @@ -1001,6 +1001,7 @@ class PasswordManager: colored("Error: Period and digits must be numbers.", "red") ) continue + notes = input("Notes (optional): ").strip() totp_index = self.entry_manager.get_next_totp_index() entry_id = self.entry_manager.get_next_index() uri = self.entry_manager.add_totp( @@ -1009,6 +1010,7 @@ class PasswordManager: index=totp_index, period=int(period), digits=int(digits), + notes=notes, ) secret = TotpManager.derive_secret(self.parent_seed, totp_index) self.is_dirty = True @@ -1043,6 +1045,7 @@ class PasswordManager: secret = raw.upper() period = int(input("Period (default 30): ").strip() or 30) digits = int(input("Digits (default 6): ").strip() or 6) + notes = input("Notes (optional): ").strip() entry_id = self.entry_manager.get_next_index() uri = self.entry_manager.add_totp( label, @@ -1050,6 +1053,7 @@ class PasswordManager: secret=secret, period=period, digits=digits, + notes=notes, ) self.is_dirty = True self.last_update = time.time() @@ -1287,6 +1291,68 @@ class PasswordManager: print(colored(f"Error: Failed to add Nostr key: {e}", "red")) pause() + def handle_add_key_value(self) -> None: + """Add a generic key/value entry.""" + try: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > Add Entry > Key/Value", + ) + label = input("Label: ").strip() + if not label: + print(colored("Error: Label cannot be empty.", "red")) + return + value = input("Value: ").strip() + notes = input("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 + field_label = input(" Field label: ").strip() + field_value = input(" Field value: ").strip() + hidden = input(" Hidden field? (y/N): ").strip().lower() == "y" + custom_fields.append( + { + "label": field_label, + "value": field_value, + "is_hidden": hidden, + } + ) + + index = self.entry_manager.add_key_value( + label, value, notes=notes, custom_fields=custom_fields + ) + self.is_dirty = True + self.last_update = time.time() + + print(colored(f"\n[+] Key/Value entry added with ID {index}.\n", "green")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) + if self.secret_mode_enabled: + copy_to_clipboard(value, self.clipboard_clear_delay) + print( + colored( + f"[+] Value copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(color_text(f"Value: {value}", "deterministic")) + 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, + ) + pause() + except Exception as e: + logging.error(f"Error during key/value setup: {e}", exc_info=True) + print(colored(f"Error: Failed to add key/value entry: {e}", "red")) + pause() + def show_entry_details_by_index(self, index: int) -> None: """Display entry details using :meth:`handle_retrieve_entry` for the given index without prompting for it again.""" @@ -1307,6 +1373,23 @@ class PasswordManager: finally: builtins.input = original_input + def _prompt_toggle_archive(self, entry: dict, index: int) -> None: + """Prompt the user to archive or restore ``entry`` based on its status.""" + archived = entry.get("archived", entry.get("blacklisted", False)) + prompt = ( + "Restore this entry from archive? (y/N): " + if archived + else "Archive this entry? (y/N): " + ) + choice = input(prompt).strip().lower() + if choice == "y": + if archived: + self.entry_manager.restore_entry(index) + else: + self.entry_manager.archive_entry(index) + self.is_dirty = True + self.last_update = time.time() + def handle_retrieve_entry(self) -> None: """ Handles retrieving a password from the index by prompting the user for the index number @@ -1387,6 +1470,7 @@ class PasswordManager: except Exception as e: logging.error(f"Error generating TOTP code: {e}", exc_info=True) print(colored(f"Error: Failed to generate TOTP code: {e}", "red")) + self._prompt_toggle_archive(entry, index) pause() return if entry_type == EntryType.SSH.value: @@ -1422,6 +1506,7 @@ class PasswordManager: except Exception as e: logging.error(f"Error deriving SSH key pair: {e}", exc_info=True) print(colored(f"Error: Failed to derive SSH keys: {e}", "red")) + self._prompt_toggle_archive(entry, index) pause() return if entry_type == EntryType.SEED.value: @@ -1458,7 +1543,7 @@ class PasswordManager: from local_bip85.bip85 import BIP85 from bip_utils import Bip39SeedGenerator - words = int(entry.get("words", 24)) + words = int(entry.get("word_count", 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) @@ -1472,6 +1557,7 @@ class PasswordManager: 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")) + self._prompt_toggle_archive(entry, index) pause() return if entry_type == EntryType.PGP.value: @@ -1505,6 +1591,7 @@ class PasswordManager: except Exception as e: logging.error(f"Error deriving PGP key: {e}", exc_info=True) print(colored(f"Error: Failed to derive PGP key: {e}", "red")) + self._prompt_toggle_archive(entry, index) pause() return if entry_type == EntryType.NOSTR.value: @@ -1538,6 +1625,64 @@ class PasswordManager: 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")) + self._prompt_toggle_archive(entry, index) + pause() + return + + if entry_type == EntryType.KEY_VALUE.value: + label = entry.get("label", "") + value = entry.get("value", "") + notes = entry.get("notes", "") + archived = entry.get("archived", False) + print(colored(f"Retrieving value for '{label}'.", "cyan")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) + print( + colored( + f"Archived Status: {'Archived' if archived else 'Active'}", + "cyan", + ) + ) + if self.secret_mode_enabled: + copy_to_clipboard(value, self.clipboard_clear_delay) + print( + colored( + f"[+] Value copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(color_text(f"Value: {value}", "deterministic")) + + custom_fields = entry.get("custom_fields", []) + if custom_fields: + print(colored("Additional Fields:", "cyan")) + hidden_fields = [] + for field in custom_fields: + f_label = field.get("label", "") + f_value = field.get("value", "") + if field.get("is_hidden"): + hidden_fields.append((f_label, f_value)) + print(colored(f" {f_label}: [hidden]", "cyan")) + else: + print(colored(f" {f_label}: {f_value}", "cyan")) + if hidden_fields: + show = input("Reveal hidden fields? (y/N): ").strip().lower() + if show == "y": + for f_label, f_value in hidden_fields: + if self.secret_mode_enabled: + copy_to_clipboard( + f_value, self.clipboard_clear_delay + ) + print( + colored( + f"[+] {f_label} copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored(f" {f_label}: {f_value}", "cyan")) + self._prompt_toggle_archive(entry, index) pause() return @@ -1545,7 +1690,7 @@ class PasswordManager: length = entry.get("length") username = entry.get("username") url = entry.get("url") - blacklisted = entry.get("blacklisted") + blacklisted = entry.get("archived", entry.get("blacklisted")) notes = entry.get("notes", "") print( @@ -1561,7 +1706,7 @@ class PasswordManager: if blacklisted: print( colored( - f"Warning: This password is blacklisted and should not be used.", + f"Warning: This password is archived and should not be used.", "yellow", ) ) @@ -1589,7 +1734,7 @@ class PasswordManager: print(colored(f"Associated URL: {url or 'N/A'}", "cyan")) print( colored( - f"Blacklist Status: {'Blacklisted' if blacklisted else 'Not Blacklisted'}", + f"Archived Status: {'Archived' if blacklisted else 'Active'}", "cyan", ) ) @@ -1625,6 +1770,7 @@ class PasswordManager: print(colored(f" {label}: {value}", "cyan")) else: print(colored("Error: Failed to retrieve the password.", "red")) + self._prompt_toggle_archive(entry, index) pause() except Exception as e: logging.error(f"Error during password retrieval: {e}", exc_info=True) @@ -1660,7 +1806,7 @@ class PasswordManager: label = entry.get("label", "") period = int(entry.get("period", 30)) digits = int(entry.get("digits", 6)) - blacklisted = entry.get("blacklisted", False) + blacklisted = entry.get("archived", entry.get("blacklisted", False)) notes = entry.get("notes", "") print( @@ -1673,7 +1819,7 @@ class PasswordManager: print(colored(f"Current Digits: {digits}", "cyan")) print( colored( - f"Current Blacklist Status: {'Blacklisted' if blacklisted else 'Not Blacklisted'}", + f"Current Archived Status: {'Archived' if blacklisted else 'Active'}", "cyan", ) ) @@ -1708,7 +1854,7 @@ class PasswordManager: ) blacklist_input = ( input( - f'Is this 2FA code blacklisted? (Y/N, current: {"Y" if blacklisted else "N"}): ' + f'Archive this 2FA code? (Y/N, current: {"Y" if blacklisted else "N"}): ' ) .strip() .lower() @@ -1722,7 +1868,7 @@ class PasswordManager: else: print( colored( - "Invalid input for blacklist status. Keeping the current status.", + "Invalid input for archived status. Keeping the current status.", "yellow", ) ) @@ -1751,18 +1897,97 @@ class PasswordManager: self.entry_manager.modify_entry( index, - blacklisted=new_blacklisted, + archived=new_blacklisted, notes=new_notes, label=new_label, period=new_period, digits=new_digits, custom_fields=custom_fields, ) + elif entry_type == EntryType.KEY_VALUE.value: + label = entry.get("label", "") + value = entry.get("value", "") + blacklisted = entry.get("archived", False) + notes = entry.get("notes", "") + + print( + colored( + f"Modifying key/value entry '{label}' (Index: {index}):", + "cyan", + ) + ) + print( + colored( + f"Current Archived Status: {'Archived' if blacklisted else 'Active'}", + "cyan", + ) + ) + new_label = ( + input(f'Enter new label (leave blank to keep "{label}"): ').strip() + or label + ) + new_value = ( + input("Enter new value (leave blank to keep current): ").strip() + or value + ) + blacklist_input = ( + input( + f'Archive this entry? (Y/N, current: {"Y" if blacklisted else "N"}): ' + ) + .strip() + .lower() + ) + if blacklist_input == "": + new_blacklisted = blacklisted + elif blacklist_input == "y": + new_blacklisted = True + elif blacklist_input == "n": + new_blacklisted = False + else: + print( + colored( + "Invalid input for archived status. Keeping the current status.", + "yellow", + ) + ) + new_blacklisted = blacklisted + + new_notes = ( + input( + f'Enter new notes (leave blank to keep "{notes or "N/A"}"): ' + ).strip() + or notes + ) + + edit_fields = input("Edit custom fields? (y/N): ").strip().lower() + custom_fields = None + if edit_fields == "y": + custom_fields = [] + while True: + f_label = input( + " Field label (leave blank to finish): " + ).strip() + if not f_label: + break + f_value = input(" Field value: ").strip() + hidden = input(" Hidden field? (y/N): ").strip().lower() == "y" + custom_fields.append( + {"label": f_label, "value": f_value, "is_hidden": hidden} + ) + + self.entry_manager.modify_entry( + index, + archived=new_blacklisted, + notes=new_notes, + label=new_label, + value=new_value, + custom_fields=custom_fields, + ) else: website_name = entry.get("label", entry.get("website")) username = entry.get("username") url = entry.get("url") - blacklisted = entry.get("blacklisted") + blacklisted = entry.get("archived", entry.get("blacklisted")) notes = entry.get("notes", "") print( @@ -1776,7 +2001,7 @@ class PasswordManager: print(colored(f"Current URL: {url or 'N/A'}", "cyan")) print( colored( - f"Current Blacklist Status: {'Blacklisted' if blacklisted else 'Not Blacklisted'}", + f"Current Archived Status: {'Archived' if blacklisted else 'Active'}", "cyan", ) ) @@ -1802,7 +2027,7 @@ class PasswordManager: ) blacklist_input = ( input( - f'Is this password blacklisted? (Y/N, current: {"Y" if blacklisted else "N"}): ' + f'Archive this password? (Y/N, current: {"Y" if blacklisted else "N"}): ' ) .strip() .lower() @@ -1816,7 +2041,7 @@ class PasswordManager: else: print( colored( - "Invalid input for blacklist status. Keeping the current status.", + "Invalid input for archived status. Keeping the current status.", "yellow", ) ) @@ -1847,8 +2072,8 @@ class PasswordManager: index, new_username, new_url, - new_blacklisted, - new_notes, + archived=new_blacklisted, + notes=new_notes, label=new_label, custom_fields=custom_fields, ) @@ -1871,6 +2096,11 @@ class PasswordManager: exc_info=True, ) + updated_entry = self.entry_manager.retrieve_entry(index) + if updated_entry: + self._prompt_toggle_archive(updated_entry, index) + pause() + except Exception as e: logging.error(f"Error during modifying entry: {e}", exc_info=True) print(colored(f"Error: Failed to modify entry: {e}", "red")) @@ -1992,12 +2222,15 @@ class PasswordManager: website = entry.get("label", entry.get("website", "")) username = entry.get("username", "") url = entry.get("url", "") - blacklisted = entry.get("blacklisted", False) + blacklisted = entry.get("archived", entry.get("blacklisted", False)) print(color_text(f" Label: {website}", "index")) print(color_text(f" Username: {username or 'N/A'}", "index")) print(color_text(f" URL: {url or 'N/A'}", "index")) print( - color_text(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "index") + color_text( + f" Archived: {'Yes' if blacklisted else 'No'}", + "index", + ) ) print("-" * 40) @@ -2017,6 +2250,7 @@ class PasswordManager: print(color_text("5. Seed Phrase", "menu")) print(color_text("6. Nostr Key Pair", "menu")) print(color_text("7. PGP", "menu")) + print(color_text("8. Key/Value", "menu")) choice = input("Select entry type or press Enter to go back: ").strip() if choice == "1": filter_kind = None @@ -2032,13 +2266,17 @@ class PasswordManager: filter_kind = EntryType.NOSTR.value elif choice == "7": filter_kind = EntryType.PGP.value + elif choice == "8": + filter_kind = EntryType.KEY_VALUE.value elif not choice: return else: print(colored("Invalid choice.", "red")) continue - summaries = self.entry_manager.get_entry_summaries(filter_kind) + summaries = self.entry_manager.get_entry_summaries( + filter_kind, include_archived=False + ) if not summaries: continue while True: @@ -2103,6 +2341,85 @@ class PasswordManager: logging.error(f"Error during entry deletion: {e}", exc_info=True) print(colored(f"Error: Failed to delete entry: {e}", "red")) + def handle_archive_entry(self) -> None: + """Archive an entry without deleting it.""" + try: + index_input = input( + "Enter the index number of the entry to archive: " + ).strip() + if not index_input.isdigit(): + print(colored("Error: Index must be a number.", "red")) + return + index = int(index_input) + self.entry_manager.archive_entry(index) + self.is_dirty = True + self.last_update = time.time() + pause() + except Exception as e: + logging.error(f"Error archiving entry: {e}", exc_info=True) + print(colored(f"Error: Failed to archive entry: {e}", "red")) + + def handle_view_archived_entries(self) -> None: + """Display archived entries and optionally view or restore them.""" + try: + archived = self.entry_manager.list_entries(include_archived=True) + archived = [e for e in archived if e[4]] + if not archived: + print(colored("No archived entries found.", "yellow")) + pause() + return + while True: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > Archived Entries", + ) + print(colored("\n[+] Archived Entries:\n", "green")) + for idx, label, _username, _url, _ in archived: + print(colored(f"{idx}. {label}", "cyan")) + idx_input = input( + "Enter index to manage or press Enter to go back: " + ).strip() + if not idx_input: + break + if not idx_input.isdigit() or int(idx_input) not in [ + e[0] for e in archived + ]: + print(colored("Invalid index.", "red")) + continue + entry_index = int(idx_input) + while True: + action = ( + input( + "Enter 'v' to view details, 'r' to restore, or press Enter to go back: " + ) + .strip() + .lower() + ) + if action == "v": + self.display_entry_details(entry_index) + pause() + elif action == "r": + self.entry_manager.restore_entry(entry_index) + self.is_dirty = True + self.last_update = time.time() + pause() + archived = self.entry_manager.list_entries( + include_archived=True + ) + archived = [e for e in archived if e[4]] + if not archived: + print(colored("All entries restored.", "green")) + pause() + return + break + elif not action: + break + else: + print(colored("Invalid choice.", "red")) + except Exception as e: + logging.error(f"Error viewing archived entries: {e}", exc_info=True) + print(colored(f"Error: Failed to view archived entries: {e}", "red")) + def handle_display_totp_codes(self) -> None: """Display all stored TOTP codes with a countdown progress bar.""" try: @@ -2115,7 +2432,7 @@ class PasswordManager: totp_list: list[tuple[str, int, int, bool]] = [] for idx_str, entry in entries.items(): if entry.get("type") == EntryType.TOTP.value and not entry.get( - "blacklisted", False + "archived", entry.get("blacklisted", False) ): label = entry.get("label", "") period = int(entry.get("period", 30)) diff --git a/src/tests/test_archive_from_retrieve.py b/src/tests/test_archive_from_retrieve.py new file mode 100644 index 0000000..2383503 --- /dev/null +++ b/src/tests/test_archive_from_retrieve.py @@ -0,0 +1,80 @@ +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 + + +class FakePasswordGenerator: + def generate_password(self, length: int, index: int) -> str: # noqa: D401 + return "pw" + + +def test_archive_entry_from_retrieve(monkeypatch): + 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 = FakePasswordGenerator() + pm.parent_seed = TEST_SEED + pm.nostr_client = SimpleNamespace() + pm.fingerprint_dir = tmp_path + pm.secret_mode_enabled = False + + index = entry_mgr.add_entry("example.com", 8) + + inputs = iter([str(index), "y"]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) + + pm.handle_retrieve_entry() + + assert entry_mgr.retrieve_entry(index)["archived"] is True + + +def test_restore_entry_from_retrieve(monkeypatch): + """Archived entries should restore when retrieved and confirmed.""" + 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 = FakePasswordGenerator() + pm.parent_seed = TEST_SEED + pm.nostr_client = SimpleNamespace() + pm.fingerprint_dir = tmp_path + pm.secret_mode_enabled = False + + index = entry_mgr.add_entry("example.com", 8) + entry_mgr.archive_entry(index) + + inputs = iter([str(index), "y"]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) + + pm.handle_retrieve_entry() + + assert entry_mgr.retrieve_entry(index)["archived"] is False diff --git a/src/tests/test_archive_nonpassword.py b/src/tests/test_archive_nonpassword.py new file mode 100644 index 0000000..6296813 --- /dev/null +++ b/src/tests/test_archive_nonpassword.py @@ -0,0 +1,42 @@ +from pathlib import Path +from tempfile import TemporaryDirectory +import sys + +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 + + +def setup_entry_mgr(tmp_path: Path) -> EntryManager: + vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + return EntryManager(vault, backup_mgr) + + +def test_archive_nonpassword_list_search(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + em = setup_entry_mgr(tmp_path) + em.add_totp("Example", TEST_SEED) + idx = em.search_entries("Example")[0][0] + + assert em.list_entries() == [(idx, "Example", None, None, False)] + assert em.search_entries("Example") == [(idx, "Example", None, None, False)] + + em.archive_entry(idx) + assert em.retrieve_entry(idx)["archived"] is True + assert em.list_entries() == [] + assert em.list_entries(include_archived=True) == [ + (idx, "Example", None, None, True) + ] + assert em.search_entries("Example") == [(idx, "Example", None, None, True)] + + em.restore_entry(idx) + assert em.retrieve_entry(idx)["archived"] is False + assert em.list_entries() == [(idx, "Example", None, None, False)] + assert em.search_entries("Example") == [(idx, "Example", None, None, False)] diff --git a/src/tests/test_archive_restore.py b/src/tests/test_archive_restore.py new file mode 100644 index 0000000..00225e1 --- /dev/null +++ b/src/tests/test_archive_restore.py @@ -0,0 +1,149 @@ +import sys +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace + +import pytest + +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.manager import PasswordManager, EncryptionMode + + +def setup_entry_mgr(tmp_path: Path) -> EntryManager: + vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + return EntryManager(vault, backup_mgr) + + +def test_archive_restore_affects_listing_and_search(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + em = setup_entry_mgr(tmp_path) + idx = em.add_entry("example.com", 8, "alice") + + assert em.list_entries() == [(idx, "example.com", "alice", "", False)] + assert em.search_entries("example") == [ + (idx, "example.com", "alice", "", False) + ] + + em.archive_entry(idx) + assert em.retrieve_entry(idx)["archived"] is True + assert em.list_entries() == [] + assert em.list_entries(include_archived=True) == [ + (idx, "example.com", "alice", "", True) + ] + assert em.search_entries("example") == [(idx, "example.com", "alice", "", True)] + + em.restore_entry(idx) + assert em.retrieve_entry(idx)["archived"] is False + assert em.list_entries() == [(idx, "example.com", "alice", "", False)] + assert em.search_entries("example") == [ + (idx, "example.com", "alice", "", False) + ] + + +def test_view_archived_entries_cli(monkeypatch): + 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.parent_seed = TEST_SEED + pm.nostr_client = SimpleNamespace() + pm.fingerprint_dir = tmp_path + pm.is_dirty = False + + idx = entry_mgr.add_entry("example.com", 8) + + monkeypatch.setattr("builtins.input", lambda *_: str(idx)) + pm.handle_archive_entry() + assert entry_mgr.retrieve_entry(idx)["archived"] is True + + inputs = iter([str(idx), "r", "", ""]) + monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) + pm.handle_view_archived_entries() + assert entry_mgr.retrieve_entry(idx)["archived"] is False + + +def test_view_archived_entries_view_only(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.parent_seed = TEST_SEED + pm.nostr_client = SimpleNamespace() + pm.fingerprint_dir = tmp_path + pm.is_dirty = False + + idx = entry_mgr.add_entry("example.com", 8) + + monkeypatch.setattr("builtins.input", lambda *_: str(idx)) + pm.handle_archive_entry() + assert entry_mgr.retrieve_entry(idx)["archived"] is True + + inputs = iter([str(idx), "v", "", "", ""]) + monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) + pm.handle_view_archived_entries() + assert entry_mgr.retrieve_entry(idx)["archived"] is True + out = capsys.readouterr().out + assert "example.com" in out + + +def test_view_archived_entries_removed_after_restore(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.parent_seed = TEST_SEED + pm.nostr_client = SimpleNamespace() + pm.fingerprint_dir = tmp_path + pm.is_dirty = False + + idx = entry_mgr.add_entry("example.com", 8) + + monkeypatch.setattr("builtins.input", lambda *_: str(idx)) + pm.handle_archive_entry() + assert entry_mgr.retrieve_entry(idx)["archived"] is True + + inputs = iter([str(idx), "r", "", ""]) + monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) + pm.handle_view_archived_entries() + assert entry_mgr.retrieve_entry(idx)["archived"] is False + + monkeypatch.setattr("builtins.input", lambda *_: "") + pm.handle_view_archived_entries() + out = capsys.readouterr().out + assert "No archived entries found." in out diff --git a/src/tests/test_cli_export_import.py b/src/tests/test_cli_export_import.py new file mode 100644 index 0000000..de11ea4 --- /dev/null +++ b/src/tests/test_cli_export_import.py @@ -0,0 +1,91 @@ +from pathlib import Path +from types import SimpleNamespace + +import sys + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +import main +from password_manager.portable_backup import export_backup, import_backup +from password_manager.config_manager import ConfigManager +from password_manager.backup import BackupManager +from helpers import create_vault, TEST_SEED + + +def _setup_pm(tmp_path: Path): + vault, _ = create_vault(tmp_path, TEST_SEED) + cfg = ConfigManager(vault, tmp_path) + backup = BackupManager(tmp_path, cfg) + pm = SimpleNamespace( + handle_export_database=lambda p: export_backup( + vault, backup, p, parent_seed=TEST_SEED + ), + handle_import_database=lambda p: import_backup( + vault, backup, p, parent_seed=TEST_SEED + ), + nostr_client=SimpleNamespace(close_client_pool=lambda: None), + ) + return pm, vault + + +def test_cli_export_creates_file(monkeypatch, tmp_path): + pm, vault = _setup_pm(tmp_path) + data = { + "schema_version": 3, + "entries": { + "0": { + "label": "example", + "type": "password", + "notes": "", + "custom_fields": [], + "origin": "", + } + }, + } + vault.save_index(data) + + monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "configure_logging", lambda: None) + monkeypatch.setattr(main, "initialize_app", lambda: None) + monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) + + export_path = tmp_path / "out.json" + rc = main.main(["export", "--file", str(export_path)]) + assert rc == 0 + assert export_path.exists() + + +def test_cli_import_round_trip(monkeypatch, tmp_path): + pm, vault = _setup_pm(tmp_path) + original = { + "schema_version": 3, + "entries": { + "0": { + "label": "example", + "type": "password", + "notes": "", + "custom_fields": [], + "origin": "", + } + }, + } + vault.save_index(original) + + export_path = tmp_path / "out.json" + export_backup( + vault, + BackupManager(tmp_path, ConfigManager(vault, tmp_path)), + export_path, + parent_seed=TEST_SEED, + ) + + vault.save_index({"schema_version": 3, "entries": {}}) + + monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "configure_logging", lambda: None) + monkeypatch.setattr(main, "initialize_app", lambda: None) + monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) + + rc = main.main(["import", "--file", str(export_path)]) + assert rc == 0 + assert vault.load_index() == original diff --git a/src/tests/test_cli_invalid_input.py b/src/tests/test_cli_invalid_input.py index 6331c8c..cfdf2bb 100644 --- a/src/tests/test_cli_invalid_input.py +++ b/src/tests/test_cli_invalid_input.py @@ -64,7 +64,7 @@ def test_empty_and_non_numeric_choice(monkeypatch, capsys): def test_out_of_range_menu(monkeypatch, capsys): called = {"add": False, "retrieve": False, "modify": False} pm, _ = _make_pm(called) - inputs = iter(["9", ""]) + inputs = iter(["10", ""]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) with pytest.raises(SystemExit): main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) diff --git a/src/tests/test_custom_fields_display.py b/src/tests/test_custom_fields_display.py index f8966fc..279e19a 100644 --- a/src/tests/test_custom_fields_display.py +++ b/src/tests/test_custom_fields_display.py @@ -42,7 +42,7 @@ def test_retrieve_entry_shows_custom_fields(monkeypatch, capsys): ], ) - inputs = iter(["0", "y"]) + inputs = iter(["0", "y", "", "n"]) monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) pm.handle_retrieve_entry() diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index f44f318..ed2630e 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -34,7 +34,7 @@ def test_add_and_retrieve_entry(): "length": 12, "username": "user", "url": "", - "blacklisted": False, + "archived": False, "type": "password", "kind": "password", "notes": "", @@ -53,6 +53,7 @@ def test_add_and_retrieve_entry(): ("add_totp", "totp"), ("add_ssh_key", "ssh"), ("add_seed", "seed"), + ("add_key_value", "key_value"), ], ) def test_round_trip_entry_types(method, expected_type): @@ -67,6 +68,8 @@ def test_round_trip_entry_types(method, expected_type): elif method == "add_totp": entry_mgr.add_totp("example", TEST_SEED) index = 0 + elif method == "add_key_value": + index = entry_mgr.add_key_value("label", "val") else: if method == "add_ssh_key": index = entry_mgr.add_ssh_key("ssh", TEST_SEED) @@ -98,3 +101,32 @@ def test_legacy_entry_defaults_to_password(): loaded = entry_mgr._load_index() assert loaded["entries"][str(index)]["type"] == "password" + + +@pytest.mark.parametrize( + "method,args", + [ + ("add_entry", ("site.com", 8)), + ("add_totp", ("totp", TEST_SEED)), + ("add_ssh_key", ("ssh", TEST_SEED)), + ("add_pgp_key", ("pgp", TEST_SEED)), + ("add_nostr_key", ("nostr",)), + ("add_seed", ("seed", TEST_SEED)), + ("add_key_value", ("label", "val")), + ], +) +def test_add_default_archived_false(method, args): + with TemporaryDirectory() as tmpdir: + vault, _ = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, Path(tmpdir)) + backup_mgr = BackupManager(Path(tmpdir), cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + if method == "add_totp": + getattr(entry_mgr, method)(*args) + index = 0 + else: + index = getattr(entry_mgr, method)(*args) + + entry = entry_mgr.retrieve_entry(index) + assert entry["archived"] is False diff --git a/src/tests/test_key_value_entry.py b/src/tests/test_key_value_entry.py new file mode 100644 index 0000000..9322d64 --- /dev/null +++ b/src/tests/test_key_value_entry.py @@ -0,0 +1,43 @@ +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 + + +def setup_entry_mgr(tmp_path: Path) -> EntryManager: + vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + return EntryManager(vault, backup_mgr) + + +def test_add_and_modify_key_value(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + em = setup_entry_mgr(tmp_path) + + idx = em.add_key_value("API", "abc123", notes="token") + entry = em.retrieve_entry(idx) + assert entry == { + "type": "key_value", + "kind": "key_value", + "label": "API", + "value": "abc123", + "notes": "token", + "archived": False, + "custom_fields": [], + } + + em.modify_entry(idx, value="def456") + updated = em.retrieve_entry(idx) + assert updated["value"] == "def456" + + results = em.search_entries("def456") + assert results == [(idx, "API", None, None, False)] diff --git a/src/tests/test_manager_add_totp.py b/src/tests/test_manager_add_totp.py index 4b159ad..0038511 100644 --- a/src/tests/test_manager_add_totp.py +++ b/src/tests/test_manager_add_totp.py @@ -47,6 +47,7 @@ def test_handle_add_totp(monkeypatch, capsys): "Example", # label "", # period "", # digits + "", # notes ] ) monkeypatch.setattr("builtins.input", lambda *args, **kwargs: next(inputs)) @@ -63,5 +64,7 @@ def test_handle_add_totp(monkeypatch, capsys): "index": 0, "period": 30, "digits": 6, + "archived": False, + "notes": "", } assert "ID 0" in out diff --git a/src/tests/test_manager_display_totp_codes.py b/src/tests/test_manager_display_totp_codes.py index b2773e0..649bcd2 100644 --- a/src/tests/test_manager_display_totp_codes.py +++ b/src/tests/test_manager_display_totp_codes.py @@ -61,7 +61,7 @@ def test_handle_display_totp_codes(monkeypatch, capsys): assert "123456" in out -def test_display_totp_codes_excludes_blacklisted(monkeypatch, capsys): +def test_display_totp_codes_excludes_archived(monkeypatch, capsys): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) @@ -83,7 +83,7 @@ def test_display_totp_codes_excludes_blacklisted(monkeypatch, capsys): entry_mgr.add_totp("Visible", TEST_SEED) entry_mgr.add_totp("Hidden", TEST_SEED) - entry_mgr.modify_entry(1, blacklisted=True) + entry_mgr.modify_entry(1, archived=True) monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456") monkeypatch.setattr( diff --git a/src/tests/test_manager_list_entries.py b/src/tests/test_manager_list_entries.py index 5d7dd3a..abd62dd 100644 --- a/src/tests/test_manager_list_entries.py +++ b/src/tests/test_manager_list_entries.py @@ -33,6 +33,7 @@ def test_handle_list_entries(monkeypatch, capsys): entry_mgr.add_totp("Example", TEST_SEED) entry_mgr.add_entry("example.com", 12) + entry_mgr.add_key_value("API", "abc123") inputs = iter(["1", ""]) # list all, then exit monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) @@ -41,6 +42,7 @@ def test_handle_list_entries(monkeypatch, capsys): out = capsys.readouterr().out assert "Example" in out assert "example.com" in out + assert "API" in out def test_list_entries_show_details(monkeypatch, capsys): @@ -63,6 +65,7 @@ def test_list_entries_show_details(monkeypatch, capsys): pm.secret_mode_enabled = False entry_mgr.add_totp("Example", TEST_SEED) + entry_mgr.add_key_value("API", "val") monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456") monkeypatch.setattr( @@ -74,10 +77,11 @@ def test_list_entries_show_details(monkeypatch, capsys): lambda *a, **k: "b", ) - inputs = iter(["1", "0"]) + inputs = iter(["1", "0", "n"]) monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) pm.handle_list_entries() out = capsys.readouterr().out assert "Retrieved 2FA Code" in out assert "123456" in out + assert "API" in out diff --git a/src/tests/test_manager_retrieve_totp.py b/src/tests/test_manager_retrieve_totp.py index b67fe27..ae0f8d2 100644 --- a/src/tests/test_manager_retrieve_totp.py +++ b/src/tests/test_manager_retrieve_totp.py @@ -43,7 +43,8 @@ def test_handle_retrieve_totp_entry(monkeypatch, capsys): entry_mgr.add_totp("Example", TEST_SEED) - monkeypatch.setattr("builtins.input", lambda *a, **k: "0") + inputs = iter(["0", "n"]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456") monkeypatch.setattr( pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1 diff --git a/src/tests/test_manager_search_display.py b/src/tests/test_manager_search_display.py index 16c83c2..3133c65 100644 --- a/src/tests/test_manager_search_display.py +++ b/src/tests/test_manager_search_display.py @@ -41,7 +41,7 @@ def test_search_entries_prompt_for_details(monkeypatch, capsys): monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b") - inputs = iter(["Example", "0", ""]) + inputs = iter(["Example", "0", "n", ""]) monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) pm.handle_search_entries() diff --git a/src/tests/test_manager_workflow.py b/src/tests/test_manager_workflow.py index dac3a2e..5a95cd1 100644 --- a/src/tests/test_manager_workflow.py +++ b/src/tests/test_manager_workflow.py @@ -46,6 +46,7 @@ def test_manager_workflow(monkeypatch): pm.nostr_client = FakeNostrClient() pm.fingerprint_dir = tmp_path pm.is_dirty = False + pm.secret_mode_enabled = False inputs = iter( [ @@ -56,11 +57,12 @@ def test_manager_workflow(monkeypatch): "n", # add custom field "", # length (default) "0", # retrieve index + "n", # archive entry prompt "0", # modify index "", # new label "user", # new username "", # new url - "", # blacklist status + "", # archive status "", # new notes "n", # edit custom fields ] diff --git a/src/tests/test_menu_navigation.py b/src/tests/test_menu_navigation.py new file mode 100644 index 0000000..8ab7bae --- /dev/null +++ b/src/tests/test_menu_navigation.py @@ -0,0 +1,55 @@ +import time +from types import SimpleNamespace +from pathlib import Path +import sys +import pytest + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +import main + + +def _make_pm(calls): + return SimpleNamespace( + is_dirty=False, + last_update=time.time(), + last_activity=time.time(), + nostr_client=SimpleNamespace(close_client_pool=lambda: None), + handle_add_password=lambda: calls.append("add"), + handle_add_totp=lambda: calls.append("totp"), + handle_add_ssh_key=lambda: calls.append("ssh"), + handle_add_seed=lambda: calls.append("seed"), + handle_add_nostr_key=lambda: calls.append("nostr"), + handle_add_pgp=lambda: calls.append("pgp"), + handle_retrieve_entry=lambda: calls.append("retrieve"), + handle_search_entries=lambda: calls.append("search"), + handle_list_entries=lambda: calls.append("list"), + handle_modify_entry=lambda: calls.append("modify"), + handle_display_totp_codes=lambda: calls.append("show_totp"), + handle_view_archived_entries=lambda: calls.append("view_archived"), + update_activity=lambda: None, + lock_vault=lambda: None, + unlock_vault=lambda: None, + ) + + +def test_navigate_all_main_menu_options(monkeypatch): + calls = [] + pm = _make_pm(calls) + # Sequence through all main menu options then exit + inputs = iter(["1", "2", "3", "4", "5", "6", "7", "8", ""]) + monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) + # Submenus immediately return + monkeypatch.setattr("builtins.input", lambda *_: "") + monkeypatch.setattr(main, "handle_settings", lambda *_: calls.append("settings")) + with pytest.raises(SystemExit): + main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) + assert calls == [ + "retrieve", + "search", + "list", + "modify", + "show_totp", + "settings", + "view_archived", + ] diff --git a/src/tests/test_nostr_entry.py b/src/tests/test_nostr_entry.py index cfedc8f..569dfeb 100644 --- a/src/tests/test_nostr_entry.py +++ b/src/tests/test_nostr_entry.py @@ -30,6 +30,7 @@ def test_nostr_key_determinism(): "index": idx, "label": "main", "notes": "", + "archived": False, } npub1, nsec1 = entry_mgr.get_nostr_key_pair(idx, TEST_SEED) diff --git a/src/tests/test_nostr_qr.py b/src/tests/test_nostr_qr.py index fe8cc84..ccb81e9 100644 --- a/src/tests/test_nostr_qr.py +++ b/src/tests/test_nostr_qr.py @@ -44,7 +44,8 @@ def test_show_qr_for_nostr_keys(monkeypatch): idx = entry_mgr.add_nostr_key("main") npub, _ = entry_mgr.get_nostr_key_pair(idx, TEST_SEED) - monkeypatch.setattr("builtins.input", lambda *a, **k: str(idx)) + inputs = iter([str(idx), "n", ""]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) responses = iter([True, False]) monkeypatch.setattr( "password_manager.manager.confirm_action", diff --git a/src/tests/test_search_entries.py b/src/tests/test_search_entries.py index 7f133ed..1d24c76 100644 --- a/src/tests/test_search_entries.py +++ b/src/tests/test_search_entries.py @@ -86,6 +86,17 @@ def test_search_by_custom_field(): assert result == [(idx, "Example", "", "", False)] +def test_search_key_value_value(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + entry_mgr = setup_entry_manager(tmp_path) + + idx = entry_mgr.add_key_value("API", "token123") + + result = entry_mgr.search_entries("token123") + assert result == [(idx, "API", None, None, False)] + + def test_search_no_results(): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) diff --git a/src/tests/test_secret_mode.py b/src/tests/test_secret_mode.py index 7087396..e11f7a2 100644 --- a/src/tests/test_secret_mode.py +++ b/src/tests/test_secret_mode.py @@ -41,7 +41,8 @@ def test_password_retrieve_secret_mode(monkeypatch, capsys): pm, entry_mgr = setup_pm(tmp) entry_mgr.add_entry("example", 8) - monkeypatch.setattr("builtins.input", lambda *a, **k: "0") + inputs = iter(["0", "n"]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) called = [] monkeypatch.setattr( "password_manager.manager.copy_to_clipboard", @@ -89,7 +90,8 @@ def test_password_retrieve_no_secret_mode(monkeypatch, capsys): pm.secret_mode_enabled = False entry_mgr.add_entry("example", 8) - monkeypatch.setattr("builtins.input", lambda *a, **k: "0") + inputs = iter(["0", "n"]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) called = [] monkeypatch.setattr( "password_manager.manager.copy_to_clipboard", diff --git a/src/tests/test_seed_entry.py b/src/tests/test_seed_entry.py index 5b43eb4..772fa55 100644 --- a/src/tests/test_seed_entry.py +++ b/src/tests/test_seed_entry.py @@ -34,6 +34,29 @@ def test_seed_phrase_determinism(): entry12 = entry_mgr.retrieve_entry(idx_12) entry24 = entry_mgr.retrieve_entry(idx_24) + assert entry12 == { + "type": "seed", + "kind": "seed", + "index": idx_12, + "label": "seed12", + "word_count": 12, + "notes": "", + "archived": False, + } + + assert entry24 == { + "type": "seed", + "kind": "seed", + "index": idx_24, + "label": "seed24", + "word_count": 24, + "notes": "", + "archived": False, + } + + assert phrase12_a not in entry12.values() + assert phrase24_a not in entry24.values() + seed_bytes = Bip39SeedGenerator(TEST_SEED).Generate() bip85 = BIP85(seed_bytes) expected12 = derive_seed_phrase(bip85, idx_12, 12) @@ -45,5 +68,5 @@ def test_seed_phrase_determinism(): assert len(phrase24_a.split()) == 24 assert Mnemonic("english").check(phrase12_a) assert Mnemonic("english").check(phrase24_a) - assert entry12.get("words") == 12 - assert entry24.get("words") == 24 + assert entry12.get("word_count") == 12 + assert entry24.get("word_count") == 24 diff --git a/src/tests/test_ssh_entry.py b/src/tests/test_ssh_entry.py index 7a2f552..695034c 100644 --- a/src/tests/test_ssh_entry.py +++ b/src/tests/test_ssh_entry.py @@ -28,6 +28,7 @@ def test_add_and_retrieve_ssh_key_pair(): "index": index, "label": "ssh", "notes": "", + "archived": False, } priv1, pub1 = entry_mgr.get_ssh_key_pair(index, TEST_SEED) diff --git a/src/tests/test_totp_entry.py b/src/tests/test_totp_entry.py index 2b6b301..b25d610 100644 --- a/src/tests/test_totp_entry.py +++ b/src/tests/test_totp_entry.py @@ -35,6 +35,8 @@ def test_add_totp_and_get_code(): "index": 0, "period": 30, "digits": 6, + "archived": False, + "notes": "", } code = entry_mgr.get_totp_code(0, TEST_SEED, timestamp=0) @@ -72,6 +74,19 @@ def test_add_totp_imported(tmp_path): "secret": secret, "period": 30, "digits": 6, + "archived": False, + "notes": "", } code = em.get_totp_code(0, timestamp=0) assert code == pyotp.TOTP(secret).at(0) + + +def test_add_totp_with_notes(tmp_path): + vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + em = EntryManager(vault, backup_mgr) + + em.add_totp("NoteLabel", TEST_SEED, notes="some note") + entry = em.retrieve_entry(0) + assert entry["notes"] == "some note"