Merge pull request #379 from PR0M3TH3AN/beta

Beta
This commit is contained in:
thePR0M3TH3AN
2025-07-07 19:59:39 -04:00
committed by GitHub
31 changed files with 1208 additions and 131 deletions

View File

@@ -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

View File

@@ -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 <query>` to retrieve a time-based one-time password (TOTP). The
`<query>` 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.

View File

@@ -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.
---

View File

@@ -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": "<checksum_value>"
}
"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": "<encrypted_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

View File

@@ -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;
</pre>
<a href="https://github.com/PR0M3TH3AN/SeedPass" class="btn-primary cta-button"><i class="fas fa-download" aria-hidden="true"></i> Get Started</a>
@@ -110,13 +111,14 @@ flowchart TD
end
A["Parent Seed<br>(BIP-39 Mnemonic)"] --> B["Seed Bytes<br>(BIP-39 → 512-bit)"]
B --> C["BIP-85 Derivation<br>(local_bip85.BIP85)"]
C --> D1["Password Entropy<br>(password_generation)"] & D2["TOTP Secret<br>(utils.key_derivation.derive_totp_secret)"] & D3["SSH Key Entropy<br>(password_generation.derive_ssh_key)"] & D4["PGP Key Entropy<br>(entry_management.add_pgp_key)"] & D5["Child Mnemonic<br>(BIP-85 derive_mnemonic)"] & D6["Nostr Key Entropy<br>(nostr.KeyManager)"]
C --> D1["Password Entropy<br>(password_generation)"] & D2["TOTP Secret<br>(utils.key_derivation.derive_totp_secret)"] & D3["SSH Key Entropy<br>(password_generation.derive_ssh_key)"] & D4["PGP Key Entropy<br>(entry_management.add_pgp_key)"] & D5["Child Mnemonic<br>(BIP-85 derive_mnemonic)"] & D6["Nostr Key Entropy<br>(nostr.KeyManager)"] & D7["Key/Value Data<br>(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<br>(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
</pre>
</div>
</section>
@@ -145,6 +147,7 @@ flowchart TD
<li><i class="fas fa-check" aria-hidden="true"></i> Checksum verification to ensure script integrity</li>
<li><i class="fas fa-terminal" aria-hidden="true"></i> Interactive TUI for managing entries and settings</li>
<li><i class="fas fa-shield-alt" aria-hidden="true"></i> Issue or import TOTP secrets for 2FA</li>
<li><i class="fas fa-key" aria-hidden="true"></i> Store arbitrary secrets as key/value pairs</li>
<li><i class="fas fa-file-export" aria-hidden="true"></i> Export your 2FA codes to an encrypted file</li>
<li><i class="fas fa-folder-open" aria-hidden="true"></i> Optional external backup location</li>
<li><i class="fas fa-lock" aria-hidden="true"></i> Auto-lock after inactivity</li>
@@ -198,6 +201,7 @@ Enter your choice (1-7) or press Enter to exit:
<div class="container">
<h2 class="section-title" id="disclaimer-heading">Disclaimer</h2>
<p><strong>⚠️ Disclaimer:</strong> 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.</p>
<p>Snapshot chunks are limited to 50&nbsp;KB and rotated when deltas accumulate.</p>
</div>
</section>
</main>

View File

@@ -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"))

View File

@@ -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:

View File

@@ -13,3 +13,4 @@ class EntryType(str, Enum):
SEED = "seed"
PGP = "pgp"
NOSTR = "nostr"
KEY_VALUE = "key_value"

View File

@@ -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))

View File

@@ -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

View File

@@ -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)]

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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)]

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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
]

View File

@@ -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",
]

View File

@@ -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)

View File

@@ -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",

View File

@@ -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)

View File

@@ -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",

View File

@@ -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

View File

@@ -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)

View File

@@ -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"