mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-10 00:09:04 +00:00
20
README.md
20
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
|
||||
|
@@ -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.
|
||||
|
@@ -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.
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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 KB and rotated when deltas accumulate.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
18
src/main.py
18
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"))
|
||||
|
||||
|
@@ -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:
|
||||
|
@@ -13,3 +13,4 @@ class EntryType(str, Enum):
|
||||
SEED = "seed"
|
||||
PGP = "pgp"
|
||||
NOSTR = "nostr"
|
||||
KEY_VALUE = "key_value"
|
||||
|
@@ -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))
|
||||
|
80
src/tests/test_archive_from_retrieve.py
Normal file
80
src/tests/test_archive_from_retrieve.py
Normal 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
|
42
src/tests/test_archive_nonpassword.py
Normal file
42
src/tests/test_archive_nonpassword.py
Normal 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)]
|
149
src/tests/test_archive_restore.py
Normal file
149
src/tests/test_archive_restore.py
Normal 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
|
91
src/tests/test_cli_export_import.py
Normal file
91
src/tests/test_cli_export_import.py
Normal 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
|
@@ -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)
|
||||
|
@@ -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()
|
||||
|
@@ -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
|
||||
|
43
src/tests/test_key_value_entry.py
Normal file
43
src/tests/test_key_value_entry.py
Normal 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)]
|
@@ -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
|
||||
|
@@ -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(
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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()
|
||||
|
@@ -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
|
||||
]
|
||||
|
55
src/tests/test_menu_navigation.py
Normal file
55
src/tests/test_menu_navigation.py
Normal 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",
|
||||
]
|
@@ -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)
|
||||
|
@@ -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",
|
||||
|
@@ -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)
|
||||
|
@@ -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",
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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"
|
||||
|
Reference in New Issue
Block a user