Merge pull request #712 from PR0M3TH3AN/beta

Beta
This commit is contained in:
thePR0M3TH3AN
2025-08-02 21:18:20 -04:00
committed by GitHub
61 changed files with 2340 additions and 296 deletions

View File

@@ -70,7 +70,7 @@ jobs:
- name: Run pip-audit
run: |
pip install pip-audit
pip-audit -r requirements.lock
pip-audit -r requirements.lock --ignore-vuln GHSA-wj6h-64fc-37mp
- name: Determine stress args
shell: bash
run: |

View File

@@ -32,6 +32,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
- [Running the Application](#running-the-application)
- [Managing Multiple Seeds](#managing-multiple-seeds)
- [Additional Entry Types](#additional-entry-types)
- [Recovery](#recovery)
- [Building a standalone executable](#building-a-standalone-executable)
- [Packaging with Briefcase](#packaging-with-briefcase)
- [Security Considerations](#security-considerations)
@@ -58,6 +59,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
- **Quick Unlock:** Optionally skip the password prompt after verifying once.
- **Secret Mode:** When enabled, newly generated and retrieved passwords are copied to your clipboard and automatically cleared after a delay.
- **Tagging Support:** Organize entries with optional tags and find them quickly via search.
- **Typed Search Results:** Results now display each entry's type for quicker identification.
- **Manual Vault Export/Import:** Create encrypted backups or restore them using the CLI or API.
- **Parent Seed Backup:** Securely save an encrypted copy of the master seed.
- **Manual Vault Locking:** Instantly clear keys from memory when needed.
@@ -113,6 +115,8 @@ See `docs/ARCHITECTURE.md` for details.
Use the automated installer to download SeedPass and its dependencies in one step.
The scripts also install the correct BeeWare backend for your platform automatically.
If the GTK `gi` bindings are missing, the installer attempts to install the
necessary system packages using `apt`, `yum`, `pacman`, or Homebrew.
**Linux and macOS:**
```bash
@@ -232,6 +236,7 @@ seedpass import --file "~/seedpass_backup.json"
seedpass search "github"
seedpass search --tags "work,personal"
seedpass get "github"
# Search results show the entry type, e.g. "1: Password - GitHub"
# Retrieve a TOTP entry
seedpass entry get "email"
# The code is printed and copied to your clipboard
@@ -239,6 +244,8 @@ seedpass entry get "email"
# Sort or filter the list view
seedpass list --sort label
seedpass list --filter totp
# Generate a password with the safe character set defined by `SAFE_SPECIAL_CHARS`
seedpass util generate-password --length 20 --special-mode safe --exclude-ambiguous
# Use the **Settings** menu to configure an extra backup directory
# on an external drive.
@@ -402,6 +409,15 @@ When choosing **Add Entry**, you can now select from:
- **Key/Value**
- **Managed Account**
### Adding a Password Entry
After selecting **Password**, SeedPass asks you to pick a mode:
1. **Quick** prompts only for a label, username, URL, desired length, and whether to include special characters. Default values are used for notes, tags, and policy settings.
2. **Advanced** walks through the full set of prompts for notes, tags, custom fields, and detailed password policy options.
Both modes generate the password, display it (or copy it to the clipboard in Secret Mode), and save the entry to your encrypted vault.
### Adding a 2FA Entry
1. From the main menu choose **Add Entry** and select **2FA (TOTP)**.
@@ -458,7 +474,7 @@ The table below summarizes the extra fields stored for each entry type. Every en
| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes`, optional `tags` |
| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes`, optional `tags` |
| Nostr Key Pair | `index`, `archived`, optional `notes`, optional `tags` |
| Key/Value | `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` |
| Key/Value | `key`, `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` |
| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes`, optional `tags` |
### Managing Multiple Seeds
@@ -539,6 +555,18 @@ seedpass config set nostr_retry_delay 1
The default configuration uses **50,000** PBKDF2 iterations. Increase this value for stronger password hashing or lower it for faster startup (not recommended). Offline Mode skips all Nostr communication, keeping your data local until you re-enable syncing. Quick Unlock stores a hashed copy of your password in the encrypted config so that after the initial unlock, subsequent operations won't prompt for the password until you exit the program. Avoid enabling Quick Unlock on shared machines.
### Recovery
If you previously backed up your vault to Nostr you can restore it during the
initial setup:
1. Start SeedPass and choose option **4** when prompted to set up a seed.
2. Paste your BIP-85 seed phrase when asked.
3. SeedPass initializes the profile and attempts to download the encrypted vault
from the configured relays.
4. A success message confirms the vault was restored. If no data is found a
failure message is shown and a new empty vault is created.
## Running Tests
SeedPass includes a small suite of unit tests located under `src/tests`. **Before running `pytest`, be sure to install the test requirements.** Activate your virtual environment and run `pip install -r src/requirements.txt` to ensure all testing dependencies are available. Then run the tests with **pytest**. Use `-vv` to see INFO-level log messages from each passing test:

View File

@@ -49,15 +49,15 @@ Manage individual entries within a vault.
| List entries | `entry list` | `seedpass entry list --sort label` |
| Search for entries | `entry search` | `seedpass entry search "GitHub"` |
| Retrieve an entry's secret (password or TOTP code) | `entry get` | `seedpass entry get "GitHub"` |
| Add a password entry | `entry add` | `seedpass entry add Example --length 16` |
| Add a password entry | `entry add` | `seedpass entry add Example --length 16 --no-special --exclude-ambiguous` |
| Add a TOTP entry | `entry add-totp` | `seedpass entry add-totp Email --secret JBSW...` |
| Add an SSH key entry | `entry add-ssh` | `seedpass entry add-ssh Server --index 0` |
| Add a PGP key entry | `entry add-pgp` | `seedpass entry add-pgp Personal --user-id me@example.com` |
| Add a Nostr key entry | `entry add-nostr` | `seedpass entry add-nostr Chat` |
| Add a seed phrase entry | `entry add-seed` | `seedpass entry add-seed Backup --words 24` |
| Add a key/value entry | `entry add-key-value` | `seedpass entry add-key-value "API Token" --value abc123` |
| Add a key/value entry | `entry add-key-value` | `seedpass entry add-key-value "API Token" --key api --value abc123` |
| Add a managed account entry | `entry add-managed-account` | `seedpass entry add-managed-account Trading` |
| Modify an entry | `entry modify` | `seedpass entry modify 1 --username alice` |
| Modify an entry | `entry modify` | `seedpass entry modify 1 --key new --value updated` |
| Archive an entry | `entry archive` | `seedpass entry archive 1` |
| Unarchive an entry | `entry unarchive` | `seedpass entry unarchive 1` |
| Export all TOTP secrets | `entry export-totp` | `seedpass entry export-totp --file totp.json` |
@@ -112,7 +112,7 @@ Miscellaneous helper commands.
| Action | Command | Examples |
| :--- | :--- | :--- |
| Generate a password | `util generate-password` | `seedpass util generate-password --length 24` |
| Generate a password | `util generate-password` | `seedpass util generate-password --length 24 --special-mode safe --exclude-ambiguous` |
| Verify script checksum | `util verify-checksum` | `seedpass util verify-checksum` |
| Update script checksum | `util update-checksum` | `seedpass util update-checksum` |
@@ -136,17 +136,17 @@ Run or stop the local HTTP API.
### `entry` Commands
- **`seedpass entry list`** List entries in the vault, optionally sorted or filtered.
- **`seedpass entry search <query>`** Search across labels, usernames, URLs and notes.
- **`seedpass entry search <query>`** Search across labels, usernames, URLs and notes. Results show the entry type before each label.
- **`seedpass entry get <query>`** Retrieve the password or TOTP code for one matching entry, depending on the entry's type.
- **`seedpass entry add <label>`** Create a new password entry. Use `--length` to set the password length and optional `--username`/`--url` values.
- **`seedpass entry add <label>`** Create a new password entry. Use `--length` and flags like `--no-special`, `--special-mode safe`, or `--exclude-ambiguous` to override the global policy.
- **`seedpass entry add-totp <label>`** Create a TOTP entry. Use `--secret` to import an existing secret or `--index` to derive from the seed.
- **`seedpass entry add-ssh <label>`** Create an SSH key entry derived from the seed.
- **`seedpass entry add-pgp <label>`** Create a PGP key entry. Provide `--user-id` and `--key-type` as needed.
- **`seedpass entry add-nostr <label>`** Create a Nostr key entry for decentralised chat.
- **`seedpass entry add-seed <label>`** Store a derived seed phrase. Use `--words` to set the word count.
- **`seedpass entry add-key-value <label>`** Store arbitrary data with `--value`.
- **`seedpass entry add-key-value <label>`** Store arbitrary data with `--key` and `--value`.
- **`seedpass entry add-managed-account <label>`** Store a BIP85 derived account seed.
- **`seedpass entry modify <id>`** Update an entry's label, username, URL or notes.
- **`seedpass entry modify <id>`** Update an entry's fields. For key/value entries you can change the label, key and value.
- **`seedpass entry archive <id>`** Mark an entry as archived so it is hidden from normal lists.
- **`seedpass entry unarchive <id>`** Restore an archived entry.
- **`seedpass entry export-totp --file <path>`** Export all stored TOTP secrets to a JSON file.
@@ -185,7 +185,7 @@ QR codes for supported types.
### `config` Commands
- **`seedpass config get <key>`** Retrieve a configuration value such as `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `clipboard_clear_delay`, `additional_backup_path`, `relays`, `quick_unlock`, `nostr_max_retries`, `nostr_retry_delay`, or password policy fields like `min_uppercase`.
- **`seedpass config set <key> <value>`** Update a configuration option. Example: `seedpass config set kdf_iterations 200000`. Use keys like `min_uppercase`, `min_lowercase`, `min_digits`, `min_special`, `nostr_max_retries`, `nostr_retry_delay`, or `quick_unlock` to adjust settings.
- **`seedpass config set <key> <value>`** Update a configuration option. Example: `seedpass config set kdf_iterations 200000`. Use keys like `min_uppercase`, `min_lowercase`, `min_digits`, `min_special`, `include_special_chars`, `allowed_special_chars`, `special_mode`, `exclude_ambiguous`, `nostr_max_retries`, `nostr_retry_delay`, or `quick_unlock` to adjust settings.
- **`seedpass config toggle-secret-mode`** Interactively enable or disable Secret Mode and set the clipboard delay.
- **`seedpass config toggle-offline`** Enable or disable offline mode to skip Nostr operations.
@@ -223,5 +223,5 @@ Shut down the server with `seedpass api stop`.
- Use the `--help` flag for details on any command.
- Set a strong master password and regularly export encrypted backups.
- Adjust configuration values like `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `nostr_max_retries`, `nostr_retry_delay`, or `quick_unlock` through the `config` commands.
- Customize password complexity with `config set min_uppercase 3`, `config set min_digits 4`, and similar commands.
- Customize the global password policy with commands like `config set min_uppercase 3` or `config set special_mode safe`. When adding a password interactively you can override these values, choose a safe special-character set, and exclude ambiguous characters.
- `entry get` is scriptfriendly and can be piped into other commands.

View File

@@ -95,10 +95,22 @@ Each entry is stored within `seedpass_entries_db.json.enc` under the `entries` d
- **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.
- **key** (`string`, optional): Name of the key for `key_value` entries.
- **index** (`integer`, optional): BIP-85 derivation index for entries that derive material from a seed.
- **word_count** (`integer`, managed_account only): Number of words in the child seed. Managed accounts always use `12`.
- **fingerprint** (`string`, managed_account only): Identifier of the child profile, used for its directory name.
- **tags** (`array`, optional): Category labels to aid in organization and search.
#### Password Policy Fields
- **min_uppercase** (`integer`, default `2`): Minimum required uppercase letters.
- **min_lowercase** (`integer`, default `2`): Minimum required lowercase letters.
- **min_digits** (`integer`, default `2`): Minimum required digits.
- **min_special** (`integer`, default `2`): Minimum required special characters.
- **include_special_chars** (`boolean`, default `true`): Enable or disable any punctuation in generated passwords.
- **allowed_special_chars** (`string`, optional): Restrict punctuation to this exact set.
- **special_mode** (`string`, default `"standard"`): Choose `"safe"` for the `SAFE_SPECIAL_CHARS` set (`!@#$%^*-_+=?`), otherwise the full `string.punctuation` is used.
- **exclude_ambiguous** (`boolean`, default `false`): Omit confusing characters like `O0Il1`.
Example:
```json
@@ -160,6 +172,17 @@ Each entry is stored within `seedpass_entries_db.json.enc` under the `entries` d
}
```
#### Password Entry with Policy Overrides
```json
{
"label": "Custom Policy",
"length": 16,
"include_special_chars": false,
"exclude_ambiguous": true
}
```
#### 3. Managed User
```json

View File

@@ -70,6 +70,7 @@ maintainable while enabling a consistent experience on multiple platforms.
- **Quick Unlock:** Optionally skip the password prompt after verifying once. Startup delay is unaffected.
- **Secret Mode:** Copy retrieved passwords directly to your clipboard and automatically clear it after a delay.
- **Tagging Support:** Organize entries with optional tags and find them quickly via search.
- **Typed Search Results:** Searches display each entry's type for easier scanning.
- **Manual Vault Export/Import:** Create encrypted backups or restore them using the CLI or API.
- **Parent Seed Backup:** Securely save an encrypted copy of the master seed.
- **Manual Vault Locking:** Instantly clear keys from memory when needed.
@@ -90,6 +91,8 @@ maintainable while enabling a consistent experience on multiple platforms.
### Quick Installer
Use the automated installer to download SeedPass and its dependencies in one step.
If GTK packages are missing, the installer will try to install them using your
system's package manager (`apt`, `yum`, `pacman`, or Homebrew).
**Linux and macOS:**
```bash
@@ -217,6 +220,7 @@ seedpass vault import --file "~/seedpass_backup.json"
seedpass search "github"
seedpass search --tags "work,personal"
seedpass get "github"
# Search results show the entry type, e.g. "1: Password - GitHub"
# Retrieve a TOTP entry
seedpass entry get "email"
# The code is printed and copied to your clipboard
@@ -312,6 +316,15 @@ When choosing **Add Entry**, you can now select from:
- **Key/Value**
- **Managed Account**
### Adding a Password Entry
After selecting **Password**, SeedPass asks you to choose a mode:
1. **Quick** enter only a label, username, URL, desired length, and whether to include special characters. All other fields use defaults.
2. **Advanced** continue through prompts for notes, tags, custom fields, and detailed password policy settings.
Both modes generate the password, display it (or copy it to the clipboard in Secret Mode), and save the entry to your encrypted vault.
### Adding a 2FA Entry
1. From the main menu choose **Add Entry** and select **2FA (TOTP)**.
@@ -363,7 +376,7 @@ entry includes a `label`, while only password entries track a `url`.
| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes`, optional `tags` |
| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes`, optional `tags` |
| Nostr Key Pair| `index`, `archived`, optional `notes`, optional `tags` |
| Key/Value | `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` |
| Key/Value | `key`, `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` |
| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes`, optional `tags` |

View File

@@ -31,7 +31,7 @@ requires = [
"aiohttp>=3.12.14",
"bcrypt",
"portalocker>=2.8",
"nostr-sdk>=0.42.1",
"nostr-sdk>=0.43",
"websocket-client==1.7.0",
"websockets>=15.0.0",
"tomli",

View File

@@ -32,7 +32,7 @@ mnemonic==0.21
monero==1.1.1
multidict==6.6.3
mutmut==2.4.4
nostr-sdk==0.42.1
nostr-sdk==0.43.0
orjson==3.10.18
packaging==25.0
parso==0.8.4

View File

@@ -21,6 +21,32 @@ print_info() { echo -e "\033[1;34m[INFO]\033[0m $1"; }
print_success() { echo -e "\033[1;32m[SUCCESS]\033[0m $1"; }
print_warning() { echo -e "\033[1;33m[WARNING]\033[0m $1"; }
print_error() { echo -e "\033[1;31m[ERROR]\033[0m $1" >&2; exit 1; }
# Install build dependencies for Gtk/GObject if available via the system package manager
install_dependencies() {
print_info "Installing system packages required for Gtk bindings..."
if command -v apt-get &>/dev/null; then
sudo apt-get update && sudo apt-get install -y \
build-essential pkg-config libcairo2 libcairo2-dev \
libgirepository1.0-dev gobject-introspection \
gir1.2-gtk-3.0 python3-dev libffi-dev libssl-dev xclip
elif command -v yum &>/dev/null; then
sudo yum install -y @'Development Tools' cairo cairo-devel \
gobject-introspection-devel gtk3-devel python3-devel \
libffi-devel openssl-devel xclip
elif command -v dnf &>/dev/null; then
sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y \
cairo cairo-devel gobject-introspection-devel gtk3-devel \
python3-devel libffi-devel openssl-devel xclip
elif command -v pacman &>/dev/null; then
sudo pacman -Syu --noconfirm base-devel pkgconf cairo \
gobject-introspection gtk3 python xclip
elif command -v brew &>/dev/null; then
brew install pkg-config cairo gobject-introspection gtk+3
else
print_warning "Unsupported package manager. Please install Gtk/GObject dependencies manually."
fi
}
usage() {
echo "Usage: $0 [-b | --branch <branch_name>] [-h | --help]"
echo " -b, --branch Specify the git branch to install (default: main)"
@@ -84,28 +110,12 @@ main() {
fi
# 3. Install OS-specific dependencies
print_info "Checking for build dependencies..."
if [ "$OS_NAME" = "Linux" ]; then
if command -v apt-get &> /dev/null; then
sudo apt-get update && sudo apt-get install -y \
build-essential pkg-config xclip \
libcairo2 libcairo2-dev \
libgirepository-2.0-dev gir1.2-girepository-2.0 \
gobject-introspection \
gir1.2-gtk-3.0 python3-dev
elif command -v dnf &> /dev/null; then
sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y \
pkg-config cairo cairo-devel xclip \
gobject-introspection-devel cairo-devel gtk3-devel python3-devel
elif command -v pacman &> /dev/null; then
sudo pacman -Syu --noconfirm base-devel pkg-config cairo xclip \
gobject-introspection cairo gtk3 python
else
print_warning "Could not detect package manager. Ensure build tools, cairo, and pkg-config are installed."
fi
elif [ "$OS_NAME" = "Darwin" ]; then
if ! command -v brew &> /dev/null; then print_error "Homebrew not installed. See https://brew.sh/"; fi
brew install pkg-config cairo
print_info "Checking for Gtk development libraries..."
if ! python3 -c "import gi" &>/dev/null; then
print_warning "Gtk introspection bindings not found. Installing dependencies..."
install_dependencies
else
print_info "Gtk bindings already available."
fi
# 4. Clone or update the repository

View File

@@ -50,6 +50,9 @@ DEFAULT_PASSWORD_LENGTH = 16 # Default length for generated passwords
MIN_PASSWORD_LENGTH = 8 # Minimum allowed password length
MAX_PASSWORD_LENGTH = 128 # Maximum allowed password length
# Characters considered safe for passwords when limiting punctuation
SAFE_SPECIAL_CHARS = "!@#$%^*-_+=?"
# Timeout in seconds before the vault locks due to inactivity
INACTIVITY_TIMEOUT = 15 * 60 # 15 minutes

View File

@@ -275,12 +275,24 @@ def handle_display_npub(password_manager: PasswordManager):
def _display_live_stats(
password_manager: PasswordManager, interval: float = 1.0
) -> None:
"""Continuously refresh stats until the user presses Enter."""
"""Continuously refresh stats until the user presses Enter.
Each refresh also triggers a background sync so the latest stats are
displayed if newer data exists on Nostr.
"""
stats_mgr = getattr(password_manager, "stats_manager", None)
display_fn = getattr(password_manager, "display_stats", None)
sync_fn = getattr(password_manager, "start_background_sync", None)
if not callable(display_fn):
return
if callable(sync_fn):
try:
sync_fn()
except Exception as exc: # pragma: no cover - sync best effort
logging.debug("Background sync failed during stats display: %s", exc)
if not sys.stdin or not sys.stdin.isatty():
clear_screen()
display_fn()
@@ -289,9 +301,16 @@ def _display_live_stats(
print(note)
print(colored("Press Enter to continue.", "cyan"))
pause()
if stats_mgr is not None:
stats_mgr.reset()
return
while True:
if callable(sync_fn):
try:
sync_fn()
except Exception: # pragma: no cover - sync best effort
logging.debug("Background sync failed during stats display")
clear_screen()
display_fn()
note = get_notification_text(password_manager)
@@ -308,6 +327,8 @@ def _display_live_stats(
except KeyboardInterrupt:
print()
break
if stats_mgr is not None:
stats_mgr.reset()
def handle_display_stats(password_manager: PasswordManager) -> None:
@@ -321,31 +342,28 @@ def handle_display_stats(password_manager: PasswordManager) -> None:
def print_matches(
password_manager: PasswordManager,
matches: list[tuple[int, str, str | None, str | None, bool]],
matches: list[tuple[int, str, str | None, str | None, bool, EntryType]],
) -> None:
"""Print a list of search matches."""
print(colored("\n[+] Matches:\n", "green"))
for entry in matches:
idx, website, username, url, blacklisted = entry
idx, website, username, url, blacklisted, etype = entry
data = password_manager.entry_manager.retrieve_entry(idx)
etype = (
data.get("type", data.get("kind", EntryType.PASSWORD.value))
if data
else EntryType.PASSWORD.value
)
print(color_text(f"Index: {idx}", "index"))
if etype == EntryType.TOTP.value:
print(color_text(f" Label: {data.get('label', website)}", "index"))
print(color_text(f" Derivation Index: {data.get('index', idx)}", "index"))
elif etype == EntryType.SEED.value:
if etype == EntryType.TOTP:
label = data.get("label", website) if data else website
deriv = data.get("index", idx) if data else idx
print(color_text(f" Label: {label}", "index"))
print(color_text(f" Derivation Index: {deriv}", "index"))
elif etype == EntryType.SEED:
print(color_text(" Type: Seed Phrase", "index"))
elif etype == EntryType.SSH.value:
elif etype == EntryType.SSH:
print(color_text(" Type: SSH Key", "index"))
elif etype == EntryType.PGP.value:
elif etype == EntryType.PGP:
print(color_text(" Type: PGP Key", "index"))
elif etype == EntryType.NOSTR.value:
elif etype == EntryType.NOSTR:
print(color_text(" Type: Nostr Key", "index"))
elif etype == EntryType.KEY_VALUE.value:
elif etype == EntryType.KEY_VALUE:
print(color_text(" Type: Key/Value", "index"))
else:
if website:
@@ -390,6 +408,7 @@ def handle_retrieve_from_nostr(password_manager: PasswordManager):
Handles the action of retrieving the encrypted password index from Nostr.
"""
try:
password_manager.nostr_client.fingerprint = password_manager.current_fingerprint
result = asyncio.run(password_manager.nostr_client.fetch_latest_snapshot())
if result:
manifest, chunks = result
@@ -407,8 +426,12 @@ def handle_retrieve_from_nostr(password_manager: PasswordManager):
print(colored("Encrypted index retrieved and saved successfully.", "green"))
logging.info("Encrypted index retrieved and saved successfully from Nostr.")
else:
print(colored("Failed to retrieve data from Nostr.", "red"))
logging.error("Failed to retrieve data from Nostr.")
msg = (
f"No Nostr events found for fingerprint"
f" {password_manager.current_fingerprint}."
)
print(colored(msg, "red"))
logging.error(msg)
except Exception as e:
logging.error(f"Failed to retrieve from Nostr: {e}", exc_info=True)
print(colored(f"Error: Failed to retrieve from Nostr: {e}", "red"))
@@ -433,10 +456,21 @@ def handle_view_relays(cfg_mgr: "ConfigManager") -> None:
print(colored(f"Error: {e}", "red"))
def _safe_close_client_pool(pm: PasswordManager) -> None:
"""Close the Nostr client pool if the client exists."""
client = getattr(pm, "nostr_client", None)
if client is None:
return
try:
client.close_client_pool()
except Exception as exc:
logging.error(f"Error during NostrClient shutdown: {exc}")
def _reload_relays(password_manager: PasswordManager, relays: list) -> None:
"""Reload NostrClient with the updated relay list."""
try:
password_manager.nostr_client.close_client_pool()
_safe_close_client_pool(password_manager)
except Exception as exc:
logging.warning(f"Failed to close client pool: {exc}")
try:
@@ -1024,7 +1058,8 @@ def display_menu(
continue
logging.info("Exiting the program.")
print(colored("Exiting the program.", "green"))
password_manager.nostr_client.close_client_pool()
getattr(password_manager, "cleanup", lambda: None)()
_safe_close_client_pool(password_manager)
sys.exit(0)
if choice == "1":
while True:
@@ -1227,7 +1262,8 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
print(colored("\nReceived shutdown signal. Exiting gracefully...", "yellow"))
logging.info(f"Received shutdown signal: {sig}. Initiating graceful shutdown.")
try:
password_manager.nostr_client.close_client_pool()
getattr(password_manager, "cleanup", lambda: None)()
_safe_close_client_pool(password_manager)
logging.info("NostrClient closed successfully.")
except Exception as exc:
logging.error(f"Error during shutdown: {exc}")
@@ -1245,7 +1281,8 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
logger.info("Program terminated by user via KeyboardInterrupt.")
print(colored("\nProgram terminated by user.", "yellow"))
try:
password_manager.nostr_client.close_client_pool()
getattr(password_manager, "cleanup", lambda: None)()
_safe_close_client_pool(password_manager)
logging.info("NostrClient closed successfully.")
except Exception as exc:
logging.error(f"Error during shutdown: {exc}")
@@ -1255,7 +1292,8 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
logger.error(f"A user-related error occurred: {e}", exc_info=True)
print(colored(f"Error: {e}", "red"))
try:
password_manager.nostr_client.close_client_pool()
getattr(password_manager, "cleanup", lambda: None)()
_safe_close_client_pool(password_manager)
logging.info("NostrClient closed successfully.")
except Exception as exc:
logging.error(f"Error during shutdown: {exc}")
@@ -1265,7 +1303,8 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
logger.error(f"An unexpected error occurred: {e}", exc_info=True)
print(colored(f"Error: An unexpected error occurred: {e}", "red"))
try:
password_manager.nostr_client.close_client_pool()
getattr(password_manager, "cleanup", lambda: None)()
_safe_close_client_pool(password_manager)
logging.info("NostrClient closed successfully.")
except Exception as exc:
logging.error(f"Error during shutdown: {exc}")

View File

@@ -21,6 +21,7 @@ from nostr_sdk import (
Kind,
KindStandard,
Tag,
RelayUrl,
)
from datetime import timedelta
from nostr_sdk import EventId, Timestamp
@@ -173,14 +174,26 @@ class NostrClient:
async def _initialize_client_pool(self) -> None:
if self.offline_mode or not self.relays:
return
formatted = []
for relay in self.relays:
if isinstance(relay, str):
try:
formatted.append(RelayUrl.parse(relay))
except Exception:
logger.error("Invalid relay URL: %s", relay)
else:
formatted.append(relay)
if hasattr(self.client, "add_relays"):
await self.client.add_relays(self.relays)
await self.client.add_relays(formatted)
else:
for relay in self.relays:
for relay in formatted:
await self.client.add_relay(relay)
await self.client.connect()
self._connected = True
logger.info(f"NostrClient connected to relays: {self.relays}")
logger.info("NostrClient connected to relays: %s", formatted)
async def _ping_relay(self, relay: str, timeout: float) -> bool:
"""Attempt to retrieve the latest event from a single relay."""

View File

@@ -11,7 +11,7 @@ pytest>=7.0
pytest-cov
pytest-xdist
portalocker>=2.8
nostr-sdk>=0.42.1
nostr-sdk>=0.43
websocket-client==1.7.0
websockets>=15.0.0

View File

@@ -10,7 +10,7 @@ mnemonic
aiohttp>=3.12.14
bcrypt
portalocker>=2.8
nostr-sdk>=0.42.1
nostr-sdk>=0.43
websocket-client==1.7.0
websockets>=15.0.0

View File

@@ -16,6 +16,7 @@ from fastapi.middleware.cors import CORSMiddleware
from seedpass.core.manager import PasswordManager
from seedpass.core.entry_types import EntryType
from seedpass.core.api import UtilityService
app = FastAPI()
@@ -85,8 +86,9 @@ def search_entry(query: str, authorization: str | None = Header(None)) -> List[A
"username": username,
"url": url,
"archived": archived,
"type": etype.value,
}
for idx, label, username, url, archived in results
for idx, label, username, url, archived, etype in results
]
@@ -117,11 +119,23 @@ def create_entry(
etype = (entry.get("type") or entry.get("kind") or "password").lower()
if etype == "password":
policy_keys = [
"include_special_chars",
"allowed_special_chars",
"special_mode",
"exclude_ambiguous",
"min_uppercase",
"min_lowercase",
"min_digits",
"min_special",
]
kwargs = {k: entry.get(k) for k in policy_keys if entry.get(k) is not None}
index = _pm.entry_manager.add_entry(
entry.get("label"),
int(entry.get("length", 12)),
entry.get("username"),
entry.get("url"),
**kwargs,
)
return {"id": index}
@@ -164,6 +178,7 @@ def create_entry(
if etype == "nostr":
index = _pm.entry_manager.add_nostr_key(
entry.get("label"),
_pm.parent_seed,
index=entry.get("index"),
notes=entry.get("notes", ""),
archived=entry.get("archived", False),
@@ -173,6 +188,7 @@ def create_entry(
if etype == "key_value":
index = _pm.entry_manager.add_key_value(
entry.get("label"),
entry.get("key"),
entry.get("value"),
notes=entry.get("notes", ""),
)
@@ -217,6 +233,7 @@ def update_entry(
label=entry.get("label"),
period=entry.get("period"),
digits=entry.get("digits"),
key=entry.get("key"),
value=entry.get("value"),
)
except ValueError as e:
@@ -564,6 +581,30 @@ def change_password(
return {"status": "ok"}
@app.post("/api/v1/password")
def generate_password(
data: dict, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Generate a password using optional policy overrides."""
_check_token(authorization)
assert _pm is not None
length = int(data.get("length", 12))
policy_keys = [
"include_special_chars",
"allowed_special_chars",
"special_mode",
"exclude_ambiguous",
"min_uppercase",
"min_lowercase",
"min_digits",
"min_special",
]
kwargs = {k: data.get(k) for k in policy_keys if data.get(k) is not None}
util = UtilityService(_pm)
password = util.generate_password(length, **kwargs)
return {"password": password}
@app.post("/api/v1/vault/lock")
def lock_vault(authorization: str | None = Header(None)) -> dict[str, str]:
"""Lock the vault and clear sensitive data from memory."""

View File

@@ -161,8 +161,8 @@ def entry_search(
if not results:
typer.echo("No matching entries found")
return
for idx, label, username, url, _arch in results:
line = f"{idx}: {label}"
for idx, label, username, url, _arch, etype in results:
line = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}"
if username:
line += f" ({username})"
if url:
@@ -180,8 +180,8 @@ def entry_get(ctx: typer.Context, query: str) -> None:
raise typer.Exit(code=1)
if len(matches) > 1:
typer.echo("Matches:")
for idx, label, username, _url, _arch in matches:
name = f"{idx}: {label}"
for idx, label, username, _url, _arch, etype in matches:
name = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}"
if username:
name += f" ({username})"
typer.echo(name)
@@ -209,10 +209,49 @@ def entry_add(
length: int = typer.Option(12, "--length"),
username: Optional[str] = typer.Option(None, "--username"),
url: Optional[str] = typer.Option(None, "--url"),
no_special: bool = typer.Option(
False, "--no-special", help="Exclude special characters", is_flag=True
),
allowed_special_chars: Optional[str] = typer.Option(
None, "--allowed-special-chars", help="Explicit set of special characters"
),
special_mode: Optional[str] = typer.Option(
None,
"--special-mode",
help="Special character mode",
),
exclude_ambiguous: bool = typer.Option(
False,
"--exclude-ambiguous",
help="Exclude ambiguous characters",
is_flag=True,
),
min_uppercase: Optional[int] = typer.Option(None, "--min-uppercase"),
min_lowercase: Optional[int] = typer.Option(None, "--min-lowercase"),
min_digits: Optional[int] = typer.Option(None, "--min-digits"),
min_special: Optional[int] = typer.Option(None, "--min-special"),
) -> None:
"""Add a new password entry and output its index."""
service = _get_entry_service(ctx)
index = service.add_entry(label, length, username, url)
kwargs = {}
if no_special:
kwargs["include_special_chars"] = False
if allowed_special_chars is not None:
kwargs["allowed_special_chars"] = allowed_special_chars
if special_mode is not None:
kwargs["special_mode"] = special_mode
if exclude_ambiguous:
kwargs["exclude_ambiguous"] = True
if min_uppercase is not None:
kwargs["min_uppercase"] = min_uppercase
if min_lowercase is not None:
kwargs["min_lowercase"] = min_lowercase
if min_digits is not None:
kwargs["min_digits"] = min_digits
if min_special is not None:
kwargs["min_special"] = min_special
index = service.add_entry(label, length, username, url, **kwargs)
typer.echo(str(index))
@@ -315,12 +354,13 @@ def entry_add_seed(
def entry_add_key_value(
ctx: typer.Context,
label: str,
key: str = typer.Option(..., "--key", help="Key name"),
value: str = typer.Option(..., "--value", help="Stored value"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a key/value entry and output its index."""
service = _get_entry_service(ctx)
idx = service.add_key_value(label, value, notes=notes)
idx = service.add_key_value(label, key, value, notes=notes)
typer.echo(str(idx))
@@ -353,6 +393,7 @@ def entry_modify(
None, "--period", help="TOTP period in seconds"
),
digits: Optional[int] = typer.Option(None, "--digits", help="TOTP digits"),
key: Optional[str] = typer.Option(None, "--key", help="New key"),
value: Optional[str] = typer.Option(None, "--value", help="New value"),
) -> None:
"""Modify an existing entry."""
@@ -366,6 +407,7 @@ def entry_modify(
label=label,
period=period,
digits=digits,
key=key,
value=value,
)
except ValueError as e:
@@ -705,10 +747,52 @@ def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None:
@util_app.command("generate-password")
def generate_password(ctx: typer.Context, length: int = 24) -> None:
def generate_password(
ctx: typer.Context,
length: int = 24,
no_special: bool = typer.Option(
False, "--no-special", help="Exclude special characters", is_flag=True
),
allowed_special_chars: Optional[str] = typer.Option(
None, "--allowed-special-chars", help="Explicit set of special characters"
),
special_mode: Optional[str] = typer.Option(
None,
"--special-mode",
help="Special character mode",
),
exclude_ambiguous: bool = typer.Option(
False,
"--exclude-ambiguous",
help="Exclude ambiguous characters",
is_flag=True,
),
min_uppercase: Optional[int] = typer.Option(None, "--min-uppercase"),
min_lowercase: Optional[int] = typer.Option(None, "--min-lowercase"),
min_digits: Optional[int] = typer.Option(None, "--min-digits"),
min_special: Optional[int] = typer.Option(None, "--min-special"),
) -> None:
"""Generate a strong password."""
service = _get_util_service(ctx)
password = service.generate_password(length)
kwargs = {}
if no_special:
kwargs["include_special_chars"] = False
if allowed_special_chars is not None:
kwargs["allowed_special_chars"] = allowed_special_chars
if special_mode is not None:
kwargs["special_mode"] = special_mode
if exclude_ambiguous:
kwargs["exclude_ambiguous"] = True
if min_uppercase is not None:
kwargs["min_uppercase"] = min_uppercase
if min_lowercase is not None:
kwargs["min_lowercase"] = min_lowercase
if min_digits is not None:
kwargs["min_digits"] = min_digits
if min_special is not None:
kwargs["min_special"] = min_special
password = service.generate_password(length, **kwargs)
typer.echo(password)

View File

@@ -4,7 +4,14 @@
from importlib import import_module
__all__ = ["PasswordManager", "ConfigManager", "Vault", "EntryType", "StateManager"]
__all__ = [
"PasswordManager",
"ConfigManager",
"Vault",
"EntryType",
"StateManager",
"StatsManager",
]
def __getattr__(name: str):
@@ -18,4 +25,6 @@ def __getattr__(name: str):
return import_module(".entry_types", __name__).EntryType
if name == "StateManager":
return import_module(".state_manager", __name__).StateManager
if name == "StatsManager":
return import_module(".stats_manager", __name__).StatsManager
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")

View File

@@ -9,13 +9,15 @@ allow easy validation and documentation.
from pathlib import Path
from threading import Lock
from typing import List, Optional, Dict
from typing import List, Optional, Dict, Any
import dataclasses
import json
from pydantic import BaseModel
from .manager import PasswordManager
from .pubsub import bus
from .entry_types import EntryType
class VaultExportRequest(BaseModel):
@@ -83,6 +85,34 @@ class SyncResponse(BaseModel):
delta_ids: List[str] = []
class PasswordPolicyOptions(BaseModel):
"""Optional password policy overrides."""
include_special_chars: bool | None = None
allowed_special_chars: str | None = None
special_mode: str | None = None
exclude_ambiguous: bool | None = None
min_uppercase: int | None = None
min_lowercase: int | None = None
min_digits: int | None = None
min_special: int | None = None
class AddPasswordEntryRequest(PasswordPolicyOptions):
label: str
length: int
username: str | None = None
url: str | None = None
class GeneratePasswordRequest(PasswordPolicyOptions):
length: int
class GeneratePasswordResponse(BaseModel):
password: str
class VaultService:
"""Thread-safe wrapper around vault operations."""
@@ -245,7 +275,7 @@ class EntryService:
def search_entries(
self, query: str, kinds: list[str] | None = None
) -> list[tuple[int, str, str | None, str | None, bool]]:
) -> list[tuple[int, str, str | None, str | None, bool, EntryType]]:
"""Search entries optionally filtering by ``kinds``.
Parameters
@@ -265,7 +295,11 @@ class EntryService:
def generate_password(self, length: int, index: int) -> str:
with self._lock:
return self._manager.password_generator.generate_password(length, index)
entry = self._manager.entry_manager.retrieve_entry(index)
gen_fn = getattr(self._manager, "_generate_password_for_entry", None)
if gen_fn is None:
return self._manager.password_generator.generate_password(length, index)
return gen_fn(entry, index, length)
def get_totp_code(self, entry_id: int) -> str:
with self._lock:
@@ -279,9 +313,42 @@ class EntryService:
length: int,
username: str | None = None,
url: str | None = None,
*,
include_special_chars: bool | None = None,
allowed_special_chars: str | None = None,
special_mode: str | None = None,
exclude_ambiguous: bool | None = None,
min_uppercase: int | None = None,
min_lowercase: int | None = None,
min_digits: int | None = None,
min_special: int | None = None,
) -> int:
with self._lock:
idx = self._manager.entry_manager.add_entry(label, length, username, url)
kwargs: dict[str, Any] = {}
if include_special_chars is not None:
kwargs["include_special_chars"] = include_special_chars
if allowed_special_chars is not None:
kwargs["allowed_special_chars"] = allowed_special_chars
if special_mode is not None:
kwargs["special_mode"] = special_mode
if exclude_ambiguous is not None:
kwargs["exclude_ambiguous"] = exclude_ambiguous
if min_uppercase is not None:
kwargs["min_uppercase"] = min_uppercase
if min_lowercase is not None:
kwargs["min_lowercase"] = min_lowercase
if min_digits is not None:
kwargs["min_digits"] = min_digits
if min_special is not None:
kwargs["min_special"] = min_special
idx = self._manager.entry_manager.add_entry(
label,
length,
username,
url,
**kwargs,
)
self._manager.start_background_vault_sync()
return idx
@@ -354,6 +421,7 @@ class EntryService:
with self._lock:
idx = self._manager.entry_manager.add_nostr_key(
label,
self._manager.parent_seed,
index=index,
notes=notes,
)
@@ -379,9 +447,13 @@ class EntryService:
self._manager.start_background_vault_sync()
return idx
def add_key_value(self, label: str, value: str, *, notes: str = "") -> int:
def add_key_value(
self, label: str, key: str, value: str, *, notes: str = ""
) -> int:
with self._lock:
idx = self._manager.entry_manager.add_key_value(label, value, notes=notes)
idx = self._manager.entry_manager.add_key_value(
label, key, value, notes=notes
)
self._manager.start_background_vault_sync()
return idx
@@ -412,6 +484,7 @@ class EntryService:
label: str | None = None,
period: int | None = None,
digits: int | None = None,
key: str | None = None,
value: str | None = None,
) -> None:
with self._lock:
@@ -423,6 +496,7 @@ class EntryService:
label=label,
period=period,
digits=digits,
key=key,
value=value,
)
self._manager.start_background_vault_sync()
@@ -482,6 +556,16 @@ class ConfigService:
"min_lowercase": ("set_min_lowercase", int),
"min_digits": ("set_min_digits", int),
"min_special": ("set_min_special", int),
"include_special_chars": (
"set_include_special_chars",
lambda v: v.lower() in ("1", "true", "yes", "y", "on"),
),
"allowed_special_chars": ("set_allowed_special_chars", lambda v: v),
"special_mode": ("set_special_mode", lambda v: v),
"exclude_ambiguous": (
"set_exclude_ambiguous",
lambda v: v.lower() in ("1", "true", "yes", "y", "on"),
),
"quick_unlock": (
"set_quick_unlock",
lambda v: v.lower() in ("1", "true", "yes", "y", "on"),
@@ -537,9 +621,50 @@ class UtilityService:
self._manager = manager
self._lock = Lock()
def generate_password(self, length: int) -> str:
def generate_password(
self,
length: int,
*,
include_special_chars: bool | None = None,
allowed_special_chars: str | None = None,
special_mode: str | None = None,
exclude_ambiguous: bool | None = None,
min_uppercase: int | None = None,
min_lowercase: int | None = None,
min_digits: int | None = None,
min_special: int | None = None,
) -> str:
with self._lock:
return self._manager.password_generator.generate_password(length)
pg = self._manager.password_generator
base_policy = getattr(pg, "policy", None)
overrides: dict[str, Any] = {}
if include_special_chars is not None:
overrides["include_special_chars"] = include_special_chars
if allowed_special_chars is not None:
overrides["allowed_special_chars"] = allowed_special_chars
if special_mode is not None:
overrides["special_mode"] = special_mode
if exclude_ambiguous is not None:
overrides["exclude_ambiguous"] = exclude_ambiguous
if min_uppercase is not None:
overrides["min_uppercase"] = int(min_uppercase)
if min_lowercase is not None:
overrides["min_lowercase"] = int(min_lowercase)
if min_digits is not None:
overrides["min_digits"] = int(min_digits)
if min_special is not None:
overrides["min_special"] = int(min_special)
if base_policy is not None and overrides:
pg.policy = dataclasses.replace(
base_policy,
**{k: overrides[k] for k in overrides if hasattr(base_policy, k)},
)
try:
return pg.generate_password(length)
finally:
pg.policy = base_policy
return pg.generate_password(length)
def verify_checksum(self) -> None:
with self._lock:

View File

@@ -58,6 +58,10 @@ class ConfigManager:
"min_lowercase": 2,
"min_digits": 2,
"min_special": 2,
"include_special_chars": True,
"allowed_special_chars": "",
"special_mode": "standard",
"exclude_ambiguous": False,
"verbose_timing": False,
}
try:
@@ -83,6 +87,10 @@ class ConfigManager:
data.setdefault("min_lowercase", 2)
data.setdefault("min_digits", 2)
data.setdefault("min_special", 2)
data.setdefault("include_special_chars", True)
data.setdefault("allowed_special_chars", "")
data.setdefault("special_mode", "standard")
data.setdefault("exclude_ambiguous", False)
data.setdefault("verbose_timing", False)
# Migrate legacy hashed_password.enc if present and password_hash is missing
@@ -259,6 +267,10 @@ class ConfigManager:
min_lowercase=int(cfg.get("min_lowercase", 2)),
min_digits=int(cfg.get("min_digits", 2)),
min_special=int(cfg.get("min_special", 2)),
include_special_chars=bool(cfg.get("include_special_chars", True)),
allowed_special_chars=cfg.get("allowed_special_chars") or None,
special_mode=cfg.get("special_mode") or None,
exclude_ambiguous=bool(cfg.get("exclude_ambiguous", False)),
)
def set_min_uppercase(self, count: int) -> None:
@@ -281,6 +293,30 @@ class ConfigManager:
cfg["min_special"] = int(count)
self.save_config(cfg)
def set_include_special_chars(self, enabled: bool) -> None:
"""Persist whether special characters are allowed."""
cfg = self.load_config(require_pin=False)
cfg["include_special_chars"] = bool(enabled)
self.save_config(cfg)
def set_allowed_special_chars(self, chars: str | None) -> None:
"""Persist the set of allowed special characters."""
cfg = self.load_config(require_pin=False)
cfg["allowed_special_chars"] = chars or ""
self.save_config(cfg)
def set_special_mode(self, mode: str) -> None:
"""Persist the special character mode."""
cfg = self.load_config(require_pin=False)
cfg["special_mode"] = mode
self.save_config(cfg)
def set_exclude_ambiguous(self, enabled: bool) -> None:
"""Persist whether ambiguous characters are excluded."""
cfg = self.load_config(require_pin=False)
cfg["exclude_ambiguous"] = bool(enabled)
self.save_config(cfg)
def set_quick_unlock(self, enabled: bool) -> None:
"""Persist the quick unlock toggle."""
cfg = self.load_config(require_pin=False)

View File

@@ -37,6 +37,13 @@ from .entry_types import EntryType
from .totp import TotpManager
from utils.fingerprint import generate_fingerprint
from utils.checksum import canonical_json_dumps
from utils.key_validation import (
validate_totp_secret,
validate_ssh_key_pair,
validate_pgp_private_key,
validate_nostr_keys,
validate_seed_phrase,
)
from .vault import Vault
from .backup import BackupManager
@@ -152,6 +159,15 @@ class EntryManager:
notes: str = "",
custom_fields: List[Dict[str, Any]] | None = None,
tags: list[str] | None = None,
*,
include_special_chars: bool | None = None,
allowed_special_chars: str | None = None,
special_mode: str | None = None,
exclude_ambiguous: bool | None = None,
min_uppercase: int | None = None,
min_lowercase: int | None = None,
min_digits: int | None = None,
min_special: int | None = None,
) -> int:
"""
Adds a new entry to the encrypted JSON index file.
@@ -169,7 +185,7 @@ class EntryManager:
data = self._load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {
entry = {
"label": label,
"length": length,
"username": username if username else "",
@@ -183,6 +199,28 @@ class EntryManager:
"tags": tags or [],
}
policy: dict[str, Any] = {}
if include_special_chars is not None:
policy["include_special_chars"] = include_special_chars
if allowed_special_chars is not None:
policy["allowed_special_chars"] = allowed_special_chars
if special_mode is not None:
policy["special_mode"] = special_mode
if exclude_ambiguous is not None:
policy["exclude_ambiguous"] = exclude_ambiguous
if min_uppercase is not None:
policy["min_uppercase"] = int(min_uppercase)
if min_lowercase is not None:
policy["min_lowercase"] = int(min_lowercase)
if min_digits is not None:
policy["min_digits"] = int(min_digits)
if min_special is not None:
policy["min_special"] = int(min_special)
if policy:
entry["policy"] = policy
data["entries"][str(index)] = entry
logger.debug(f"Added entry at index {index}: {data['entries'][str(index)]}")
self._save_index(data)
@@ -235,6 +273,8 @@ class EntryManager:
if index is None:
index = self.get_next_totp_index()
secret = TotpManager.derive_secret(parent_seed, index)
if not validate_totp_secret(secret):
raise ValueError("Invalid derived TOTP secret")
entry = {
"type": EntryType.TOTP.value,
"kind": EntryType.TOTP.value,
@@ -248,6 +288,8 @@ class EntryManager:
"tags": tags or [],
}
else:
if not validate_totp_secret(secret):
raise ValueError("Invalid TOTP secret")
entry = {
"type": EntryType.TOTP.value,
"kind": EntryType.TOTP.value,
@@ -292,6 +334,12 @@ class EntryManager:
if index is None:
index = self.get_next_index()
from .password_generation import derive_ssh_key_pair
priv_pem, pub_pem = derive_ssh_key_pair(parent_seed, index)
if not validate_ssh_key_pair(priv_pem, pub_pem):
raise ValueError("Derived SSH key pair failed validation")
data = self._load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {
@@ -339,6 +387,17 @@ class EntryManager:
if index is None:
index = self.get_next_index()
from .password_generation import derive_pgp_key
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
seed_bytes = Bip39SeedGenerator(parent_seed).Generate()
bip85 = BIP85(seed_bytes)
priv_key, fp = derive_pgp_key(bip85, index, key_type, user_id)
if not validate_pgp_private_key(priv_key, fp):
raise ValueError("Derived PGP key failed validation")
data = self._load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {
@@ -382,6 +441,7 @@ class EntryManager:
def add_nostr_key(
self,
label: str,
parent_seed: str,
index: int | None = None,
notes: str = "",
archived: bool = False,
@@ -392,6 +452,19 @@ class EntryManager:
if index is None:
index = self.get_next_index()
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
from nostr.coincurve_keys import Keys
seed_bytes = Bip39SeedGenerator(parent_seed).Generate()
bip85 = BIP85(seed_bytes)
entropy = bip85.derive_entropy(index=index, bytes_len=32)
keys = Keys(priv_k=entropy.hex())
npub = Keys.hex_to_bech32(keys.public_key_hex(), "npub")
nsec = Keys.hex_to_bech32(keys.private_key_hex(), "nsec")
if not validate_nostr_keys(npub, nsec):
raise ValueError("Derived Nostr keys failed validation")
data = self._load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {
@@ -412,6 +485,7 @@ class EntryManager:
def add_key_value(
self,
label: str,
key: str,
value: str,
*,
notes: str = "",
@@ -429,6 +503,7 @@ class EntryManager:
"type": EntryType.KEY_VALUE.value,
"kind": EntryType.KEY_VALUE.value,
"label": label,
"key": key,
"modified_ts": int(time.time()),
"value": value,
"notes": notes,
@@ -482,6 +557,16 @@ class EntryManager:
if index is None:
index = self.get_next_index()
from .password_generation import derive_seed_phrase
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
seed_bytes = Bip39SeedGenerator(parent_seed).Generate()
bip85 = BIP85(seed_bytes)
phrase = derive_seed_phrase(bip85, index, words_num)
if not validate_seed_phrase(phrase):
raise ValueError("Derived seed phrase failed validation")
data = self._load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {
@@ -550,6 +635,8 @@ class EntryManager:
word_count = 12
seed_phrase = derive_seed_phrase(bip85, index, word_count)
if not validate_seed_phrase(seed_phrase):
raise ValueError("Derived managed account seed failed validation")
fingerprint = generate_fingerprint(seed_phrase)
account_dir = self.fingerprint_dir / "accounts" / fingerprint
@@ -720,9 +807,18 @@ class EntryManager:
label: Optional[str] = None,
period: Optional[int] = None,
digits: Optional[int] = None,
key: Optional[str] = None,
value: Optional[str] = None,
custom_fields: List[Dict[str, Any]] | None = None,
tags: list[str] | None = None,
include_special_chars: bool | None = None,
allowed_special_chars: str | None = None,
special_mode: str | None = None,
exclude_ambiguous: bool | None = None,
min_uppercase: int | None = None,
min_lowercase: int | None = None,
min_digits: int | None = None,
min_special: int | None = None,
**legacy,
) -> None:
"""
@@ -736,6 +832,7 @@ class EntryManager:
: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 key: (Optional) New key for key/value entries.
:param value: (Optional) New value for key/value entries.
"""
try:
@@ -764,9 +861,18 @@ class EntryManager:
"label": label,
"period": period,
"digits": digits,
"key": key,
"value": value,
"custom_fields": custom_fields,
"tags": tags,
"include_special_chars": include_special_chars,
"allowed_special_chars": allowed_special_chars,
"special_mode": special_mode,
"exclude_ambiguous": exclude_ambiguous,
"min_uppercase": min_uppercase,
"min_lowercase": min_lowercase,
"min_digits": min_digits,
"min_special": min_special,
}
allowed = {
@@ -778,6 +884,14 @@ class EntryManager:
"notes",
"custom_fields",
"tags",
"include_special_chars",
"allowed_special_chars",
"special_mode",
"exclude_ambiguous",
"min_uppercase",
"min_lowercase",
"min_digits",
"min_special",
},
EntryType.TOTP.value: {
"label",
@@ -790,6 +904,7 @@ class EntryManager:
},
EntryType.KEY_VALUE.value: {
"label",
"key",
"value",
"archived",
"notes",
@@ -870,6 +985,9 @@ class EntryManager:
EntryType.KEY_VALUE.value,
EntryType.MANAGED_ACCOUNT.value,
):
if key is not None and entry_type == EntryType.KEY_VALUE.value:
entry["key"] = key
logger.debug(f"Updated key for index {index}.")
if value is not None:
entry["value"] = value
logger.debug(f"Updated value for index {index}.")
@@ -899,6 +1017,28 @@ class EntryManager:
entry["tags"] = tags
logger.debug(f"Updated tags for index {index}: {tags}")
policy_updates: dict[str, Any] = {}
if include_special_chars is not None:
policy_updates["include_special_chars"] = include_special_chars
if allowed_special_chars is not None:
policy_updates["allowed_special_chars"] = allowed_special_chars
if special_mode is not None:
policy_updates["special_mode"] = special_mode
if exclude_ambiguous is not None:
policy_updates["exclude_ambiguous"] = exclude_ambiguous
if min_uppercase is not None:
policy_updates["min_uppercase"] = int(min_uppercase)
if min_lowercase is not None:
policy_updates["min_lowercase"] = int(min_lowercase)
if min_digits is not None:
policy_updates["min_digits"] = int(min_digits)
if min_special is not None:
policy_updates["min_special"] = int(min_special)
if policy_updates:
entry_policy = entry.get("policy", {})
entry_policy.update(policy_updates)
entry["policy"] = entry_policy
entry["modified_ts"] = int(time.time())
data["entries"][str(index)] = entry
@@ -1070,8 +1210,12 @@ class EntryManager:
def search_entries(
self, query: str, kinds: List[str] | None = None
) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]:
"""Return entries matching ``query`` across whitelisted metadata fields."""
) -> List[Tuple[int, str, Optional[str], Optional[str], bool, EntryType]]:
"""Return entries matching ``query`` across whitelisted metadata fields.
Each match is represented as ``(index, label, username, url, archived, etype)``
where ``etype`` is the :class:`EntryType` of the entry.
"""
data = self._load_index()
entries_data = data.get("entries", {})
@@ -1080,19 +1224,23 @@ class EntryManager:
return []
query_lower = query.lower()
results: List[Tuple[int, str, Optional[str], Optional[str], bool]] = []
results: List[
Tuple[int, str, Optional[str], Optional[str], bool, EntryType]
] = []
for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])):
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
etype = EntryType(
entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
)
if kinds is not None and etype not in kinds:
if kinds is not None and etype.value not in kinds:
continue
label = entry.get("label", entry.get("website", ""))
username = (
entry.get("username", "") if etype == EntryType.PASSWORD.value else None
entry.get("username", "") if etype == EntryType.PASSWORD else None
)
url = entry.get("url", "") if etype == EntryType.PASSWORD.value else None
url = entry.get("url", "") if etype == EntryType.PASSWORD else None
tags = entry.get("tags", [])
archived = entry.get("archived", entry.get("blacklisted", False))
@@ -1109,6 +1257,7 @@ class EntryManager:
username if username is not None else None,
url if url is not None else None,
archived,
etype,
)
)

View File

@@ -21,6 +21,7 @@ import builtins
import threading
import queue
from dataclasses import dataclass
import dataclasses
from termcolor import colored
from utils.color_scheme import color_text
from utils.input_utils import timed_input
@@ -31,6 +32,7 @@ from .password_generation import PasswordGenerator
from .backup import BackupManager
from .vault import Vault
from .portable_backup import export_backup, import_backup
from cryptography.fernet import InvalidToken
from .totp import TotpManager
from .entry_types import EntryType
from .pubsub import bus
@@ -94,14 +96,21 @@ from datetime import datetime
from utils.fingerprint_manager import FingerprintManager
# Import NostrClient
from nostr.client import NostrClient, DEFAULT_RELAYS
from nostr.client import NostrClient, DEFAULT_RELAYS, MANIFEST_ID_PREFIX
from .config_manager import ConfigManager
from .state_manager import StateManager
from .stats_manager import StatsManager
# Instantiate the logger
logger = logging.getLogger(__name__)
def calculate_profile_id(seed: str) -> str:
"""Return the fingerprint identifier for ``seed``."""
fp = generate_fingerprint(seed)
return fp or ""
@dataclass
class Notification:
"""Simple message container for UI notifications."""
@@ -161,6 +170,7 @@ class PasswordManager:
self.nostr_client: Optional[NostrClient] = None
self.config_manager: Optional[ConfigManager] = None
self.state_manager: Optional[StateManager] = None
self.stats_manager: StatsManager = StatsManager()
self.notifications: queue.Queue[Notification] = queue.Queue()
self._current_notification: Optional[Notification] = None
self._notification_expiry: float = 0.0
@@ -271,6 +281,8 @@ class PasswordManager:
def notify(self, message: str, level: str = "INFO") -> None:
"""Enqueue a notification and set it as the active message."""
note = Notification(message, level)
if not hasattr(self, "notifications"):
self.notifications = queue.Queue()
self.notifications.put(note)
self._current_notification = note
self._notification_expiry = time.time() + NOTIFICATION_DURATION
@@ -604,6 +616,8 @@ class PasswordManager:
selected_fingerprint = fingerprints[int(choice) - 1]
self.fingerprint_manager.current_fingerprint = selected_fingerprint
self.current_fingerprint = selected_fingerprint
if not getattr(self, "manifest_id", None):
self.manifest_id = f"{MANIFEST_ID_PREFIX}{selected_fingerprint}"
# Update fingerprint directory
self.fingerprint_dir = (
@@ -644,7 +658,9 @@ class PasswordManager:
config_manager=getattr(self, "config_manager", None),
parent_seed=getattr(self, "parent_seed", None),
)
if getattr(self, "manifest_id", None):
if getattr(self, "manifest_id", None) and hasattr(
self.nostr_client, "_state_lock"
):
from nostr.backup_models import Manifest
with self.nostr_client._state_lock:
@@ -807,8 +823,8 @@ class PasswordManager:
choice = input(
"Do you want to (1) Paste in an existing seed in full "
"(2) Enter an existing seed one word at a time or "
"(3) Generate a new seed? (1/2/3): "
"(2) Enter an existing seed one word at a time, "
"(3) Generate a new seed, or (4) Restore from Nostr? (1/2/3/4): "
).strip()
if choice == "1":
@@ -817,6 +833,10 @@ class PasswordManager:
self.setup_existing_seed(method="words")
elif choice == "3":
self.generate_new_seed()
elif choice == "4":
seed_phrase = masked_input("Enter your 12-word BIP-85 seed: ").strip()
self.restore_from_nostr_with_guidance(seed_phrase)
return
else:
print(colored("Invalid choice. Exiting.", "red"))
sys.exit(1)
@@ -893,6 +913,8 @@ class PasswordManager:
self.current_fingerprint = fingerprint
self.fingerprint_manager.current_fingerprint = fingerprint
self.fingerprint_dir = fingerprint_dir
if not getattr(self, "manifest_id", None):
self.manifest_id = f"{MANIFEST_ID_PREFIX}{fingerprint}"
logging.info(f"Current seed profile set to {fingerprint}")
try:
@@ -1164,7 +1186,9 @@ class PasswordManager:
parent_seed=getattr(self, "parent_seed", None),
)
if getattr(self, "manifest_id", None):
if getattr(self, "manifest_id", None) and hasattr(
self.nostr_client, "_state_lock"
):
from nostr.backup_models import Manifest
with self.nostr_client._state_lock:
@@ -1187,6 +1211,8 @@ class PasswordManager:
"""Always fetch the latest vault data from Nostr and update the local index."""
start = time.perf_counter()
try:
if getattr(self, "current_fingerprint", None):
self.nostr_client.fingerprint = self.current_fingerprint
result = await self.nostr_client.fetch_latest_snapshot()
if not result:
if self.nostr_client.last_error:
@@ -1255,20 +1281,30 @@ class PasswordManager:
async def _worker() -> None:
try:
if hasattr(self, "nostr_client") and hasattr(self, "vault"):
self.attempt_initial_sync()
if hasattr(self, "sync_index_from_nostr"):
self.sync_index_from_nostr()
await self.attempt_initial_sync_async()
await self.sync_index_from_nostr_async()
except Exception as exc:
logger.warning(f"Background sync failed: {exc}")
try:
loop = asyncio.get_running_loop()
except RuntimeError:
threading.Thread(target=lambda: asyncio.run(_worker()), daemon=True).start()
thread = threading.Thread(
target=lambda: asyncio.run(_worker()), daemon=True
)
thread.start()
self._sync_task = thread
else:
self._sync_task = asyncio.create_task(_worker())
def cleanup(self) -> None:
"""Cancel any pending background sync task."""
task = getattr(self, "_sync_task", None)
if isinstance(task, asyncio.Task) and not task.done():
task.cancel()
elif isinstance(task, threading.Thread) and task.is_alive():
task.join(timeout=0.1)
def start_background_relay_check(self) -> None:
"""Check relay health in a background thread."""
if (
@@ -1336,6 +1372,8 @@ class PasswordManager:
have_data = False
start = time.perf_counter()
try:
if getattr(self, "current_fingerprint", None):
self.nostr_client.fingerprint = self.current_fingerprint
result = await self.nostr_client.fetch_latest_snapshot()
if result:
manifest, chunks = result
@@ -1385,6 +1423,37 @@ class PasswordManager:
except Exception as exc: # pragma: no cover - best effort
logger.warning(f"Unable to publish fresh database: {exc}")
def check_nostr_backup_exists(self, profile_id: str) -> bool:
"""Return ``True`` if a snapshot exists on Nostr for ``profile_id``."""
if not self.nostr_client or getattr(self, "offline_mode", False):
return False
previous = self.nostr_client.fingerprint
self.nostr_client.fingerprint = profile_id
try:
result = asyncio.run(self.nostr_client.fetch_latest_snapshot())
return result is not None
finally:
self.nostr_client.fingerprint = previous
def restore_from_nostr_with_guidance(self, seed_phrase: str) -> None:
"""Restore a profile from Nostr, warning if no backup exists."""
profile_id = calculate_profile_id(seed_phrase)
have_backup = self.check_nostr_backup_exists(profile_id)
if not have_backup:
print(colored("No Nostr backup found for this seed profile.", "yellow"))
if not confirm_action("Continue with an empty database? (Y/N): "):
return
fp = self._finalize_existing_seed(seed_phrase)
if not fp:
return
success = self.attempt_initial_sync()
if success:
print(colored("Vault restored from Nostr.", "green"))
elif have_backup:
print(colored("Failed to download vault from Nostr.", "red"))
def handle_add_password(self) -> None:
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
@@ -1395,6 +1464,72 @@ class PasswordManager:
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
def prompt_length() -> int | None:
length_input = input(
f"Enter desired password length (default {DEFAULT_PASSWORD_LENGTH}): "
).strip()
length = DEFAULT_PASSWORD_LENGTH
if length_input:
if not length_input.isdigit():
print(
colored("Error: Password length must be a number.", "red")
)
return None
length = int(length_input)
if not (MIN_PASSWORD_LENGTH <= length <= MAX_PASSWORD_LENGTH):
print(
colored(
f"Error: Password length must be between {MIN_PASSWORD_LENGTH} and {MAX_PASSWORD_LENGTH}.",
"red",
)
)
return None
return length
def finalize_entry(index: int, label: str, length: int) -> None:
# Mark database as dirty for background sync
self.is_dirty = True
self.last_update = time.time()
# Generate the password using the assigned index
entry = self.entry_manager.retrieve_entry(index)
password = self._generate_password_for_entry(entry, index, length)
# Provide user feedback
print(
colored(
f"\n[+] Password generated and indexed with ID {index}.\n",
"green",
)
)
if self.secret_mode_enabled:
copy_to_clipboard(password, self.clipboard_clear_delay)
print(
colored(
f"[+] Password copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.",
"green",
)
)
else:
print(colored(f"Password for {label}: {password}\n", "yellow"))
# Automatically push the updated encrypted index to Nostr so the
# latest changes are backed up remotely.
try:
self.start_background_vault_sync()
logging.info(
"Encrypted index posted to Nostr after entry addition."
)
except Exception as nostr_error:
logging.error(
f"Failed to post updated index to Nostr: {nostr_error}",
exc_info=True,
)
pause()
mode = input("Choose mode: [Q]uick or [A]dvanced? ").strip().lower()
website_name = input("Enter the label or website name: ").strip()
if not website_name:
print(colored("Error: Label cannot be empty.", "red"))
@@ -1402,6 +1537,29 @@ class PasswordManager:
username = input("Enter the username (optional): ").strip()
url = input("Enter the URL (optional): ").strip()
if mode.startswith("q"):
length = prompt_length()
if length is None:
return
include_special_input = (
input("Include special characters? (Y/n): ").strip().lower()
)
include_special_chars: bool | None = None
if include_special_input:
include_special_chars = include_special_input != "n"
index = self.entry_manager.add_entry(
website_name,
length,
username,
url,
include_special_chars=include_special_chars,
)
finalize_entry(index, website_name, length)
return
notes = input("Enter notes (optional): ").strip()
tags_input = input("Enter tags (comma-separated, optional): ").strip()
tags = (
@@ -1422,25 +1580,64 @@ class PasswordManager:
{"label": label, "value": value, "is_hidden": hidden}
)
length_input = input(
f"Enter desired password length (default {DEFAULT_PASSWORD_LENGTH}): "
).strip()
length = DEFAULT_PASSWORD_LENGTH
if length_input:
if not length_input.isdigit():
print(colored("Error: Password length must be a number.", "red"))
return
length = int(length_input)
if not (MIN_PASSWORD_LENGTH <= length <= MAX_PASSWORD_LENGTH):
print(
colored(
f"Error: Password length must be between {MIN_PASSWORD_LENGTH} and {MAX_PASSWORD_LENGTH}.",
"red",
)
)
return
length = prompt_length()
if length is None:
return
include_special_input = (
input("Include special characters? (Y/n): ").strip().lower()
)
include_special_chars: bool | None = None
if include_special_input:
include_special_chars = include_special_input != "n"
allowed_special_chars = input(
"Allowed special characters (leave blank for default): "
).strip()
if not allowed_special_chars:
allowed_special_chars = None
special_mode = input("Special character mode (safe/leave blank): ").strip()
if not special_mode:
special_mode = None
exclude_ambiguous_input = (
input("Exclude ambiguous characters? (y/N): ").strip().lower()
)
exclude_ambiguous: bool | None = None
if exclude_ambiguous_input:
exclude_ambiguous = exclude_ambiguous_input == "y"
min_uppercase_input = input(
"Minimum uppercase letters (blank for default): "
).strip()
if min_uppercase_input and not min_uppercase_input.isdigit():
print(colored("Error: Minimum uppercase must be a number.", "red"))
return
min_uppercase = int(min_uppercase_input) if min_uppercase_input else None
min_lowercase_input = input(
"Minimum lowercase letters (blank for default): "
).strip()
if min_lowercase_input and not min_lowercase_input.isdigit():
print(colored("Error: Minimum lowercase must be a number.", "red"))
return
min_lowercase = int(min_lowercase_input) if min_lowercase_input else None
min_digits_input = input("Minimum digits (blank for default): ").strip()
if min_digits_input and not min_digits_input.isdigit():
print(colored("Error: Minimum digits must be a number.", "red"))
return
min_digits = int(min_digits_input) if min_digits_input else None
min_special_input = input(
"Minimum special characters (blank for default): "
).strip()
if min_special_input and not min_special_input.isdigit():
print(colored("Error: Minimum special must be a number.", "red"))
return
min_special = int(min_special_input) if min_special_input else None
# Add the entry to the index and get the assigned index
index = self.entry_manager.add_entry(
website_name,
length,
@@ -1450,44 +1647,17 @@ class PasswordManager:
notes=notes,
custom_fields=custom_fields,
tags=tags,
include_special_chars=include_special_chars,
allowed_special_chars=allowed_special_chars,
special_mode=special_mode,
exclude_ambiguous=exclude_ambiguous,
min_uppercase=min_uppercase,
min_lowercase=min_lowercase,
min_digits=min_digits,
min_special=min_special,
)
# Mark database as dirty for background sync
self.is_dirty = True
self.last_update = time.time()
# Generate the password using the assigned index
password = self.password_generator.generate_password(length, index)
# Provide user feedback
print(
colored(
f"\n[+] Password generated and indexed with ID {index}.\n",
"green",
)
)
if self.secret_mode_enabled:
copy_to_clipboard(password, self.clipboard_clear_delay)
print(
colored(
f"[+] Password copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.",
"green",
)
)
else:
print(colored(f"Password for {website_name}: {password}\n", "yellow"))
# Automatically push the updated encrypted index to Nostr so the
# latest changes are backed up remotely.
try:
self.start_background_vault_sync()
logging.info("Encrypted index posted to Nostr after entry addition.")
except Exception as nostr_error:
logging.error(
f"Failed to post updated index to Nostr: {nostr_error}",
exc_info=True,
)
pause()
finalize_entry(index, website_name, length)
except Exception as e:
logging.error(f"Error during password generation: {e}", exc_info=True)
@@ -1838,7 +2008,9 @@ class PasswordManager:
if tags_input
else []
)
index = self.entry_manager.add_nostr_key(label, notes=notes, tags=tags)
index = self.entry_manager.add_nostr_key(
label, self.parent_seed, notes=notes, tags=tags
)
npub, nsec = self.entry_manager.get_nostr_key_pair(index, self.parent_seed)
self.is_dirty = True
self.last_update = time.time()
@@ -1888,6 +2060,10 @@ class PasswordManager:
if not label:
print(colored("Error: Label cannot be empty.", "red"))
return
key_field = input("Key: ").strip()
if not key_field:
print(colored("Error: Key cannot be empty.", "red"))
return
value = input("Value: ").strip()
notes = input("Notes (optional): ").strip()
tags_input = input("Enter tags (comma-separated, optional): ").strip()
@@ -1915,6 +2091,7 @@ class PasswordManager:
index = self.entry_manager.add_key_value(
label,
key_field,
value,
notes=notes,
custom_fields=custom_fields,
@@ -2062,6 +2239,29 @@ class PasswordManager:
entry_type = entry_type.value
return str(entry_type).lower()
def _generate_password_for_entry(
self, entry: dict, index: int, length: int | None = None
) -> str:
"""Generate a password for ``entry`` honoring any policy overrides."""
if length is None:
length = int(entry.get("length", DEFAULT_PASSWORD_LENGTH))
overrides = entry.get("policy", {})
pg = self.password_generator
if not hasattr(pg, "policy") or not isinstance(overrides, dict):
return pg.generate_password(length, index)
base_policy = pg.policy
merged = dataclasses.replace(
base_policy,
**{k: overrides[k] for k in overrides if hasattr(base_policy, k)},
)
pg.policy = merged
try:
return pg.generate_password(length, index)
finally:
pg.policy = base_policy
def _entry_actions_menu(self, index: int, entry: dict) -> None:
"""Provide actions for a retrieved entry."""
while True:
@@ -2168,7 +2368,12 @@ class PasswordManager:
child_fingerprint=child_fp,
)
print(colored("\n[+] Edit Menu:", "green"))
print(colored("L. Edit Label (key)", "cyan"))
print(colored("L. Edit Label", "cyan"))
if entry_type == EntryType.KEY_VALUE.value:
print(colored("K. Edit Key", "cyan"))
print(
colored("V. Edit Value", "cyan")
) # 🔧 merged conflicting changes from feature-X vs main
if entry_type == EntryType.PASSWORD.value:
print(colored("U. Edit Username", "cyan"))
print(colored("R. Edit URL", "cyan"))
@@ -2179,11 +2384,25 @@ class PasswordManager:
if not choice:
break
if choice == "l":
new_label = input("New label (key): ").strip()
new_label = input("New label: ").strip()
if new_label:
self.entry_manager.modify_entry(index, label=new_label)
self.is_dirty = True
self.last_update = time.time()
elif entry_type == EntryType.KEY_VALUE.value and choice == "k":
new_key = input("New key: ").strip()
if new_key:
self.entry_manager.modify_entry(index, key=new_key)
self.is_dirty = True
self.last_update = time.time()
elif entry_type == EntryType.KEY_VALUE.value and choice == "v":
new_value = input("New value: ").strip()
if new_value:
self.entry_manager.modify_entry(index, value=new_value)
self.is_dirty = True
self.last_update = (
time.time()
) # 🔧 merged conflicting changes from feature-X vs main
elif entry_type == EntryType.PASSWORD.value and choice == "u":
new_username = input("New username: ").strip()
self.entry_manager.modify_entry(index, username=new_username)
@@ -2629,7 +2848,7 @@ class PasswordManager:
level="WARNING",
)
password = self.password_generator.generate_password(length, index)
password = self._generate_password_for_entry(entry, index, length)
if password:
if self.secret_mode_enabled:
@@ -2656,6 +2875,8 @@ class PasswordManager:
"cyan",
)
)
if notes:
print(colored(f"Notes: {notes}", "cyan"))
tags = entry.get("tags", [])
if tags:
print(colored(f"Tags: {', '.join(tags)}", "cyan"))
@@ -2861,18 +3082,14 @@ class PasswordManager:
custom_fields=custom_fields,
tags=tags,
)
elif entry_type in (
EntryType.KEY_VALUE.value,
EntryType.MANAGED_ACCOUNT.value,
):
elif entry_type == EntryType.SSH.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}):",
f"Modifying SSH key entry '{label}' (Index: {index}):",
"cyan",
)
)
@@ -2886,6 +3103,86 @@ class PasswordManager:
input(f'Enter new label (leave blank to keep "{label}"): ').strip()
or label
)
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:
self.notify(
"Invalid input for archived status. Keeping the current status.",
level="WARNING",
)
new_blacklisted = blacklisted
new_notes = (
input(
f'Enter new notes (leave blank to keep "{notes or "N/A"}"): '
).strip()
or notes
)
tags_input = input(
"Enter tags (comma-separated, leave blank to keep current): "
).strip()
tags = (
[t.strip() for t in tags_input.split(",") if t.strip()]
if tags_input
else None
)
self.entry_manager.modify_entry(
index,
archived=new_blacklisted,
notes=new_notes,
label=new_label,
tags=tags,
)
elif entry_type in (
EntryType.KEY_VALUE.value,
EntryType.MANAGED_ACCOUNT.value,
):
label = entry.get("label", "")
value = entry.get("value", "")
blacklisted = entry.get("archived", False)
notes = entry.get("notes", "")
entry_label = (
"key/value entry"
if entry_type == EntryType.KEY_VALUE.value
else "managed account"
)
print(
colored(
f"Modifying {entry_label} '{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
)
if entry_type == EntryType.KEY_VALUE.value:
new_key = input(
f'Enter new key (leave blank to keep "{entry.get("key", "")}"): '
).strip() or entry.get("key", "")
else:
new_key = None
new_value = (
input("Enter new value (leave blank to keep current): ").strip()
or value
@@ -2942,14 +3239,20 @@ class PasswordManager:
else None
)
modify_kwargs = {
"archived": new_blacklisted,
"notes": new_notes,
"label": new_label,
"value": new_value,
"custom_fields": custom_fields,
"tags": tags,
}
if entry_type == EntryType.KEY_VALUE.value:
modify_kwargs["key"] = new_key
self.entry_manager.modify_entry(
index,
archived=new_blacklisted,
notes=new_notes,
label=new_label,
value=new_value,
custom_fields=custom_fields,
tags=tags,
**modify_kwargs,
)
else:
website_name = entry.get("label", entry.get("website"))
@@ -3114,11 +3417,12 @@ class PasswordManager:
child_fingerprint=child_fp,
)
print(colored("\n[+] Search Results:\n", "green"))
for idx, label, username, _url, _b in results:
for idx, label, username, _url, _b, etype in results:
display_label = label
if username:
display_label += f" ({username})"
print(colored(f"{idx}. {display_label}", "cyan"))
type_name = etype.value.replace("_", " ").title()
print(colored(f"{idx}. {type_name} - {display_label}", "cyan"))
idx_input = input(
"Enter index to view details or press Enter to go back: "
@@ -3237,7 +3541,8 @@ class PasswordManager:
print(color_text(f" Tags: {', '.join(tags)}", "index"))
elif etype == EntryType.KEY_VALUE.value:
print(color_text(" Type: Key/Value", "index"))
print(color_text(f" Label (key): {entry.get('label', '')}", "index"))
print(color_text(f" Label: {entry.get('label', '')}", "index"))
print(color_text(f" Key: {entry.get('key', '')}", "index"))
print(color_text(f" Value: {entry.get('value', '')}", "index"))
notes = entry.get("notes", "")
if notes:
@@ -3271,9 +3576,15 @@ class PasswordManager:
username = entry.get("username", "")
url = entry.get("url", "")
blacklisted = entry.get("archived", entry.get("blacklisted", False))
notes = entry.get("notes", "")
tags = entry.get("tags", [])
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"))
if notes:
print(color_text(f" Notes: {notes}", "index"))
if tags:
print(color_text(f" Tags: {', '.join(tags)}", "index"))
print(
color_text(
f" Archived: {'Yes' if blacklisted else 'No'}",
@@ -3788,26 +4099,57 @@ class PasswordManager:
def handle_import_database(self, src: Path) -> None:
"""Import a portable database file, replacing the current index."""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_header_with_notification(
self,
fp,
"Main Menu > Settings > Import database",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
if not src.name.endswith(".json.enc"):
print(
colored(
"Error: Selected file must be a SeedPass database backup (.json.enc).",
"red",
)
)
return
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_header_with_notification(
self,
fp,
"Main Menu > Settings > Import database",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
try:
import_backup(
self.vault,
self.backup_manager,
src,
parent_seed=self.parent_seed,
)
print(colored("Database imported successfully.", "green"))
self.sync_vault()
except InvalidToken:
logging.error("Invalid backup token during import", exc_info=True)
print(
colored(
"Error: Invalid backup. Please import a file created by SeedPass.",
"red",
)
)
return
except FileNotFoundError:
logging.error(f"Backup file not found: {src}", exc_info=True)
print(colored(f"Error: File '{src}' not found.", "red"))
return
except Exception as e:
logging.error(f"Failed to import database: {e}", exc_info=True)
print(colored(f"Error: Failed to import database: {e}", "red"))
print(
colored(
f"Error: Failed to import database: {e}. Please verify the backup file.",
"red",
)
)
return
print(colored("Database imported successfully.", "green"))
self.sync_vault()
def handle_export_totp_codes(self) -> Path | None:
"""Export all 2FA codes to a JSON file for other authenticator apps."""
@@ -4097,7 +4439,9 @@ class PasswordManager:
parent_seed=getattr(self, "parent_seed", None),
)
if getattr(self, "manifest_id", None):
if getattr(self, "manifest_id", None) and hasattr(
self.nostr_client, "_state_lock"
):
from nostr.backup_models import Manifest
with self.nostr_client._state_lock:

View File

@@ -42,7 +42,12 @@ except ModuleNotFoundError: # pragma: no cover - fallback for removed module
from local_bip85.bip85 import BIP85
from constants import DEFAULT_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH
from constants import (
DEFAULT_PASSWORD_LENGTH,
MIN_PASSWORD_LENGTH,
MAX_PASSWORD_LENGTH,
SAFE_SPECIAL_CHARS,
)
from .encryption import EncryptionManager
# Instantiate the logger
@@ -51,12 +56,27 @@ logger = logging.getLogger(__name__)
@dataclass
class PasswordPolicy:
"""Minimum complexity requirements for generated passwords."""
"""Minimum complexity requirements for generated passwords.
Attributes:
min_uppercase: Minimum required uppercase letters.
min_lowercase: Minimum required lowercase letters.
min_digits: Minimum required digits.
min_special: Minimum required special characters.
include_special_chars: Whether to include any special characters.
allowed_special_chars: Explicit set of allowed special characters.
special_mode: Preset mode for special characters (e.g. "safe").
exclude_ambiguous: Exclude easily confused characters like ``O`` and ``0``.
"""
min_uppercase: int = 2
min_lowercase: int = 2
min_digits: int = 2
min_special: int = 2
include_special_chars: bool = True
allowed_special_chars: str | None = None
special_mode: str | None = None
exclude_ambiguous: bool = False
class PasswordGenerator:
@@ -175,9 +195,28 @@ class PasswordGenerator:
dk = self._derive_password_entropy(index=index)
all_allowed = string.ascii_letters + string.digits + string.punctuation
letters = string.ascii_letters
digits = string.digits
if self.policy.exclude_ambiguous:
ambiguous = "O0Il1"
letters = "".join(c for c in letters if c not in ambiguous)
digits = "".join(c for c in digits if c not in ambiguous)
if not self.policy.include_special_chars:
allowed_special = ""
elif self.policy.allowed_special_chars is not None:
allowed_special = self.policy.allowed_special_chars
elif self.policy.special_mode == "safe":
allowed_special = SAFE_SPECIAL_CHARS
else:
allowed_special = string.punctuation
all_allowed = letters + digits + allowed_special
password = self._map_entropy_to_chars(dk, all_allowed)
password = self._enforce_complexity(password, all_allowed, dk)
password = self._enforce_complexity(
password, all_allowed, allowed_special, dk
)
password = self._shuffle_deterministically(password, dk)
# Ensure password length by extending if necessary
@@ -195,7 +234,9 @@ class PasswordGenerator:
# produced above when the requested length is shorter than the
# initial entropy size.
password = password[:length]
password = self._enforce_complexity(password, all_allowed, dk)
password = self._enforce_complexity(
password, all_allowed, allowed_special, dk
)
password = self._shuffle_deterministically(password, dk)
logger.debug(
f"Final password (trimmed to {length} chars with complexity enforced): {password}"
@@ -208,7 +249,9 @@ class PasswordGenerator:
print(colored(f"Error: Failed to generate password: {e}", "red"))
raise
def _enforce_complexity(self, password: str, alphabet: str, dk: bytes) -> str:
def _enforce_complexity(
self, password: str, alphabet: str, allowed_special: str, dk: bytes
) -> str:
"""
Ensures that the password contains at least two uppercase letters, two lowercase letters,
two digits, and two special characters, modifying it deterministically if necessary.
@@ -226,7 +269,13 @@ class PasswordGenerator:
uppercase = string.ascii_uppercase
lowercase = string.ascii_lowercase
digits = string.digits
special = string.punctuation
special = allowed_special
if self.policy.exclude_ambiguous:
ambiguous = "O0Il1"
uppercase = "".join(c for c in uppercase if c not in ambiguous)
lowercase = "".join(c for c in lowercase if c not in ambiguous)
digits = "".join(c for c in digits if c not in ambiguous)
password_chars = list(password)
@@ -244,7 +293,7 @@ class PasswordGenerator:
min_upper = self.policy.min_uppercase
min_lower = self.policy.min_lowercase
min_digits = self.policy.min_digits
min_special = self.policy.min_special
min_special = self.policy.min_special if special else 0
# Initialize derived key index
dk_index = 0
@@ -282,7 +331,7 @@ class PasswordGenerator:
password_chars[index] = char
logger.debug(f"Added digit '{char}' at position {index}.")
if current_special < min_special:
if special and current_special < min_special:
for _ in range(min_special - current_special):
index = get_dk_value() % len(password_chars)
char = special[get_dk_value() % len(special)]
@@ -292,23 +341,29 @@ class PasswordGenerator:
)
# Additional deterministic inclusion of symbols to increase score
symbol_target = 3 # Increase target number of symbols
current_symbols = sum(1 for c in password_chars if c in special)
additional_symbols_needed = max(symbol_target - current_symbols, 0)
if special:
symbol_target = 3 # Increase target number of symbols
current_symbols = sum(1 for c in password_chars if c in special)
additional_symbols_needed = max(symbol_target - current_symbols, 0)
for _ in range(additional_symbols_needed):
if dk_index >= dk_length:
break # Avoid exceeding the derived key length
index = get_dk_value() % len(password_chars)
char = special[get_dk_value() % len(special)]
password_chars[index] = char
logger.debug(f"Added additional symbol '{char}' at position {index}.")
for _ in range(additional_symbols_needed):
if dk_index >= dk_length:
break # Avoid exceeding the derived key length
index = get_dk_value() % len(password_chars)
char = special[get_dk_value() % len(special)]
password_chars[index] = char
logger.debug(
f"Added additional symbol '{char}' at position {index}."
)
# Ensure balanced distribution by assigning different character types to specific segments
# Example: Divide password into segments and assign different types
segment_length = len(password_chars) // 4
char_types = [uppercase, lowercase, digits]
if special:
char_types.append(special)
segment_length = len(password_chars) // len(char_types)
if segment_length > 0:
for i, char_type in enumerate([uppercase, lowercase, digits, special]):
for i, char_type in enumerate(char_types):
segment_start = i * segment_length
segment_end = segment_start + segment_length
if segment_end > len(password_chars):
@@ -330,7 +385,11 @@ class PasswordGenerator:
char = digits[get_dk_value() % len(digits)]
password_chars[j] = char
logger.debug(f"Assigned digit '{char}' to position {j}.")
elif i == 3 and password_chars[j] not in special:
elif (
special
and i == len(char_types) - 1
and password_chars[j] not in special
):
char = special[get_dk_value() % len(special)]
password_chars[j] = char
logger.debug(

View File

@@ -0,0 +1,20 @@
"""Manage display of stats screens."""
from __future__ import annotations
class StatsManager:
"""Track whether stats have been displayed."""
def __init__(self) -> None:
self._displayed = False
def display_stats_once(self, manager) -> None:
"""Display stats using ``manager`` once per reset."""
if not self._displayed:
manager.display_stats()
self._displayed = True
def reset(self) -> None:
"""Reset the displayed flag."""
self._displayed = False

View File

@@ -195,6 +195,9 @@ class MainWindow(toga.Window):
bus.unsubscribe("sync_started", self.sync_started)
bus.unsubscribe("sync_finished", self.sync_finished)
bus.unsubscribe("vault_locked", self.vault_locked)
manager = getattr(self.nostr, "_manager", None)
if manager is not None:
manager.cleanup()
class EntryDialog(toga.Window):
@@ -217,6 +220,7 @@ class EntryDialog(toga.Window):
self.length_input = toga.NumberInput(
min=8, max=128, style=Pack(width=80), value=16
)
self.key_input = toga.TextInput(style=Pack(flex=1))
self.value_input = toga.TextInput(style=Pack(flex=1))
save_button = toga.Button(
@@ -234,6 +238,8 @@ class EntryDialog(toga.Window):
box.add(self.url_input)
box.add(toga.Label("Length"))
box.add(self.length_input)
box.add(toga.Label("Key"))
box.add(self.key_input)
box.add(toga.Label("Value"))
box.add(self.value_input)
box.add(save_button)
@@ -249,6 +255,7 @@ class EntryDialog(toga.Window):
self.username_input.value = entry.get("username", "") or ""
self.url_input.value = entry.get("url", "") or ""
self.length_input.value = entry.get("length", 16)
self.key_input.value = entry.get("key", "")
self.value_input.value = entry.get("value", "")
def save(self, widget: toga.Widget) -> None:
@@ -257,6 +264,7 @@ class EntryDialog(toga.Window):
url = self.url_input.value or None
length = int(self.length_input.value or 16)
kind = self.kind_input.value
key = self.key_input.value or None
value = self.value_input.value or None
if self.entry_id is None:
@@ -275,7 +283,9 @@ class EntryDialog(toga.Window):
elif kind == EntryType.NOSTR.value:
entry_id = self.main.entries.add_nostr_key(label)
elif kind == EntryType.KEY_VALUE.value:
entry_id = self.main.entries.add_key_value(label, value or "")
entry_id = self.main.entries.add_key_value(
label, key or "", value or ""
)
elif kind == EntryType.MANAGED_ACCOUNT.value:
entry_id = self.main.entries.add_managed_account(label)
else:
@@ -284,7 +294,7 @@ class EntryDialog(toga.Window):
if kind == EntryType.PASSWORD.value:
kwargs.update({"username": username, "url": url})
elif kind == EntryType.KEY_VALUE.value:
kwargs.update({"value": value})
kwargs.update({"key": key, "value": value})
self.main.entries.modify_entry(entry_id, **kwargs)
entry = self.main.entries.retrieve_entry(entry_id) or {}
@@ -341,7 +351,7 @@ class SearchDialog(toga.Window):
query = self.query_input.value or ""
results = self.main.entries.search_entries(query)
self.main.entry_source.clear()
for idx, label, username, url, _arch in results:
for idx, label, username, url, _arch, _etype in results:
self.main.entry_source.append(
{
"id": idx,

View File

@@ -8,13 +8,16 @@ from fastapi.testclient import TestClient
sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass import api
from seedpass.core.entry_types import EntryType
@pytest.fixture
def client(monkeypatch):
dummy = SimpleNamespace(
entry_manager=SimpleNamespace(
search_entries=lambda q: [(1, "Site", "user", "url", False)],
search_entries=lambda q: [
(1, "Site", "user", "url", False, EntryType.PASSWORD)
],
retrieve_entry=lambda i: {"label": "Site"},
add_entry=lambda *a, **k: 1,
modify_entry=lambda *a, **k: None,

View File

@@ -5,6 +5,8 @@ import pytest
from seedpass import api
from test_api import client
from helpers import dummy_nostr_client
import string
from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy
from nostr.client import NostrClient, DEFAULT_RELAYS
@@ -401,3 +403,55 @@ def test_relay_management_endpoints(client, dummy_nostr_client, monkeypatch):
assert res.status_code == 200
assert called.get("init") is True
assert api._pm.nostr_client.relays == list(DEFAULT_RELAYS)
def test_generate_password_no_special_chars(client):
cl, token = client
class DummyEnc:
def derive_seed_from_mnemonic(self, mnemonic):
return b"\x00" * 32
class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
return bytes(range(bytes_len))
api._pm.password_generator = PasswordGenerator(DummyEnc(), "seed", DummyBIP85())
api._pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}"}
res = cl.post(
"/api/v1/password",
json={"length": 16, "include_special_chars": False},
headers=headers,
)
assert res.status_code == 200
pw = res.json()["password"]
assert not any(c in string.punctuation for c in pw)
def test_generate_password_allowed_chars(client):
cl, token = client
class DummyEnc:
def derive_seed_from_mnemonic(self, mnemonic):
return b"\x00" * 32
class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len))
api._pm.password_generator = PasswordGenerator(DummyEnc(), "seed", DummyBIP85())
api._pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}"}
allowed = "@$"
res = cl.post(
"/api/v1/password",
json={"length": 16, "allowed_special_chars": allowed},
headers=headers,
)
assert res.status_code == 200
pw = res.json()["password"]
specials = [c for c in pw if c in string.punctuation]
assert specials and all(c in allowed for c in specials)

View File

@@ -9,6 +9,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
from seedpass.core.entry_types import EntryType
def setup_entry_mgr(tmp_path: Path) -> EntryManager:
@@ -26,7 +27,9 @@ def test_archive_nonpassword_list_search():
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)]
assert em.search_entries("Example") == [
(idx, "Example", None, None, False, EntryType.TOTP)
]
em.archive_entry(idx)
assert em.retrieve_entry(idx)["archived"] is True
@@ -34,9 +37,13 @@ def test_archive_nonpassword_list_search():
assert em.list_entries(include_archived=True) == [
(idx, "Example", None, None, True)
]
assert em.search_entries("Example") == [(idx, "Example", None, None, True)]
assert em.search_entries("Example") == [
(idx, "Example", None, None, True, EntryType.TOTP)
]
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)]
assert em.search_entries("Example") == [
(idx, "Example", None, None, False, EntryType.TOTP)
]

View File

@@ -14,6 +14,7 @@ from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
from seedpass.core.manager import PasswordManager, EncryptionMode
from seedpass.core.entry_types import EntryType
def setup_entry_mgr(tmp_path: Path) -> EntryManager:
@@ -31,7 +32,7 @@ def test_archive_restore_affects_listing_and_search():
assert em.list_entries() == [(idx, "example.com", "alice", "", False)]
assert em.search_entries("example") == [
(idx, "example.com", "alice", "", False)
(idx, "example.com", "alice", "", False, EntryType.PASSWORD)
]
em.archive_entry(idx)
@@ -40,13 +41,15 @@ def test_archive_restore_affects_listing_and_search():
assert em.list_entries(include_archived=True) == [
(idx, "example.com", "alice", "", True)
]
assert em.search_entries("example") == [(idx, "example.com", "alice", "", True)]
assert em.search_entries("example") == [
(idx, "example.com", "alice", "", True, EntryType.PASSWORD)
]
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)
(idx, "example.com", "alice", "", False, EntryType.PASSWORD)
]

View File

@@ -5,6 +5,7 @@ from typer.testing import CliRunner
from seedpass import cli
from seedpass.cli import app
from seedpass.core.entry_types import EntryType
runner = CliRunner()
@@ -34,7 +35,7 @@ def test_cli_entry_add_search_sync(monkeypatch):
def search_entries(q, kinds=None):
calls["search"] = (q, kinds)
return [(1, "Label", None, None, False)]
return [(1, "Label", None, None, False, EntryType.PASSWORD)]
def start_background_vault_sync():
calls["sync"] = True

View File

@@ -17,16 +17,18 @@ class DummyPM:
list_entries=lambda sort_by="index", filter_kind=None, include_archived=False: [
(1, "Label", "user", "url", False)
],
search_entries=lambda q, kinds=None: [(1, "GitHub", "user", "", False)],
search_entries=lambda q, kinds=None: [
(1, "GitHub", "user", "", False, EntryType.PASSWORD)
],
retrieve_entry=lambda idx: {"type": EntryType.PASSWORD.value, "length": 8},
get_totp_code=lambda idx, seed: "123456",
add_entry=lambda label, length, username, url: 1,
add_entry=lambda label, length, username, url, **kwargs: 1,
add_totp=lambda label, seed, index=None, secret=None, period=30, digits=6: "totp://",
add_ssh_key=lambda label, seed, index=None, notes="": 2,
add_pgp_key=lambda label, seed, index=None, key_type="ed25519", user_id="", notes="": 3,
add_nostr_key=lambda label, index=None, notes="": 4,
add_nostr_key=lambda label, seed, index=None, notes="": 4,
add_seed=lambda label, seed, index=None, words_num=24, notes="": 5,
add_key_value=lambda label, value, notes="": 6,
add_key_value=lambda label, key, value, notes="": 6,
add_managed_account=lambda label, seed, index=None, notes="": 7,
modify_entry=lambda *a, **kw: None,
archive_entry=lambda i: None,

View File

@@ -4,6 +4,7 @@ from typer.testing import CliRunner
from seedpass.cli import app
from seedpass import cli
from helpers import TEST_SEED
runner = CliRunner()
@@ -22,9 +23,32 @@ runner = CliRunner()
"user",
"--url",
"https://example.com",
"--no-special",
"--allowed-special-chars",
"!@",
"--special-mode",
"safe",
"--exclude-ambiguous",
"--min-uppercase",
"1",
"--min-lowercase",
"2",
"--min-digits",
"3",
"--min-special",
"4",
],
("Label", 16, "user", "https://example.com"),
{},
{
"include_special_chars": False,
"allowed_special_chars": "!@",
"special_mode": "safe",
"exclude_ambiguous": True,
"min_uppercase": 1,
"min_lowercase": 2,
"min_digits": 3,
"min_special": 4,
},
"1",
),
(
@@ -75,7 +99,7 @@ runner = CliRunner()
"add-nostr",
"add_nostr_key",
["Label", "--index", "4", "--notes", "n"],
("Label",),
("Label", "seed"),
{"index": 4, "notes": "n"},
"5",
),
@@ -90,8 +114,8 @@ runner = CliRunner()
(
"add-key-value",
"add_key_value",
["Label", "--value", "val", "--notes", "note"],
("Label", "val"),
["Label", "--key", "k1", "--value", "val", "--notes", "note"],
("Label", "k1", "val"),
{"notes": "note"},
"7",
),

View File

@@ -27,7 +27,7 @@ def make_pm(search_results, entry=None, totp_code="123456"):
def test_search_command(monkeypatch, capsys):
pm = make_pm([(0, "Example", "user", "", False)])
pm = make_pm([(0, "Example", "user", "", False, EntryType.PASSWORD)])
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
monkeypatch.setattr(main, "configure_logging", lambda: None)
monkeypatch.setattr(main, "initialize_app", lambda: None)
@@ -40,7 +40,7 @@ def test_search_command(monkeypatch, capsys):
def test_get_command(monkeypatch, capsys):
entry = {"type": EntryType.PASSWORD.value, "length": 8}
pm = make_pm([(0, "Example", "user", "", False)], entry=entry)
pm = make_pm([(0, "Example", "user", "", False, EntryType.PASSWORD)], entry=entry)
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
monkeypatch.setattr(main, "configure_logging", lambda: None)
monkeypatch.setattr(main, "initialize_app", lambda: None)
@@ -53,7 +53,7 @@ def test_get_command(monkeypatch, capsys):
def test_totp_command(monkeypatch, capsys):
entry = {"type": EntryType.TOTP.value, "period": 30, "index": 0}
pm = make_pm([(0, "Example", None, None, False)], entry=entry)
pm = make_pm([(0, "Example", None, None, False, EntryType.TOTP)], entry=entry)
called = {}
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
monkeypatch.setattr(main, "configure_logging", lambda: None)
@@ -83,7 +83,10 @@ def test_search_command_no_results(monkeypatch, capsys):
def test_get_command_multiple_matches(monkeypatch, capsys):
matches = [(0, "Example", "user", "", False), (1, "Ex2", "bob", "", False)]
matches = [
(0, "Example", "user", "", False, EntryType.PASSWORD),
(1, "Ex2", "bob", "", False, EntryType.PASSWORD),
]
pm = make_pm(matches)
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
monkeypatch.setattr(main, "configure_logging", lambda: None)
@@ -97,7 +100,7 @@ def test_get_command_multiple_matches(monkeypatch, capsys):
def test_get_command_wrong_type(monkeypatch, capsys):
entry = {"type": EntryType.TOTP.value}
pm = make_pm([(0, "Example", "user", "", False)], entry=entry)
pm = make_pm([(0, "Example", None, None, False, EntryType.TOTP)], entry=entry)
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
monkeypatch.setattr(main, "configure_logging", lambda: None)
monkeypatch.setattr(main, "initialize_app", lambda: None)
@@ -109,7 +112,10 @@ def test_get_command_wrong_type(monkeypatch, capsys):
def test_totp_command_multiple_matches(monkeypatch, capsys):
matches = [(0, "GH", None, None, False), (1, "Git", None, None, False)]
matches = [
(0, "GH", None, None, False, EntryType.TOTP),
(1, "Git", None, None, False, EntryType.TOTP),
]
pm = make_pm(matches)
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
monkeypatch.setattr(main, "configure_logging", lambda: None)
@@ -123,7 +129,7 @@ def test_totp_command_multiple_matches(monkeypatch, capsys):
def test_totp_command_wrong_type(monkeypatch, capsys):
entry = {"type": EntryType.PASSWORD.value, "length": 8}
pm = make_pm([(0, "Example", "user", "", False)], entry=entry)
pm = make_pm([(0, "Example", "user", "", False, EntryType.PASSWORD)], entry=entry)
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
monkeypatch.setattr(main, "configure_logging", lambda: None)
monkeypatch.setattr(main, "initialize_app", lambda: None)

View File

@@ -196,3 +196,43 @@ def test_nostr_retry_settings_round_trip():
cfg_mgr.set_nostr_retry_delay(3.5)
assert cfg_mgr.get_nostr_max_retries() == 5
assert cfg_mgr.get_nostr_retry_delay() == 3.5
def test_special_char_settings_round_trip():
with TemporaryDirectory() as tmpdir:
vault, _ = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, Path(tmpdir))
cfg = cfg_mgr.load_config(require_pin=False)
assert cfg["include_special_chars"] is True
assert cfg["allowed_special_chars"] == ""
assert cfg["special_mode"] == "standard"
assert cfg["exclude_ambiguous"] is False
cfg_mgr.set_include_special_chars(False)
cfg_mgr.set_allowed_special_chars("@$")
cfg_mgr.set_special_mode("safe")
cfg_mgr.set_exclude_ambiguous(True)
cfg2 = cfg_mgr.load_config(require_pin=False)
assert cfg2["include_special_chars"] is False
assert cfg2["allowed_special_chars"] == "@$"
assert cfg2["special_mode"] == "safe"
assert cfg2["exclude_ambiguous"] is True
def test_password_policy_extended_fields():
with TemporaryDirectory() as tmpdir:
vault, _ = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, Path(tmpdir))
cfg_mgr.set_include_special_chars(False)
cfg_mgr.set_allowed_special_chars("()")
cfg_mgr.set_special_mode("safe")
cfg_mgr.set_exclude_ambiguous(True)
policy = cfg_mgr.get_password_policy()
assert policy.include_special_chars is False
assert policy.allowed_special_chars == "()"
assert policy.special_mode == "safe"
assert policy.exclude_ambiguous is True

View File

@@ -2,6 +2,7 @@ import types
from types import SimpleNamespace
from seedpass.core.api import VaultService, EntryService, SyncService, UnlockRequest
from seedpass.core.entry_types import EntryType
def test_vault_service_unlock():
@@ -27,7 +28,7 @@ def test_entry_service_add_entry_and_search():
def search_entries(q, kinds=None):
called["search"] = (q, kinds)
return [(5, "Example", username, url, False)]
return [(5, "Example", username, url, False, EntryType.PASSWORD)]
def start_background_vault_sync():
called["sync"] = True
@@ -47,7 +48,7 @@ def test_entry_service_add_entry_and_search():
assert called.get("sync") is True
results = service.search_entries("ex", kinds=["password"])
assert results == [(5, "Example", username, url, False)]
assert results == [(5, "Example", username, url, False, EntryType.PASSWORD)]
assert called["search"] == ("ex", ["password"])

View File

@@ -73,7 +73,7 @@ def test_round_trip_entry_types(method, expected_type):
entry_mgr.add_totp("example", TEST_SEED)
index = 0
elif method == "add_key_value":
index = entry_mgr.add_key_value("label", "val")
index = entry_mgr.add_key_value("label", "k1", "val")
else:
if method == "add_ssh_key":
index = entry_mgr.add_ssh_key("ssh", TEST_SEED)
@@ -116,9 +116,9 @@ def test_legacy_entry_defaults_to_password():
("add_totp", ("totp", TEST_SEED)),
("add_ssh_key", ("ssh", TEST_SEED)),
("add_pgp_key", ("pgp", TEST_SEED)),
("add_nostr_key", ("nostr",)),
("add_nostr_key", ("nostr", TEST_SEED)),
("add_seed", ("seed", TEST_SEED)),
("add_key_value", ("label", "val")),
("add_key_value", ("label", "k1", "val")),
("add_managed_account", ("acct", TEST_SEED)),
],
)

View File

@@ -0,0 +1,67 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
import string
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.manager import PasswordManager, EncryptionMode
from seedpass.core.config_manager import ConfigManager
from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy
class DummyEnc:
def derive_seed_from_mnemonic(self, mnemonic):
return b"\x00" * 32
class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len))
def make_manager(tmp_path: Path) -> PasswordManager:
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)
pg = PasswordGenerator.__new__(PasswordGenerator)
pg.encryption_manager = DummyEnc()
pg.bip85 = DummyBIP85()
pg.policy = PasswordPolicy(
min_uppercase=0, min_lowercase=0, min_digits=1, min_special=0
)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.password_generator = pg
pm.entry_manager = entry_mgr
pm.parent_seed = TEST_SEED
pm.vault = vault
pm.backup_manager = backup_mgr
pm.nostr_client = SimpleNamespace()
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
return pm
def test_entry_policy_override_changes_password():
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm = make_manager(tmp_path)
idx = pm.entry_manager.add_entry(
"site",
16,
min_digits=5,
include_special_chars=False,
)
entry = pm.entry_manager.retrieve_entry(idx)
pw = pm._generate_password_for_entry(entry, idx)
assert sum(c.isdigit() for c in pw) >= 5
assert not any(c in string.punctuation for c in pw)

View File

@@ -49,20 +49,22 @@ class FakeEntries:
self.added.append(("pgp", label))
return 1
def add_nostr_key(self, label):
def add_nostr_key(self, label, seed=None):
self.added.append(("nostr", label))
return 1
def add_key_value(self, label, value):
self.added.append(("key_value", label, value))
def add_key_value(self, label, key, value):
self.added.append(("key_value", label, key, value))
return 1
def add_managed_account(self, label):
self.added.append(("managed_account", label))
return 1
def modify_entry(self, entry_id, username=None, url=None, label=None, value=None):
self.modified.append((entry_id, username, url, label, value))
def modify_entry(
self, entry_id, username=None, url=None, label=None, key=None, value=None
):
self.modified.append((entry_id, username, url, label, key, value))
def setup_module(module):
@@ -106,7 +108,7 @@ def test_unlock_creates_main_window():
(EntryType.SEED.value, ("seed", "L")),
(EntryType.PGP.value, ("pgp", "L")),
(EntryType.NOSTR.value, ("nostr", "L")),
(EntryType.KEY_VALUE.value, ("key_value", "L", "val")),
(EntryType.KEY_VALUE.value, ("key_value", "L", "k1", "val")),
(EntryType.MANAGED_ACCOUNT.value, ("managed_account", "L")),
],
)
@@ -123,6 +125,7 @@ def test_entrydialog_add_calls_service(kind, expect):
dlg.username_input.value = "u"
dlg.url_input.value = "x"
dlg.length_input.value = 12
dlg.key_input.value = "k1"
dlg.value_input.value = "val"
dlg.save(None)
@@ -136,9 +139,9 @@ def test_entrydialog_add_calls_service(kind, expect):
@pytest.mark.parametrize(
"kind,expected",
[
(EntryType.PASSWORD.value, (1, "newu", "newx", "New", None)),
(EntryType.KEY_VALUE.value, (1, None, None, "New", "val2")),
(EntryType.TOTP.value, (1, None, None, "New", None)),
(EntryType.PASSWORD.value, (1, "newu", "newx", "New", None, None)),
(EntryType.KEY_VALUE.value, (1, None, None, "New", "k2", "val2")),
(EntryType.TOTP.value, (1, None, None, "New", None, None)),
],
)
def test_entrydialog_edit_calls_service(kind, expected):
@@ -157,6 +160,7 @@ def test_entrydialog_edit_calls_service(kind, expected):
dlg.kind_input.value = kind
dlg.username_input.value = "newu"
dlg.url_input.value = "newx"
dlg.key_input.value = "k2"
dlg.value_input.value = "val2"
dlg.save(None)

View File

@@ -0,0 +1,66 @@
import pytest
from pathlib import Path
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
def setup_mgr(tmp_path: Path) -> EntryManager:
vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg = ConfigManager(vault, tmp_path)
backup = BackupManager(tmp_path, cfg)
return EntryManager(vault, backup)
def test_add_totp_invalid_secret(tmp_path: Path):
mgr = setup_mgr(tmp_path)
with pytest.raises(ValueError):
mgr.add_totp("bad", TEST_SEED, secret="notbase32!")
def test_add_ssh_key_validation_failure(monkeypatch, tmp_path: Path):
mgr = setup_mgr(tmp_path)
monkeypatch.setattr(
"seedpass.core.entry_management.validate_ssh_key_pair", lambda p, q: False
)
with pytest.raises(ValueError):
mgr.add_ssh_key("ssh", TEST_SEED)
def test_add_pgp_key_validation_failure(monkeypatch, tmp_path: Path):
mgr = setup_mgr(tmp_path)
monkeypatch.setattr(
"seedpass.core.entry_management.validate_pgp_private_key", lambda p, q: False
)
with pytest.raises(ValueError):
mgr.add_pgp_key("pgp", TEST_SEED, user_id="test")
def test_add_nostr_key_validation_failure(monkeypatch, tmp_path: Path):
mgr = setup_mgr(tmp_path)
monkeypatch.setattr(
"seedpass.core.entry_management.validate_nostr_keys", lambda p, q: False
)
with pytest.raises(ValueError):
mgr.add_nostr_key("nostr", TEST_SEED)
def test_add_seed_validation_failure(monkeypatch, tmp_path: Path):
mgr = setup_mgr(tmp_path)
monkeypatch.setattr(
"seedpass.core.entry_management.validate_seed_phrase", lambda p: False
)
with pytest.raises(ValueError):
mgr.add_seed("seed", TEST_SEED)
def test_add_managed_account_validation_failure(monkeypatch, tmp_path: Path):
mgr = setup_mgr(tmp_path)
monkeypatch.setattr(
"seedpass.core.entry_management.validate_seed_phrase", lambda p: False
)
with pytest.raises(ValueError):
mgr.add_managed_account("acct", TEST_SEED)

View File

@@ -23,12 +23,13 @@ def test_add_and_modify_key_value():
tmp_path = Path(tmpdir)
em = setup_entry_mgr(tmp_path)
idx = em.add_key_value("API", "abc123", notes="token")
idx = em.add_key_value("API entry", "api_key", "abc123", notes="token")
entry = em.retrieve_entry(idx)
assert entry == {
"type": "key_value",
"kind": "key_value",
"label": "API",
"label": "API entry",
"key": "api_key",
"value": "abc123",
"notes": "token",
"archived": False,
@@ -36,8 +37,9 @@ def test_add_and_modify_key_value():
"tags": [],
}
em.modify_entry(idx, value="def456")
em.modify_entry(idx, key="api_key2", value="def456")
updated = em.retrieve_entry(idx)
assert updated["key"] == "api_key2"
assert updated["value"] == "def456"
results = em.search_entries("def456")

View File

@@ -45,6 +45,7 @@ def test_handle_add_password(monkeypatch, dummy_nostr_client, capsys):
inputs = iter(
[
"a", # advanced mode
"Example", # label
"", # username
"", # url
@@ -52,6 +53,14 @@ def test_handle_add_password(monkeypatch, dummy_nostr_client, capsys):
"", # tags
"n", # add custom field
"", # length (default)
"", # include special default
"", # allowed special default
"", # special mode default
"", # exclude ambiguous default
"", # min uppercase
"", # min lowercase
"", # min digits
"", # min special
]
)
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
@@ -106,6 +115,7 @@ def test_handle_add_password_secret_mode(monkeypatch, dummy_nostr_client, capsys
inputs = iter(
[
"a", # advanced mode
"Example", # label
"", # username
"", # url
@@ -113,6 +123,14 @@ def test_handle_add_password_secret_mode(monkeypatch, dummy_nostr_client, capsys
"", # tags
"n", # add custom field
"", # length (default)
"", # include special default
"", # allowed special default
"", # special mode default
"", # exclude ambiguous default
"", # min uppercase
"", # min lowercase
"", # min digits
"", # min special
]
)
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
@@ -131,3 +149,62 @@ def test_handle_add_password_secret_mode(monkeypatch, dummy_nostr_client, capsys
assert f"pw-0-{DEFAULT_PASSWORD_LENGTH}" not in out
assert "copied to clipboard" in out
assert called == [(f"pw-0-{DEFAULT_PASSWORD_LENGTH}", 5)]
def test_handle_add_password_quick_mode(monkeypatch, dummy_nostr_client, capsys):
client, _relay = dummy_nostr_client
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 = client
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
pm.is_dirty = False
inputs = iter(
[
"q", # quick mode
"Example", # label
"", # username
"", # url
"", # length (default)
"", # include special default
]
)
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None)
monkeypatch.setattr(pm, "start_background_vault_sync", lambda *a, **k: None)
pm.handle_add_password()
out = capsys.readouterr().out
entries = entry_mgr.list_entries(verbose=False)
assert entries == [(0, "Example", "", "", False)]
entry = entry_mgr.retrieve_entry(0)
assert entry == {
"label": "Example",
"length": DEFAULT_PASSWORD_LENGTH,
"username": "",
"url": "",
"archived": False,
"type": "password",
"kind": "password",
"notes": "",
"custom_fields": [],
"tags": [],
}
assert f"pw-0-{DEFAULT_PASSWORD_LENGTH}" in out

View File

@@ -0,0 +1,55 @@
import queue
from pathlib import Path
from types import SimpleNamespace
import sys
sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass.core.manager import PasswordManager, EncryptionMode
def _make_pm() -> PasswordManager:
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.vault = SimpleNamespace()
pm.backup_manager = SimpleNamespace()
pm.parent_seed = "seed"
pm.profile_stack = []
pm.current_fingerprint = None
pm.sync_vault = lambda: None
pm.notifications = queue.Queue()
return pm
def test_import_non_backup_file(monkeypatch, capsys):
pm = _make_pm()
called = {"called": False}
def fake_import(*_a, **_k):
called["called"] = True
monkeypatch.setattr("seedpass.core.manager.import_backup", fake_import)
monkeypatch.setattr(
"seedpass.core.manager.clear_header_with_notification", lambda *a, **k: None
)
pm.handle_import_database(Path("data.txt"))
out = capsys.readouterr().out
assert "json.enc" in out.lower()
assert called["called"] is False
def test_import_missing_file(monkeypatch, capsys):
pm = _make_pm()
def raise_missing(*_a, **_k):
raise FileNotFoundError
monkeypatch.setattr("seedpass.core.manager.import_backup", raise_missing)
monkeypatch.setattr(
"seedpass.core.manager.clear_header_with_notification", lambda *a, **k: None
)
pm.handle_import_database(Path("missing.json.enc"))
out = capsys.readouterr().out
assert "not found" in out.lower()

View File

@@ -38,7 +38,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")
entry_mgr.add_key_value("API entry", "api", "abc123")
entry_mgr.add_managed_account("acct", TEST_SEED)
inputs = iter(["1", ""]) # list all, then exit
@@ -72,7 +72,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")
entry_mgr.add_key_value("API entry", "api", "val")
entry_mgr.add_managed_account("acct", TEST_SEED)
monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456")
@@ -246,7 +246,7 @@ def test_show_nostr_entry_details(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
idx = entry_mgr.add_nostr_key("nostr")
idx = entry_mgr.add_nostr_key("nostr", TEST_SEED)
called = _detail_common(monkeypatch, pm)
@@ -339,7 +339,7 @@ def test_show_entry_details_sensitive(monkeypatch, capsys, entry_type):
expected = priv
extra = fp
elif entry_type == "nostr":
idx = entry_mgr.add_nostr_key("nostr")
idx = entry_mgr.add_nostr_key("nostr", TEST_SEED)
_npub, nsec = entry_mgr.get_nostr_key_pair(idx, TEST_SEED)
expected = nsec
elif entry_type == "totp":
@@ -353,7 +353,7 @@ def test_show_entry_details_sensitive(monkeypatch, capsys, entry_type):
)
expected = "123456"
elif entry_type == "key_value":
idx = entry_mgr.add_key_value("API", "abc")
idx = entry_mgr.add_key_value("API entry", "api", "abc")
expected = "abc"
else: # managed_account
idx = entry_mgr.add_managed_account("acct", TEST_SEED)
@@ -390,8 +390,8 @@ def test_show_entry_details_with_enum_type(monkeypatch, capsys, entry_type):
)
expect = "Label: Example"
else: # KEY_VALUE
idx = entry_mgr.add_key_value("API", "abc")
expect = "API"
idx = entry_mgr.add_key_value("API entry", "api", "abc")
expect = "API entry"
data = entry_mgr._load_index(force_reload=True)
data["entries"][str(idx)]["type"] = entry_type

View File

@@ -46,6 +46,6 @@ def test_search_entries_prompt_for_details(monkeypatch, capsys):
pm.handle_search_entries()
out = capsys.readouterr().out
assert "0. Example" in out
assert "0. Totp - Example" in out
assert "Label: Example" in out
assert "Period: 30s" in out

View File

@@ -50,6 +50,7 @@ def test_manager_workflow(monkeypatch):
inputs = iter(
[
"a", # advanced mode
"example.com",
"", # username
"", # url
@@ -57,6 +58,14 @@ def test_manager_workflow(monkeypatch):
"", # tags
"n", # add custom field
"", # length (default)
"", # include special default
"", # allowed special default
"", # special mode default
"", # exclude ambiguous default
"", # min uppercase
"", # min lowercase
"", # min digits
"", # min special
"0", # retrieve index
"", # no action in entry menu
"0", # modify index

View File

@@ -0,0 +1,56 @@
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 seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
def setup_mgr(tmp_path: Path) -> EntryManager:
vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg = ConfigManager(vault, tmp_path)
backup = BackupManager(tmp_path, cfg)
return EntryManager(vault, backup)
def test_modify_ssh_entry():
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
em = setup_mgr(tmp_path)
idx = em.add_ssh_key("ssh", TEST_SEED)
em.modify_entry(idx, label="newssh", notes="n", archived=True, tags=["x"])
entry = em.retrieve_entry(idx)
assert entry["label"] == "newssh"
assert entry["notes"] == "n"
assert entry["archived"] is True
assert entry["tags"] == ["x"]
def test_modify_managed_account_entry():
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
em = setup_mgr(tmp_path)
idx = em.add_managed_account("acct", TEST_SEED)
em.modify_entry(
idx,
label="acct2",
value="val",
notes="note",
archived=True,
tags=["tag"],
)
entry = em.retrieve_entry(idx)
assert entry["label"] == "acct2"
assert entry["value"] == "val"
assert entry["notes"] == "note"
assert entry["archived"] is True
assert entry["tags"] == ["tag"]

View File

@@ -91,7 +91,7 @@ def test_initialize_client_pool_add_relays_used(tmp_path):
client = _setup_client(tmp_path, FakeAddRelaysClient)
fc = client.client
client.connect()
assert fc.added == [client.relays]
assert [[str(r) for r in relays] for relays in fc.added] == [client.relays]
assert fc.connected is True
@@ -99,7 +99,7 @@ def test_initialize_client_pool_add_relay_fallback(tmp_path):
client = _setup_client(tmp_path, FakeAddRelayClient)
fc = client.client
client.connect()
assert fc.added == client.relays
assert [str(r) for r in fc.added] == client.relays
assert fc.connected is True

View File

@@ -22,7 +22,7 @@ def test_nostr_key_determinism():
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
idx = entry_mgr.add_nostr_key("main")
idx = entry_mgr.add_nostr_key("main", TEST_SEED)
entry = entry_mgr.retrieve_entry(idx)
assert entry == {
"type": "nostr",

View File

@@ -42,7 +42,7 @@ def test_show_qr_for_nostr_keys(monkeypatch):
pm.is_dirty = False
pm.secret_mode_enabled = False
idx = entry_mgr.add_nostr_key("main")
idx = entry_mgr.add_nostr_key("main", TEST_SEED)
npub, _ = entry_mgr.get_nostr_key_pair(idx, TEST_SEED)
inputs = iter([str(idx), "q", "p", ""])
@@ -78,7 +78,7 @@ def test_show_private_key_qr(monkeypatch, capsys):
pm.is_dirty = False
pm.secret_mode_enabled = False
idx = entry_mgr.add_nostr_key("main")
idx = entry_mgr.add_nostr_key("main", TEST_SEED)
_, nsec = entry_mgr.get_nostr_key_pair(idx, TEST_SEED)
inputs = iter([str(idx), "q", "k", ""])
@@ -116,7 +116,7 @@ def test_qr_menu_case_insensitive(monkeypatch):
pm.is_dirty = False
pm.secret_mode_enabled = False
idx = entry_mgr.add_nostr_key("main")
idx = entry_mgr.add_nostr_key("main", TEST_SEED)
npub, _ = entry_mgr.get_nostr_key_pair(idx, TEST_SEED)
# Modify index to use uppercase type/kind

View File

@@ -0,0 +1,49 @@
from pathlib import Path
import main
from helpers import create_vault, dummy_nostr_client, TEST_SEED, TEST_PASSWORD
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
from seedpass.core.manager import PasswordManager, EncryptionMode
def _init_pm(dir_path: Path, client) -> PasswordManager:
vault, enc_mgr = create_vault(dir_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, dir_path)
backup_mgr = BackupManager(dir_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.config_manager = cfg_mgr
pm.nostr_client = client
pm.fingerprint_dir = dir_path
pm.current_fingerprint = "fp"
pm.is_dirty = False
return pm
def test_restore_flow_from_snapshot(monkeypatch, tmp_path):
client, relay = dummy_nostr_client.__wrapped__(tmp_path / "srv", monkeypatch)
dir_a = tmp_path / "A"
dir_b = tmp_path / "B"
dir_a.mkdir()
dir_b.mkdir()
pm_a = _init_pm(dir_a, client)
pm_a.entry_manager.add_entry("site1", 12)
pm_a.sync_vault()
assert relay.manifests
pm_b = _init_pm(dir_b, client)
monkeypatch.setattr(main, "pause", lambda *a, **k: None)
main.handle_retrieve_from_nostr(pm_b)
labels = [e[1] for e in pm_b.entry_manager.list_entries()]
assert labels == ["site1"]

View File

@@ -39,7 +39,7 @@ def test_zero_policy_preserves_length():
pg = make_generator(policy)
alphabet = string.ascii_lowercase
dk = bytes(range(32))
result = pg._enforce_complexity("a" * 32, alphabet, dk)
result = pg._enforce_complexity("a" * 32, alphabet, "", dk)
assert len(result) == 32
@@ -50,7 +50,7 @@ def test_custom_policy_applied():
pg = make_generator(policy)
alphabet = string.ascii_letters + string.digits + string.punctuation
dk = bytes(range(32))
result = pg._enforce_complexity("a" * 32, alphabet, dk)
result = pg._enforce_complexity("a" * 32, alphabet, string.punctuation, dk)
counts = count_types(result)
assert counts[0] >= 4
assert counts[1] >= 1

View File

@@ -41,7 +41,7 @@ def test_enforce_complexity_minimum_counts():
pg = make_generator()
alphabet = string.ascii_letters + string.digits + string.punctuation
dk = bytes(range(32))
result = pg._enforce_complexity("a" * 32, alphabet, dk)
result = pg._enforce_complexity("a" * 32, alphabet, string.punctuation, dk)
assert sum(1 for c in result if c.isupper()) >= 2
assert sum(1 for c in result if c.islower()) >= 2
assert sum(1 for c in result if c.isdigit()) >= 2

View File

@@ -0,0 +1,52 @@
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 seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.manager import PasswordManager, EncryptionMode
from seedpass.core.config_manager import ConfigManager
def test_password_notes_shown(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = vault
pm.entry_manager = entry_mgr
pm.backup_manager = backup_mgr
pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw")
pm.parent_seed = TEST_SEED
pm.nostr_client = SimpleNamespace()
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
entry_mgr.add_entry("example.com", 8, notes="remember")
monkeypatch.setattr(
"seedpass.core.manager.clear_header_with_notification", lambda *a, **k: None
)
monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None)
monkeypatch.setattr(pm, "_entry_actions_menu", lambda *a, **k: None)
pm.display_entry_details(0)
out = capsys.readouterr().out
assert "Notes: remember" in out
inputs = iter(["0", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
pm.handle_retrieve_entry()
out = capsys.readouterr().out
assert "Notes: remember" in out

View File

@@ -0,0 +1,59 @@
import string
from pathlib import Path
import sys
sys.path.append(str(Path(__file__).resolve().parents[1]))
from constants import SAFE_SPECIAL_CHARS
from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy
class DummyEnc:
def derive_seed_from_mnemonic(self, mnemonic):
return b"\x00" * 32
class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len))
def make_generator(policy=None):
pg = PasswordGenerator.__new__(PasswordGenerator)
pg.encryption_manager = DummyEnc()
pg.bip85 = DummyBIP85()
pg.policy = policy or PasswordPolicy()
return pg
def test_no_special_chars():
policy = PasswordPolicy(include_special_chars=False)
pg = make_generator(policy)
pw = pg.generate_password(length=16, index=0)
assert not any(c in string.punctuation for c in pw)
def test_allowed_special_chars_only():
allowed = "@$"
policy = PasswordPolicy(allowed_special_chars=allowed)
pg = make_generator(policy)
pw = pg.generate_password(length=32, index=1)
specials = [c for c in pw if c in string.punctuation]
assert specials and all(c in allowed for c in specials)
def test_exclude_ambiguous_chars():
policy = PasswordPolicy(exclude_ambiguous=True)
pg = make_generator(policy)
pw = pg.generate_password(length=32, index=2)
for ch in "O0Il1":
assert ch not in pw
def test_safe_special_chars_mode():
policy = PasswordPolicy(special_mode="safe")
pg = make_generator(policy)
pw = pg.generate_password(length=32, index=3)
specials = [c for c in pw if c in string.punctuation]
assert specials and all(c in SAFE_SPECIAL_CHARS for c in specials)

View File

@@ -0,0 +1,62 @@
import string
from pathlib import Path
import sys
sys.path.append(str(Path(__file__).resolve().parents[1]))
from constants import SAFE_SPECIAL_CHARS
from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy
class DummyEnc:
def derive_seed_from_mnemonic(self, mnemonic):
return b"\x00" * 32
class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len))
def make_generator(policy=None):
pg = PasswordGenerator.__new__(PasswordGenerator)
pg.encryption_manager = DummyEnc()
pg.bip85 = DummyBIP85()
pg.policy = policy or PasswordPolicy()
return pg
def test_include_special_chars_false():
policy = PasswordPolicy(include_special_chars=False)
pg = make_generator(policy)
pw = pg.generate_password(length=32, index=0)
assert not any(c in string.punctuation for c in pw)
def test_safe_mode_uses_safe_chars():
policy = PasswordPolicy(special_mode="safe")
pg = make_generator(policy)
pw = pg.generate_password(length=32, index=1)
specials = [c for c in pw if c in string.punctuation]
assert specials and all(c in SAFE_SPECIAL_CHARS for c in specials)
def test_allowed_chars_override_special_mode():
allowed = "@#$"
policy = PasswordPolicy(special_mode="safe", allowed_special_chars=allowed)
pg = make_generator(policy)
pw = pg.generate_password(length=32, index=2)
specials = [c for c in pw if c in string.punctuation]
assert specials and all(c in allowed for c in specials)
def test_enforce_complexity_min_special_zero():
policy = PasswordPolicy(min_special=0)
pg = make_generator(policy)
alphabet = string.ascii_letters + string.digits + string.punctuation
dk = bytes(range(32))
result = pg._enforce_complexity("a" * 32, alphabet, string.punctuation, dk)
assert len(result) == 32
assert sum(c.isupper() for c in result) >= 2
assert sum(c.islower() for c in result) >= 2
assert sum(c.isdigit() for c in result) >= 2

View File

@@ -0,0 +1,119 @@
from pathlib import Path
from helpers import create_vault, dummy_nostr_client, TEST_SEED, TEST_PASSWORD
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
from seedpass.core.manager import PasswordManager, EncryptionMode
def _init_pm(dir_path: Path, client) -> PasswordManager:
vault, enc_mgr = create_vault(dir_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, dir_path)
backup_mgr = BackupManager(dir_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.config_manager = cfg_mgr
pm.nostr_client = client
pm.fingerprint_dir = dir_path
pm.current_fingerprint = "fp"
pm.is_dirty = False
return pm
def test_handle_new_seed_setup_restore_from_nostr(monkeypatch, tmp_path, capsys):
client, _relay = dummy_nostr_client.__wrapped__(tmp_path / "srv", monkeypatch)
dir_a = tmp_path / "A"
dir_b = tmp_path / "B"
dir_a.mkdir()
dir_b.mkdir()
pm_src = _init_pm(dir_a, client)
pm_src.notify = lambda *a, **k: None
pm_src.entry_manager.add_entry("site1", 12)
pm_src.sync_vault()
pm_new = PasswordManager.__new__(PasswordManager)
pm_new.encryption_mode = EncryptionMode.SEED_ONLY
pm_new.nostr_client = client
pm_new.notify = lambda *a, **k: None
def finalize(seed, *, password=None):
vault, enc_mgr = create_vault(dir_b, seed, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, dir_b)
backup_mgr = BackupManager(dir_b, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
pm_new.encryption_manager = enc_mgr
pm_new.vault = vault
pm_new.entry_manager = entry_mgr
pm_new.backup_manager = backup_mgr
pm_new.config_manager = cfg_mgr
pm_new.fingerprint_dir = dir_b
pm_new.current_fingerprint = "fp"
pm_new.nostr_client = client
return "fp"
monkeypatch.setattr(pm_new, "_finalize_existing_seed", finalize)
monkeypatch.setattr("seedpass.core.manager.masked_input", lambda *_: TEST_SEED)
inputs = iter(["4"])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
pm_new.handle_new_seed_setup()
out = capsys.readouterr().out
assert "Vault restored from Nostr" in out
labels = [e[1] for e in pm_new.entry_manager.list_entries()]
assert labels == ["site1"]
async def _no_snapshot():
return None
def test_restore_from_nostr_warns(monkeypatch, tmp_path, capsys):
client, _relay = dummy_nostr_client.__wrapped__(tmp_path / "srv", monkeypatch)
monkeypatch.setattr(client, "fetch_latest_snapshot", _no_snapshot)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.nostr_client = client
monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_: True)
monkeypatch.setattr(pm, "_finalize_existing_seed", lambda *_a, **_k: "fp")
monkeypatch.setattr(pm, "attempt_initial_sync", lambda: False)
pm.restore_from_nostr_with_guidance(TEST_SEED)
out = capsys.readouterr().out
assert "No Nostr backup" in out
def test_restore_from_nostr_abort(monkeypatch, tmp_path, capsys):
client, _relay = dummy_nostr_client.__wrapped__(tmp_path / "srv", monkeypatch)
monkeypatch.setattr(client, "fetch_latest_snapshot", _no_snapshot)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.nostr_client = client
pm.vault = None
called = {"finalize": 0}
def finalize(*_a, **_k):
called["finalize"] += 1
monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_: False)
monkeypatch.setattr(pm, "_finalize_existing_seed", finalize)
pm.restore_from_nostr_with_guidance(TEST_SEED)
out = capsys.readouterr().out
assert "No Nostr backup" in out
assert called["finalize"] == 0
assert pm.vault is None

View File

@@ -20,7 +20,7 @@ import pytest
(lambda mgr: mgr.add_seed("seed", TEST_SEED), True),
(lambda mgr: mgr.add_pgp_key("pgp", TEST_SEED, user_id="test"), True),
(lambda mgr: mgr.add_ssh_key("ssh", TEST_SEED), True),
(lambda mgr: mgr.add_nostr_key("nostr"), False),
(lambda mgr: mgr.add_nostr_key("nostr", TEST_SEED), False),
],
)
def test_pause_before_entry_actions(monkeypatch, adder, needs_confirm):

View File

@@ -28,7 +28,7 @@ def test_search_by_website():
entry_mgr.add_entry("Other.com", 8, "bob")
result = entry_mgr.search_entries("example")
assert result == [(idx0, "Example.com", "alice", "", False)]
assert result == [(idx0, "Example.com", "alice", "", False, EntryType.PASSWORD)]
def test_search_by_username():
@@ -40,7 +40,7 @@ def test_search_by_username():
idx1 = entry_mgr.add_entry("Test.com", 8, "Bob")
result = entry_mgr.search_entries("bob")
assert result == [(idx1, "Test.com", "Bob", "", False)]
assert result == [(idx1, "Test.com", "Bob", "", False, EntryType.PASSWORD)]
def test_search_by_url():
@@ -52,7 +52,9 @@ def test_search_by_url():
entry_mgr.add_entry("Other", 8)
result = entry_mgr.search_entries("login")
assert result == [(idx, "Example", "", "https://ex.com/login", False)]
assert result == [
(idx, "Example", "", "https://ex.com/login", False, EntryType.PASSWORD)
]
def test_search_by_notes_and_totp():
@@ -93,7 +95,7 @@ def test_search_key_value_value():
tmp_path = Path(tmpdir)
entry_mgr = setup_entry_manager(tmp_path)
idx = entry_mgr.add_key_value("API", "token123")
idx = entry_mgr.add_key_value("API entry", "api", "token123")
result = entry_mgr.search_entries("token123")
assert result == []
@@ -117,7 +119,7 @@ def test_search_by_tag_password():
idx = entry_mgr.add_entry("TaggedSite", 8, tags=["work"])
result = entry_mgr.search_entries("work")
assert result == [(idx, "TaggedSite", "", "", False)]
assert result == [(idx, "TaggedSite", "", "", False, EntryType.PASSWORD)]
def test_search_by_tag_totp():
@@ -129,7 +131,7 @@ def test_search_by_tag_totp():
idx = entry_mgr.search_entries("OTPAccount")[0][0]
result = entry_mgr.search_entries("mfa")
assert result == [(idx, "OTPAccount", None, None, False)]
assert result == [(idx, "OTPAccount", None, None, False, EntryType.TOTP)]
def test_search_with_kind_filter():
@@ -147,4 +149,4 @@ def test_search_with_kind_filter():
assert {r[0] for r in all_results} == {idx_pw, idx_totp}
only_pw = entry_mgr.search_entries("", kinds=[EntryType.PASSWORD.value])
assert only_pw == [(idx_pw, "Site", "", "", False)]
assert only_pw == [(idx_pw, "Site", "", "", False, EntryType.PASSWORD)]

View File

@@ -2,6 +2,7 @@ import sys
from types import SimpleNamespace
from pathlib import Path
import pytest
from seedpass.core.stats_manager import StatsManager
sys.path.append(str(Path(__file__).resolve().parents[1]))
@@ -9,7 +10,11 @@ import main
def _make_pm():
return SimpleNamespace(display_stats=lambda: print("stats"))
return SimpleNamespace(
display_stats=lambda: print("stats"),
start_background_sync=lambda: None,
stats_manager=StatsManager(),
)
def test_live_stats_shows_message(monkeypatch, capsys):
@@ -36,3 +41,50 @@ def test_live_stats_shows_notification(monkeypatch, capsys):
main._display_live_stats(pm)
out = capsys.readouterr().out
assert "note" in out
def test_live_stats_triggers_background_sync(monkeypatch):
called = {"sync": 0}
pm = _make_pm()
pm.start_background_sync = lambda: called.__setitem__("sync", called["sync"] + 1)
monkeypatch.setattr(main, "get_notification_text", lambda *_: "")
monkeypatch.setattr(
main,
"timed_input",
lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()),
)
main._display_live_stats(pm)
assert called["sync"] >= 1
def test_stats_display_only_once(monkeypatch, capsys):
pm = _make_pm()
monkeypatch.setattr(main, "get_notification_text", lambda *_: "")
events = [TimeoutError(), KeyboardInterrupt()]
def fake_input(*_args, **_kwargs):
raise events.pop(0)
monkeypatch.setattr(main, "timed_input", fake_input)
main._display_live_stats(pm, interval=0.01)
out = capsys.readouterr().out
assert out.count("stats") >= 1
def test_stats_display_resets_after_exit(monkeypatch, capsys):
pm = _make_pm()
monkeypatch.setattr(main, "get_notification_text", lambda *_: "")
monkeypatch.setattr(
main,
"timed_input",
lambda *_args, **_kwargs: (_ for _ in ()).throw(KeyboardInterrupt()),
)
main._display_live_stats(pm)
main._display_live_stats(pm)
out = capsys.readouterr().out
assert out.count("stats") == 2

View File

@@ -9,6 +9,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
from seedpass.core.entry_types import EntryType
def setup_entry_manager(tmp_path: Path) -> EntryManager:
@@ -29,7 +30,7 @@ def test_tags_persist_on_new_entry():
entry_mgr = setup_entry_manager(tmp_path)
result = entry_mgr.search_entries("work")
assert result == [(idx, "Site", "", "", False)]
assert result == [(idx, "Site", "", "", False, EntryType.PASSWORD)]
def test_tags_persist_after_modify():
@@ -41,9 +42,11 @@ def test_tags_persist_after_modify():
entry_mgr.modify_entry(idx, tags=["personal"])
# Ensure tag searchable before reload
assert entry_mgr.search_entries("personal") == [(idx, "Site", "", "", False)]
assert entry_mgr.search_entries("personal") == [
(idx, "Site", "", "", False, EntryType.PASSWORD)
]
# Reinitialize to simulate application restart
entry_mgr = setup_entry_manager(tmp_path)
result = entry_mgr.search_entries("personal")
assert result == [(idx, "Site", "", "", False)]
assert result == [(idx, "Site", "", "", False, EntryType.PASSWORD)]

View File

@@ -34,19 +34,21 @@ def test_entry_list(monkeypatch):
def test_entry_search(monkeypatch):
pm = SimpleNamespace(
entry_manager=SimpleNamespace(
search_entries=lambda q, kinds=None: [(1, "L", None, None, False)]
search_entries=lambda q, kinds=None: [
(1, "L", None, None, False, EntryType.PASSWORD)
]
),
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["entry", "search", "l"])
assert result.exit_code == 0
assert "1: L" in result.stdout
assert "Password - L" in result.stdout
def test_entry_get_password(monkeypatch):
def search(q, kinds=None):
return [(2, "Example", "", "", False)]
return [(2, "Example", "", "", False, EntryType.PASSWORD)]
entry = {"type": EntryType.PASSWORD.value, "length": 8}
pm = SimpleNamespace(
@@ -321,18 +323,54 @@ def test_nostr_sync(monkeypatch):
def test_generate_password(monkeypatch):
called = {}
def gen_pw(length):
def gen_pw(length, **kwargs):
called["length"] = length
called["kwargs"] = kwargs
return "secretpw"
pm = SimpleNamespace(
password_generator=SimpleNamespace(generate_password=gen_pw),
select_fingerprint=lambda fp: None,
monkeypatch.setattr(
cli,
"PasswordManager",
lambda: SimpleNamespace(select_fingerprint=lambda fp: None),
)
monkeypatch.setattr(
cli, "UtilityService", lambda pm: SimpleNamespace(generate_password=gen_pw)
)
result = runner.invoke(
app,
[
"util",
"generate-password",
"--length",
"12",
"--no-special",
"--allowed-special-chars",
"!@",
"--special-mode",
"safe",
"--exclude-ambiguous",
"--min-uppercase",
"1",
"--min-lowercase",
"2",
"--min-digits",
"3",
"--min-special",
"4",
],
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["util", "generate-password", "--length", "12"])
assert result.exit_code == 0
assert called.get("length") == 12
assert called.get("kwargs") == {
"include_special_chars": False,
"allowed_special_chars": "!@",
"special_mode": "safe",
"exclude_ambiguous": True,
"min_uppercase": 1,
"min_lowercase": 2,
"min_digits": 3,
"min_special": 4,
}
assert "secretpw" in result.stdout
@@ -370,8 +408,9 @@ def test_entry_list_passes_fingerprint(monkeypatch):
def test_entry_add(monkeypatch):
called = {}
def add_entry(label, length, username=None, url=None):
def add_entry(label, length, username=None, url=None, **kwargs):
called["args"] = (label, length, username, url)
called["kwargs"] = kwargs
return 2
pm = SimpleNamespace(
@@ -392,18 +431,44 @@ def test_entry_add(monkeypatch):
"bob",
"--url",
"ex.com",
"--no-special",
"--allowed-special-chars",
"!@",
"--special-mode",
"safe",
"--exclude-ambiguous",
"--min-uppercase",
"1",
"--min-lowercase",
"2",
"--min-digits",
"3",
"--min-special",
"4",
],
)
assert result.exit_code == 0
assert "2" in result.stdout
assert called["args"] == ("Example", 16, "bob", "ex.com")
assert called["kwargs"] == {
"include_special_chars": False,
"allowed_special_chars": "!@",
"special_mode": "safe",
"exclude_ambiguous": True,
"min_uppercase": 1,
"min_lowercase": 2,
"min_digits": 3,
"min_special": 4,
}
def test_entry_modify(monkeypatch):
called = {}
def modify_entry(index, username=None, url=None, notes=None, label=None, **kwargs):
called["args"] = (index, username, url, notes, label, kwargs)
def modify_entry(
index, username=None, url=None, notes=None, label=None, key=None, **kwargs
):
called["args"] = (index, username, url, notes, label, key, kwargs)
pm = SimpleNamespace(
entry_manager=SimpleNamespace(modify_entry=modify_entry),
@@ -413,7 +478,7 @@ def test_entry_modify(monkeypatch):
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["entry", "modify", "1", "--username", "alice"])
assert result.exit_code == 0
assert called["args"][:5] == (1, "alice", None, None, None)
assert called["args"][:6] == (1, "alice", None, None, None, None)
def test_entry_modify_invalid(monkeypatch):

View File

@@ -1,4 +1,6 @@
import time
import asyncio
import warnings
from types import SimpleNamespace
from pathlib import Path
import sys
@@ -17,14 +19,15 @@ def test_unlock_triggers_sync(monkeypatch, tmp_path):
pm.initialize_managers = lambda: None
called = {"sync": False}
def fake_sync(self):
async def fake_sync(self):
called["sync"] = True
monkeypatch.setattr(PasswordManager, "sync_index_from_nostr", fake_sync)
monkeypatch.setattr(PasswordManager, "sync_index_from_nostr_async", fake_sync)
pm.unlock_vault("pw")
pm.start_background_sync()
time.sleep(0.05)
pm.cleanup()
assert called["sync"]
@@ -54,3 +57,29 @@ def test_quick_unlock_background_sync(monkeypatch, tmp_path):
pm.exit_managed_account()
assert called["bg"]
def test_start_background_sync_running_loop(monkeypatch):
pm = PasswordManager.__new__(PasswordManager)
pm.offline_mode = False
called = {"init": False, "sync": False}
async def fake_attempt(self):
called["init"] = True
async def fake_sync(self):
called["sync"] = True
monkeypatch.setattr(PasswordManager, "attempt_initial_sync_async", fake_attempt)
monkeypatch.setattr(PasswordManager, "sync_index_from_nostr_async", fake_sync)
async def runner():
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
pm.start_background_sync()
await asyncio.sleep(0.01)
assert not any(issubclass(wi.category, RuntimeWarning) for wi in w)
asyncio.run(runner())
pm.cleanup()
assert called["init"] and called["sync"]

View File

@@ -0,0 +1,69 @@
"""Key validation helper functions."""
import logging
from cryptography.hazmat.primitives import serialization
from pgpy import PGPKey
import pyotp
from nostr.coincurve_keys import Keys
from mnemonic import Mnemonic
logger = logging.getLogger(__name__)
def validate_totp_secret(secret: str) -> bool:
"""Return True if ``secret`` is a valid Base32 TOTP secret."""
try:
pyotp.TOTP(secret).at(0)
return True
except Exception as e: # pragma: no cover - pyotp errors vary
logger.debug(f"Invalid TOTP secret: {e}")
return False
def validate_ssh_key_pair(priv_pem: str, pub_pem: str) -> bool:
"""Ensure ``priv_pem`` corresponds to ``pub_pem``."""
try:
priv = serialization.load_pem_private_key(priv_pem.encode(), password=None)
derived = (
priv.public_key()
.public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode()
)
return derived == pub_pem
except Exception as e: # pragma: no cover - serialization errors vary
logger.debug(f"SSH key validation failed: {e}")
return False
def validate_pgp_private_key(priv_key: str, fingerprint: str) -> bool:
"""Return True if ``priv_key`` matches ``fingerprint``."""
try:
key, _ = PGPKey.from_blob(priv_key)
return key.fingerprint == fingerprint
except Exception as e: # pragma: no cover - pgpy errors vary
logger.debug(f"PGP key validation failed: {e}")
return False
def validate_nostr_keys(npub: str, nsec: str) -> bool:
"""Return True if ``nsec`` decodes to ``npub``."""
try:
priv_hex = Keys.bech32_to_hex(nsec)
derived = Keys(priv_k=priv_hex)
encoded = Keys.hex_to_bech32(derived.public_key_hex(), "npub")
return encoded == npub
except Exception as e: # pragma: no cover - nostr errors vary
logger.debug(f"Nostr key validation failed: {e}")
return False
def validate_seed_phrase(mnemonic: str) -> bool:
"""Return True if ``mnemonic`` is a valid BIP-39 seed phrase."""
try:
return Mnemonic("english").check(mnemonic)
except Exception as e: # pragma: no cover - mnemonic errors vary
logger.debug(f"Seed phrase validation failed: {e}")
return False