mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-05 05:48:42 +00:00
2
.github/workflows/python-ci.yml
vendored
2
.github/workflows/python-ci.yml
vendored
@@ -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: |
|
||||
|
30
README.md
30
README.md
@@ -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:
|
||||
|
@@ -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 BIP‑85 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 script‑friendly and can be piped into other commands.
|
||||
|
@@ -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
|
||||
|
@@ -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` |
|
||||
|
||||
|
||||
|
@@ -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",
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
||||
|
87
src/main.py
87
src/main.py
@@ -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}")
|
||||
|
@@ -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."""
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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."""
|
||||
|
@@ -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)
|
||||
|
||||
|
||||
|
@@ -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}'")
|
||||
|
@@ -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:
|
||||
|
@@ -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)
|
||||
|
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
@@ -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:
|
||||
|
@@ -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(
|
||||
|
20
src/seedpass/core/stats_manager.py
Normal file
20
src/seedpass/core/stats_manager.py
Normal 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
|
@@ -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,
|
||||
|
@@ -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,
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
]
|
||||
|
@@ -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)
|
||||
]
|
||||
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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,
|
||||
|
@@ -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",
|
||||
),
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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"])
|
||||
|
||||
|
||||
|
@@ -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)),
|
||||
],
|
||||
)
|
||||
|
67
src/tests/test_entry_policy_override.py
Normal file
67
src/tests/test_entry_policy_override.py
Normal 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)
|
@@ -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)
|
||||
|
||||
|
66
src/tests/test_key_validation_failures.py
Normal file
66
src/tests/test_key_validation_failures.py
Normal 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)
|
@@ -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")
|
||||
|
@@ -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
|
||||
|
55
src/tests/test_manager_import_database.py
Normal file
55
src/tests/test_manager_import_database.py
Normal 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()
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
56
src/tests/test_modify_ssh_managed_entries.py
Normal file
56
src/tests/test_modify_ssh_managed_entries.py
Normal 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"]
|
@@ -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
|
||||
|
||||
|
||||
|
@@ -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",
|
||||
|
@@ -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
|
||||
|
49
src/tests/test_nostr_restore_flow.py
Normal file
49
src/tests/test_nostr_restore_flow.py
Normal 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"]
|
@@ -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
|
||||
|
@@ -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
|
||||
|
52
src/tests/test_password_notes_display.py
Normal file
52
src/tests/test_password_notes_display.py
Normal 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
|
59
src/tests/test_password_special_chars.py
Normal file
59
src/tests/test_password_special_chars.py
Normal 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)
|
62
src/tests/test_password_special_modes.py
Normal file
62
src/tests/test_password_special_modes.py
Normal 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
|
119
src/tests/test_restore_from_nostr_setup.py
Normal file
119
src/tests/test_restore_from_nostr_setup.py
Normal 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
|
@@ -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):
|
||||
|
@@ -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)]
|
||||
|
@@ -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
|
||||
|
@@ -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)]
|
||||
|
@@ -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):
|
||||
|
@@ -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"]
|
||||
|
69
src/utils/key_validation.py
Normal file
69
src/utils/key_validation.py
Normal 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
|
Reference in New Issue
Block a user