Merge pull request #444 from PR0M3TH3AN/codex/implement-relay-management-commands-and-api

Codex/implement relay management commands and api
This commit is contained in:
thePR0M3TH3AN
2025-07-09 21:01:54 -04:00
committed by GitHub
20 changed files with 2566 additions and 50 deletions

View File

@@ -50,6 +50,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
- **SeedPass 2FA:** Generate TOTP codes with a real-time countdown progress bar.
- **2FA Secret Issuance & Import:** Derive new TOTP secrets from your seed or import existing `otpauth://` URIs.
- **Export 2FA Codes:** Save all stored TOTP entries to an encrypted JSON file for use with other apps.
- **Display TOTP Codes:** Show all active 2FA codes with a countdown timer.
- **Optional External Backup Location:** Configure a second directory where backups are automatically copied.
- **AutoLock on Inactivity:** Vault locks after a configurable timeout for additional security.
- **Secret Mode:** Copy retrieved passwords directly to your clipboard and automatically clear it after a delay.
@@ -175,7 +176,8 @@ seedpass import --file "~/seedpass_backup.json"
seedpass search "github"
seedpass search --tags "work,personal"
seedpass get "github"
seedpass totp "email"
# Retrieve a TOTP entry
seedpass entry get "email"
# The code is printed and copied to your clipboard
# Sort or filter the list view
@@ -186,6 +188,9 @@ seedpass list --filter totp
# on an external drive.
```
For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md).
Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md).
### Vault JSON Layout
The encrypted index file `seedpass_entries_db.json.enc` begins with `schema_version` `2` and stores an `entries` map keyed by entry numbers.
@@ -213,6 +218,12 @@ After successfully installing the dependencies, you can run SeedPass using the f
python src/main.py
```
You can also use the new Typer-based CLI:
```bash
seedpass --help
```
For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). The REST API is described in [docs/api_reference.md](docs/api_reference.md).
### Running the Application
1. **Start the Application:**
@@ -373,7 +384,7 @@ Back in the Settings menu you can:
## Running Tests
SeedPass includes a small suite of unit tests located under `src/tests`. After activating your virtual environment and installing dependencies, run the tests with **pytest**. Use `-vv` to see INFO-level log messages from each passing test:
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:
```bash
@@ -441,6 +452,7 @@ Mutation testing is disabled in the GitHub workflow due to reliability issues an
- **Backup Your Data:** Regularly back up your encrypted data and checksum files to prevent data loss.
- **Backup the Settings PIN:** Your settings PIN is stored in the encrypted configuration file. Keep a copy of this file or remember the PIN, as losing it will require deleting the file and reconfiguring your relays.
- **Protect Your Passwords:** Do not share your master password or seed phrases with anyone and ensure they are strong and unique.
- **Revealing the Parent Seed:** The `vault reveal-parent-seed` command and `/api/v1/parent-seed` endpoint print your seed in plain text. Run them only in a secure environment.
- **No PBKDF2 Salt Needed:** SeedPass deliberately omits an explicit PBKDF2 salt. Every password is derived from a unique 512-bit BIP-85 child seed, which already provides stronger per-password uniqueness than a conventional 128-bit salt.
- **Checksum Verification:** Always verify the script's checksum to ensure its integrity and protect against unauthorized modifications.
- **Potential Bugs and Limitations:** Be aware that the software may contain bugs and lacks certain features. Snapshot chunks are capped at 50KB and the client rotates snapshots after enough delta events accumulate. The security of memory management and logs has not been thoroughly evaluated and may pose risks of leaking sensitive information.

View File

@@ -4,14 +4,22 @@ This directory contains supplementary guides for using SeedPass.
## Quick Example: Get a TOTP Code
Run `seedpass totp <query>` to retrieve a time-based one-time password (TOTP). The
`<query>` can be a label, title, or index. A progress bar shows the remaining
Run `seedpass entry get <query>` to retrieve a time-based one-time password (TOTP).
The `<query>` can be a label, title, or index. A progress bar shows the remaining
seconds in the current period.
```bash
$ seedpass totp "email"
$ seedpass entry get "email"
[##########----------] 15s
Code: 123456
```
See [advanced_cli.md](advanced_cli.md) (future feature set) for details on the upcoming advanced CLI.
To show all stored TOTP codes with their countdown timers, run:
```bash
$ seedpass entry totp-codes
```
## CLI and API Reference
See [advanced_cli.md](advanced_cli.md) for a list of command examples. Detailed information about the REST API is available in [api_reference.md](api_reference.md). When starting the API, set `SEEDPASS_CORS_ORIGINS` if you need to allow requests from specific web origins.

View File

@@ -6,7 +6,7 @@ Welcome to the **Advanced CLI and API Documentation** for **SeedPass**, a secure
SeedPass uses a `noun-verb` command structure (e.g., `seedpass entry get <query>`) for a clear, scalable, and discoverable interface. You can explore the available actions for any command group with the `--help` flag (for example, `seedpass entry --help`).
> **Note:** These commands describe planned functionality. The advanced CLI is not yet part of the stable release but will follow the current SeedPass design of fingerprint-based profiles and a local API for secure integrations.
The commands in this document reflect the Typer-based CLI shipped with SeedPass. Each command accepts the optional `--fingerprint` flag to operate on a specific seed profile.
---
@@ -20,8 +20,9 @@ SeedPass uses a `noun-verb` command structure (e.g., `seedpass entry get <query>
- [Config Commands](#config-commands)
- [Fingerprint Commands](#fingerprint-commands)
- [Utility Commands](#utility-commands)
- [API Commands](#api-commands)
3. [Detailed Command Descriptions](#detailed-command-descriptions)
4. [Planned API Integration](#planned-api-integration)
4. [API Integration](#api-integration)
5. [Usage Guidelines](#usage-guidelines)
---
@@ -45,12 +46,22 @@ Manage individual entries within a vault.
| Action | Command | Examples |
| :--- | :--- | :--- |
| Add a new entry | `entry add` | `seedpass entry add --type password --label "GitHub" --username "user" --length 20` |
| Retrieve an entry's secret | `entry get` | `seedpass entry get "GitHub"` |
| List entries | `entry list` | `seedpass entry list --sort label` |
| Search for entries | `entry search` | `seedpass entry search "GitHub"` |
| Modify an entry | `entry modify` | `seedpass entry modify "GitHub" --notes "New note"` |
| Delete an entry | `entry delete` | `seedpass entry delete "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 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 managed account entry | `entry add-managed-account` | `seedpass entry add-managed-account Trading` |
| Modify an entry | `entry modify` | `seedpass entry modify 1 --username alice` |
| 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` |
| Show all TOTP codes | `entry totp-codes` | `seedpass entry totp-codes` |
### Vault Commands
@@ -60,7 +71,9 @@ Manage the entire vault for a profile.
| :--- | :--- | :--- |
| Export the vault | `vault export` | `seedpass vault export --file backup.json` |
| Import a vault | `vault import` | `seedpass vault import --file backup.json` |
| Change master password | `vault changepw` | `seedpass vault changepw` |
| Change the master password | `vault change-password` | `seedpass vault change-password` |
| Lock the vault | `vault lock` | `seedpass vault lock` |
| Show profile statistics | `vault stats` | `seedpass vault stats` |
### Nostr Commands
@@ -70,7 +83,6 @@ Interact with the Nostr network for backup and synchronization.
| :--- | :--- | :--- |
| Sync with relays | `nostr sync` | `seedpass nostr sync` |
| Get public key | `nostr get-pubkey` | `seedpass nostr get-pubkey` |
| Manage relays | `nostr relays` | `seedpass nostr relays --add wss://relay.example.com` |
### Config Commands
@@ -79,7 +91,7 @@ Manage profilespecific settings.
| Action | Command | Examples |
| :--- | :--- | :--- |
| Get a setting value | `config get` | `seedpass config get inactivity_timeout` |
| Set a setting value | `config set` | `seedpass config set secret_mode true` |
| Set a setting value | `config set` | `seedpass config set inactivity_timeout 300` |
### Fingerprint Commands
@@ -87,10 +99,10 @@ Manage seed profiles (fingerprints).
| Action | Command | Examples |
| :--- | :--- | :--- |
| Add a new profile | `fingerprint add` | `seedpass fingerprint add` |
| List all profiles | `fingerprint list` | `seedpass fingerprint list` |
| Remove a profile | `fingerprint remove` | `seedpass fingerprint remove <FP>` |
| Set active profile | `fingerprint use` | `seedpass fingerprint use <FP>` |
| Add a profile | `fingerprint add` | `seedpass fingerprint add` |
| Remove a profile | `fingerprint remove` | `seedpass fingerprint remove <fp>` |
| Switch profile | `fingerprint switch` | `seedpass fingerprint switch <fp>` |
### Utility Commands
@@ -100,6 +112,16 @@ Miscellaneous helper commands.
| :--- | :--- | :--- |
| Generate a password | `util generate-password` | `seedpass util generate-password --length 24` |
| Verify script checksum | `util verify-checksum` | `seedpass util verify-checksum` |
| Update script checksum | `util update-checksum` | `seedpass util update-checksum` |
### API Commands
Run or stop the local HTTP API.
| Action | Command | Examples |
| :--- | :--- | :--- |
| Start the API | `api start` | `seedpass api start --host 0.0.0.0 --port 8000` |
| Stop the API | `api stop` | `seedpass api stop` |
---
@@ -107,47 +129,76 @@ Miscellaneous helper commands.
### `entry` Commands
- **`seedpass entry add`** Add a new entry. Use `--type` to specify `password`, `totp`, `ssh`, `pgp`, `nostr`, `key-value`, or `managed-account`. Include `--tags tag1,tag2` to categorize the entry.
- **`seedpass entry get <query>`** Retrieve the primary secret for one matching entry.
- **`seedpass entry list`** List entries in the vault, optionally sorted or filtered.
- **`seedpass entry search <query>`** Search across labels, usernames, URLs, notes, and tags.
- **`seedpass entry modify <query>`** Update fields on an existing entry. Use `--archive` to hide or `--restore` to unarchive. Specify `--tags tag1,tag2` to replace the entry's tags.
- **`seedpass entry delete <query>`** Permanently delete an entry after confirmation.
- **`seedpass entry search <query>`** Search across labels, usernames, URLs and notes.
- **`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-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-managed-account <label>`** Store a BIP85 derived account seed.
- **`seedpass entry modify <id>`** Update an entry's label, username, URL or notes.
- **`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.
- **`seedpass entry totp-codes`** Display all current TOTP codes with remaining time.
Example retrieving a TOTP code:
```bash
$ seedpass entry get "email"
[##########----------] 15s
Code: 123456
```
### `vault` Commands
- **`seedpass vault export`** Export the entire vault to an encrypted JSON file.
- **`seedpass vault import`** Import entries from an exported file, replacing the current vault after creating a backup.
- **`seedpass vault changepw`** Interactively change the master password for the current profile.
- **`seedpass vault import`** Import a vault from an encrypted JSON file.
- **`seedpass vault change-password`** Change the master password used for encryption.
- **`seedpass vault lock`** Clear sensitive data from memory and require reauthentication.
- **`seedpass vault stats`** Display statistics about the active seed profile.
### `nostr` Commands
- **`seedpass nostr sync`** Perform a twoway sync with configured Nostr relays.
- **`seedpass nostr get-pubkey`** Display the Nostr public key for the active profile.
- **`seedpass nostr relays`** Manage the relay list (`--list`, `--add`, `--remove`, `--reset`).
### `config` Commands
- **`seedpass config get <key>`** Retrieve a configuration value such as `inactivity_timeout`, `secret_mode`, or `auto_sync`.
- **`seedpass config set <key> <value>`** Set a configuration value for the active profile.
- **`seedpass config set <key> <value>`** Update a configuration option. Example: `seedpass config set inactivity_timeout 300`.
- **`seedpass config toggle-secret-mode`** Interactively enable or disable Secret Mode and set the clipboard delay.
### `fingerprint` Commands
- **`seedpass fingerprint add`** Add a new seed profile (interactive or via `--import-seed`).
- **`seedpass fingerprint list`** List available profiles by fingerprint.
- **`seedpass fingerprint remove <FP>`** Delete a profile and its data after confirmation.
- **`seedpass fingerprint use <FP>`** Make the given fingerprint active in the current shell session.
- **`seedpass fingerprint add`** Create a new seed profile.
- **`seedpass fingerprint remove <fp>`** Delete the specified profile.
- **`seedpass fingerprint switch <fp>`** Switch the active profile.
### `util` Commands
- **`seedpass util generate-password`** Generate a strong password of the requested length.
- **`seedpass util verify-checksum`** Verify the program checksum for integrity.
- **`seedpass util verify-checksum`** Verify the SeedPass script checksum.
- **`seedpass util update-checksum`** Regenerate the script checksum file.
---
## Planned API Integration
## API Integration
The advanced CLI will act as a client for a locally hosted REST API. Starting the API loads the vault into memory after prompting for the master password and prints a temporary API key. Thirdparty clients include this key in the `Authorization` header when calling endpoints such as `GET /api/v1/entry?query=GitHub`. The server automatically shuts down after a period of inactivity or when `seedpass api stop` is run.
SeedPass provides a small REST API for automation. Run `seedpass api start` to launch the server. The command prints a onetime token which clients must include in the `Authorization` header.
Set the `SEEDPASS_CORS_ORIGINS` environment variable to a commaseparated list of allowed origins when you need crossorigin requests:
```bash
SEEDPASS_CORS_ORIGINS=http://localhost:3000 seedpass api start
```
Shut down the server with `seedpass api stop`.
---

137
docs/api_reference.md Normal file
View File

@@ -0,0 +1,137 @@
# SeedPass REST API Reference
This guide covers how to start the SeedPass API, authenticate requests, and interact with the available endpoints.
## Starting the API
Run `seedpass api start` from your terminal. The command prints a onetime token used for authentication:
```bash
$ seedpass api start
API token: abcdef1234567890
```
Keep this token secret. Every request must include it in the `Authorization` header using the `Bearer` scheme.
## Endpoints
- `GET /api/v1/entry?query=<text>` Search entries matching a query.
- `GET /api/v1/entry/{id}` Retrieve a single entry by its index.
- `POST /api/v1/entry` Create a new entry of any supported type.
- `PUT /api/v1/entry/{id}` Modify an existing entry.
- `PUT /api/v1/config/{key}` Update a configuration value.
- `POST /api/v1/secret-mode` Enable or disable Secret Mode and set the clipboard delay.
- `POST /api/v1/entry/{id}/archive` Archive an entry.
- `POST /api/v1/entry/{id}/unarchive` Unarchive an entry.
- `GET /api/v1/config/{key}` Return the value for a configuration key.
- `GET /api/v1/fingerprint` List available seed fingerprints.
- `POST /api/v1/fingerprint` Add a new seed fingerprint.
- `DELETE /api/v1/fingerprint/{fp}` Remove a fingerprint.
- `POST /api/v1/fingerprint/select` Switch the active fingerprint.
- `GET /api/v1/totp/export` Export all TOTP entries as JSON.
- `GET /api/v1/totp` Return current TOTP codes and remaining time.
- `GET /api/v1/stats` Return statistics about the active seed profile.
- `GET /api/v1/parent-seed` Reveal the parent seed or save it with `?file=`.
- `GET /api/v1/nostr/pubkey` Fetch the Nostr public key for the active seed.
- `POST /api/v1/checksum/verify` Verify the checksum of the running script.
- `POST /api/v1/checksum/update` Update the stored script checksum.
- `POST /api/v1/change-password` Change the master password for the active profile.
- `POST /api/v1/vault/import` Import a vault backup from a file or path.
- `POST /api/v1/vault/lock` Lock the vault and clear sensitive data from memory.
- `POST /api/v1/shutdown` Stop the server gracefully.
**Security Warning:** Accessing `/api/v1/parent-seed` exposes your master seed in plain text. Use it only from a trusted environment.
## Example Requests
Send requests with the token in the header:
```bash
curl -H "Authorization: Bearer <token>" \
"http://127.0.0.1:8000/api/v1/entry?query=email"
```
### Creating an Entry
`POST /api/v1/entry` accepts a JSON body with at least a `label` field. Set
`type` (or `kind`) to choose the entry variant (`password`, `totp`, `ssh`, `pgp`,
`nostr`, `seed`, `key_value`, or `managed_account`). Additional fields vary by
type:
- **password** `length`, optional `username`, `url` and `notes`
- **totp** `secret` or `index`, optional `period`, `digits`, `notes`, `archived`
- **ssh/nostr/seed/managed_account** `index`, optional `notes`, `archived`
- **pgp** `index`, `key_type`, `user_id`, optional `notes`, `archived`
- **key_value** `value`, optional `notes`
Example creating a TOTP entry:
```bash
curl -X POST http://127.0.0.1:8000/api/v1/entry \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"type": "totp", "label": "Email", "secret": "JBSW..."}'
```
### Updating an Entry
Use `PUT /api/v1/entry/{id}` to change fields such as `label`, `username`,
`url`, `notes`, `period`, `digits` or `value` depending on the entry type.
```bash
curl -X PUT http://127.0.0.1:8000/api/v1/entry/1 \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"username": "alice"}'
```
### Updating Configuration
Send a JSON body containing a `value` field to `PUT /api/v1/config/{key}`:
```bash
curl -X PUT http://127.0.0.1:8000/api/v1/config/inactivity_timeout \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"value": 300}'
```
### Toggling Secret Mode
Send both `enabled` and `delay` values to `/api/v1/secret-mode`:
```bash
curl -X POST http://127.0.0.1:8000/api/v1/secret-mode \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"enabled": true, "delay": 20}'
```
### Switching Fingerprints
Change the active seed profile via `POST /api/v1/fingerprint/select`:
```bash
curl -X POST http://127.0.0.1:8000/api/v1/fingerprint/select \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"fingerprint": "abc123"}'
```
### Enabling CORS
Crossorigin requests are disabled by default. Set `SEEDPASS_CORS_ORIGINS` to a commaseparated list of allowed origins before starting the API:
```bash
SEEDPASS_CORS_ORIGINS=http://localhost:3000 seedpass api start
```
Browsers can then call the API from the specified origins, for example using JavaScript:
```javascript
fetch('http://127.0.0.1:8000/api/v1/entry?query=email', {
headers: { Authorization: 'Bearer <token>' }
});
```
Without CORS enabled, only sameorigin or commandline tools like `curl` can access the API.

View File

@@ -1,3 +1,10 @@
[project]
name = "seedpass"
version = "0.1.0"
[project.scripts]
seedpass = "seedpass.cli:app"
[tool.mypy]
python_version = "3.11"
strict = true

View File

@@ -19,6 +19,7 @@ cryptography==45.0.4
ecdsa==0.19.1
ed25519-blake2b==1.4.1
execnet==2.1.1
fastapi==0.116.0
frozenlist==1.7.0
glob2==0.7
hypothesis==6.135.20
@@ -57,7 +58,10 @@ termcolor==3.1.0
toml==0.10.2
tomli==2.2.1
urllib3==2.5.0
uvicorn==0.35.0
httpx==0.28.1
varint==1.0.2
websocket-client==1.7.0
websockets==15.0.1
yarl==1.20.1
typer==0.12.3

View File

@@ -597,6 +597,35 @@ class EntryManager:
period = int(entry.get("period", 30))
return TotpManager.time_remaining(period)
def export_totp_entries(self, parent_seed: str) -> dict[str, list[dict[str, Any]]]:
"""Return all TOTP secrets and metadata for external use."""
data = self.vault.load_index()
entries = data.get("entries", {})
exported: list[dict[str, Any]] = []
for entry in entries.values():
etype = entry.get("type", entry.get("kind"))
if etype != EntryType.TOTP.value:
continue
label = entry.get("label", "")
period = int(entry.get("period", 30))
digits = int(entry.get("digits", 6))
if "secret" in entry:
secret = entry["secret"]
else:
idx = int(entry.get("index", 0))
secret = TotpManager.derive_secret(parent_seed, idx)
uri = TotpManager.make_otpauth_uri(label, secret, period, digits)
exported.append(
{
"label": label,
"secret": secret,
"period": period,
"digits": digits,
"uri": uri,
}
)
return {"entries": exported}
def get_encrypted_index(self) -> Optional[bytes]:
"""
Retrieves the encrypted password index file's contents.

View File

@@ -3309,9 +3309,15 @@ class PasswordManager:
print(colored(f"Error: Failed to export 2FA codes: {e}", "red"))
return None
def handle_backup_reveal_parent_seed(self) -> None:
"""
Handles the backup and reveal of the parent seed.
def handle_backup_reveal_parent_seed(self, file: Path | None = None) -> None:
"""Reveal the parent seed and optionally save an encrypted backup.
Parameters
----------
file:
Optional path where an encrypted backup should be written. When
provided, the confirmation and filename prompts are skipped and the
seed is saved directly to this location.
"""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
@@ -3360,24 +3366,26 @@ class PasswordManager:
)
)
# Option to save to file with default filename
if confirm_action(
"Do you want to save this to an encrypted backup file? (Y/N): "
):
filename = input(
f"Enter filename to save (default: {DEFAULT_SEED_BACKUP_FILENAME}): "
).strip()
filename = filename if filename else DEFAULT_SEED_BACKUP_FILENAME
backup_path = (
self.fingerprint_dir / filename
) # Save in fingerprint directory
backup_path: Path | None = None
if file is not None:
backup_path = file
save = True
else:
save = confirm_action(
"Do you want to save this to an encrypted backup file? (Y/N): "
)
if save:
filename = input(
f"Enter filename to save (default: {DEFAULT_SEED_BACKUP_FILENAME}): "
).strip()
filename = filename if filename else DEFAULT_SEED_BACKUP_FILENAME
backup_path = self.fingerprint_dir / filename
# Validate filename
if not self.is_valid_filename(filename):
if save and backup_path is not None:
if not self.is_valid_filename(backup_path.name):
print(colored("Invalid filename. Operation aborted.", "red"))
return
# Encrypt and save the parent seed to the backup path
self.encryption_manager.encrypt_and_save_file(
self.parent_seed.encode("utf-8"), backup_path
)

View File

@@ -24,3 +24,9 @@ pyotp>=2.8.0
freezegun
pyperclip
qrcode>=8.2
typer>=0.12.3
fastapi>=0.116.0
uvicorn>=0.35.0
httpx>=0.28.1
requests>=2.32
python-multipart

0
src/seedpass/__init__.py Normal file
View File

457
src/seedpass/api.py Normal file
View File

@@ -0,0 +1,457 @@
"""SeedPass FastAPI server."""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
import secrets
from typing import Any, List, Optional
from fastapi import FastAPI, Header, HTTPException, Request
import asyncio
import sys
from fastapi.middleware.cors import CORSMiddleware
from password_manager.manager import PasswordManager
from password_manager.entry_types import EntryType
app = FastAPI()
_pm: Optional[PasswordManager] = None
_token: str = ""
def _check_token(auth: str | None) -> None:
if auth != f"Bearer {_token}":
raise HTTPException(status_code=401, detail="Unauthorized")
def start_server(fingerprint: str | None = None) -> str:
"""Initialize global state and return the API token.
Parameters
----------
fingerprint:
Optional seed profile fingerprint to select before starting the server.
"""
global _pm, _token
_pm = PasswordManager()
if fingerprint:
_pm.select_fingerprint(fingerprint)
_token = secrets.token_urlsafe(16)
print(f"API token: {_token}")
origins = [
o.strip()
for o in os.getenv("SEEDPASS_CORS_ORIGINS", "").split(",")
if o.strip()
]
if origins and app.middleware_stack is None:
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_methods=["*"],
allow_headers=["*"],
)
return _token
@app.get("/api/v1/entry")
def search_entry(query: str, authorization: str | None = Header(None)) -> List[Any]:
_check_token(authorization)
assert _pm is not None
results = _pm.entry_manager.search_entries(query)
return [
{
"id": idx,
"label": label,
"username": username,
"url": url,
"archived": archived,
}
for idx, label, username, url, archived in results
]
@app.get("/api/v1/entry/{entry_id}")
def get_entry(entry_id: int, authorization: str | None = Header(None)) -> Any:
_check_token(authorization)
assert _pm is not None
entry = _pm.entry_manager.retrieve_entry(entry_id)
if entry is None:
raise HTTPException(status_code=404, detail="Not found")
return entry
@app.post("/api/v1/entry")
def create_entry(
entry: dict,
authorization: str | None = Header(None),
) -> dict[str, Any]:
"""Create a new entry.
If ``entry['type']`` or ``entry['kind']`` specifies ``totp``, ``ssh`` and so
on, the corresponding entry type is created. When omitted or set to
``password`` the behaviour matches the legacy password-entry API.
"""
_check_token(authorization)
assert _pm is not None
etype = (entry.get("type") or entry.get("kind") or "password").lower()
if etype == "password":
index = _pm.entry_manager.add_entry(
entry.get("label"),
int(entry.get("length", 12)),
entry.get("username"),
entry.get("url"),
)
return {"id": index}
if etype == "totp":
index = _pm.entry_manager.get_next_index()
uri = _pm.entry_manager.add_totp(
entry.get("label"),
_pm.parent_seed,
secret=entry.get("secret"),
index=entry.get("index"),
period=int(entry.get("period", 30)),
digits=int(entry.get("digits", 6)),
notes=entry.get("notes", ""),
archived=entry.get("archived", False),
)
return {"id": index, "uri": uri}
if etype == "ssh":
index = _pm.entry_manager.add_ssh_key(
entry.get("label"),
_pm.parent_seed,
index=entry.get("index"),
notes=entry.get("notes", ""),
archived=entry.get("archived", False),
)
return {"id": index}
if etype == "pgp":
index = _pm.entry_manager.add_pgp_key(
entry.get("label"),
_pm.parent_seed,
index=entry.get("index"),
key_type=entry.get("key_type", "ed25519"),
user_id=entry.get("user_id", ""),
notes=entry.get("notes", ""),
archived=entry.get("archived", False),
)
return {"id": index}
if etype == "nostr":
index = _pm.entry_manager.add_nostr_key(
entry.get("label"),
index=entry.get("index"),
notes=entry.get("notes", ""),
archived=entry.get("archived", False),
)
return {"id": index}
if etype == "key_value":
index = _pm.entry_manager.add_key_value(
entry.get("label"),
entry.get("value"),
notes=entry.get("notes", ""),
)
return {"id": index}
if etype in {"seed", "managed_account"}:
func = (
_pm.entry_manager.add_seed
if etype == "seed"
else _pm.entry_manager.add_managed_account
)
index = func(
entry.get("label"),
_pm.parent_seed,
index=entry.get("index"),
notes=entry.get("notes", ""),
)
return {"id": index}
raise HTTPException(status_code=400, detail="Unsupported entry type")
@app.put("/api/v1/entry/{entry_id}")
def update_entry(
entry_id: int,
entry: dict,
authorization: str | None = Header(None),
) -> dict[str, str]:
"""Update an existing entry.
Additional fields like ``period``, ``digits`` and ``value`` are forwarded for
specialized entry types (e.g. TOTP or key/value entries).
"""
_check_token(authorization)
assert _pm is not None
_pm.entry_manager.modify_entry(
entry_id,
username=entry.get("username"),
url=entry.get("url"),
notes=entry.get("notes"),
label=entry.get("label"),
period=entry.get("period"),
digits=entry.get("digits"),
value=entry.get("value"),
)
return {"status": "ok"}
@app.post("/api/v1/entry/{entry_id}/archive")
def archive_entry(
entry_id: int, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Archive an entry."""
_check_token(authorization)
assert _pm is not None
_pm.entry_manager.archive_entry(entry_id)
return {"status": "archived"}
@app.post("/api/v1/entry/{entry_id}/unarchive")
def unarchive_entry(
entry_id: int, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Restore an archived entry."""
_check_token(authorization)
assert _pm is not None
_pm.entry_manager.restore_entry(entry_id)
return {"status": "active"}
@app.get("/api/v1/config/{key}")
def get_config(key: str, authorization: str | None = Header(None)) -> Any:
_check_token(authorization)
assert _pm is not None
value = _pm.config_manager.load_config(require_pin=False).get(key)
if value is None:
raise HTTPException(status_code=404, detail="Not found")
return {"key": key, "value": value}
@app.put("/api/v1/config/{key}")
def update_config(
key: str, data: dict, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Update a configuration setting."""
_check_token(authorization)
assert _pm is not None
cfg = _pm.config_manager
mapping = {
"relays": lambda v: cfg.set_relays(v, require_pin=False),
"pin": cfg.set_pin,
"password_hash": cfg.set_password_hash,
"inactivity_timeout": lambda v: cfg.set_inactivity_timeout(float(v)),
"additional_backup_path": cfg.set_additional_backup_path,
"secret_mode_enabled": cfg.set_secret_mode_enabled,
"clipboard_clear_delay": lambda v: cfg.set_clipboard_clear_delay(int(v)),
}
action = mapping.get(key)
if action is None:
raise HTTPException(status_code=400, detail="Unknown key")
if "value" not in data:
raise HTTPException(status_code=400, detail="Missing value")
action(data["value"])
return {"status": "ok"}
@app.post("/api/v1/secret-mode")
def set_secret_mode(
data: dict, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Enable/disable secret mode and set the clipboard delay."""
_check_token(authorization)
assert _pm is not None
enabled = data.get("enabled")
delay = data.get("delay")
if enabled is None or delay is None:
raise HTTPException(status_code=400, detail="Missing fields")
cfg = _pm.config_manager
cfg.set_secret_mode_enabled(bool(enabled))
cfg.set_clipboard_clear_delay(int(delay))
_pm.secret_mode_enabled = bool(enabled)
_pm.clipboard_clear_delay = int(delay)
return {"status": "ok"}
@app.get("/api/v1/fingerprint")
def list_fingerprints(authorization: str | None = Header(None)) -> List[str]:
_check_token(authorization)
assert _pm is not None
return _pm.fingerprint_manager.list_fingerprints()
@app.post("/api/v1/fingerprint")
def add_fingerprint(authorization: str | None = Header(None)) -> dict[str, str]:
"""Create a new seed profile."""
_check_token(authorization)
assert _pm is not None
_pm.add_new_fingerprint()
return {"status": "ok"}
@app.delete("/api/v1/fingerprint/{fingerprint}")
def remove_fingerprint(
fingerprint: str, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Remove a seed profile."""
_check_token(authorization)
assert _pm is not None
_pm.fingerprint_manager.remove_fingerprint(fingerprint)
return {"status": "deleted"}
@app.post("/api/v1/fingerprint/select")
def select_fingerprint(
data: dict, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Switch the active seed profile."""
_check_token(authorization)
assert _pm is not None
fp = data.get("fingerprint")
if not fp:
raise HTTPException(status_code=400, detail="Missing fingerprint")
_pm.select_fingerprint(fp)
return {"status": "ok"}
@app.get("/api/v1/totp/export")
def export_totp(authorization: str | None = Header(None)) -> dict:
"""Return all stored TOTP entries in JSON format."""
_check_token(authorization)
assert _pm is not None
return _pm.entry_manager.export_totp_entries(_pm.parent_seed)
@app.get("/api/v1/totp")
def get_totp_codes(authorization: str | None = Header(None)) -> dict:
"""Return active TOTP codes with remaining seconds."""
_check_token(authorization)
assert _pm is not None
entries = _pm.entry_manager.list_entries(
filter_kind=EntryType.TOTP.value, include_archived=False
)
codes = []
for idx, label, _u, _url, _arch in entries:
code = _pm.entry_manager.get_totp_code(idx, _pm.parent_seed)
rem = _pm.entry_manager.get_totp_time_remaining(idx)
codes.append(
{"id": idx, "label": label, "code": code, "seconds_remaining": rem}
)
return {"codes": codes}
@app.get("/api/v1/stats")
def get_profile_stats(authorization: str | None = Header(None)) -> dict:
"""Return statistics about the active seed profile."""
_check_token(authorization)
assert _pm is not None
return _pm.get_profile_stats()
@app.get("/api/v1/parent-seed")
def get_parent_seed(
authorization: str | None = Header(None), file: str | None = None
) -> dict:
"""Return the parent seed or save it as an encrypted backup."""
_check_token(authorization)
assert _pm is not None
if file:
path = Path(file)
_pm.encryption_manager.encrypt_and_save_file(
_pm.parent_seed.encode("utf-8"), path
)
return {"status": "saved", "path": str(path)}
return {"seed": _pm.parent_seed}
@app.get("/api/v1/nostr/pubkey")
def get_nostr_pubkey(authorization: str | None = Header(None)) -> Any:
_check_token(authorization)
assert _pm is not None
return {"npub": _pm.nostr_client.key_manager.get_npub()}
@app.post("/api/v1/checksum/verify")
def verify_checksum(authorization: str | None = Header(None)) -> dict[str, str]:
"""Verify the SeedPass script checksum."""
_check_token(authorization)
assert _pm is not None
_pm.handle_verify_checksum()
return {"status": "ok"}
@app.post("/api/v1/checksum/update")
def update_checksum(authorization: str | None = Header(None)) -> dict[str, str]:
"""Regenerate the script checksum file."""
_check_token(authorization)
assert _pm is not None
_pm.handle_update_script_checksum()
return {"status": "ok"}
@app.post("/api/v1/vault/import")
async def import_vault(
request: Request, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Import a vault backup from a file upload or a server path."""
_check_token(authorization)
assert _pm is not None
ctype = request.headers.get("content-type", "")
if ctype.startswith("multipart/form-data"):
form = await request.form()
file = form.get("file")
if file is None:
raise HTTPException(status_code=400, detail="Missing file")
data = await file.read()
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp.write(data)
tmp_path = Path(tmp.name)
try:
_pm.handle_import_database(tmp_path)
finally:
os.unlink(tmp_path)
else:
body = await request.json()
path = body.get("path")
if not path:
raise HTTPException(status_code=400, detail="Missing file or path")
_pm.handle_import_database(Path(path))
return {"status": "ok"}
@app.post("/api/v1/change-password")
def change_password(authorization: str | None = Header(None)) -> dict[str, str]:
"""Change the master password for the active profile."""
_check_token(authorization)
assert _pm is not None
_pm.change_password()
return {"status": "ok"}
@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."""
_check_token(authorization)
assert _pm is not None
_pm.lock_vault()
return {"status": "locked"}
@app.post("/api/v1/shutdown")
async def shutdown_server(authorization: str | None = Header(None)) -> dict[str, str]:
_check_token(authorization)
asyncio.get_event_loop().call_soon(sys.exit, 0)
return {"status": "shutting down"}

575
src/seedpass/cli.py Normal file
View File

@@ -0,0 +1,575 @@
from pathlib import Path
from typing import Optional
import json
import typer
from password_manager.manager import PasswordManager
from password_manager.entry_types import EntryType
import uvicorn
from . import api as api_module
app = typer.Typer(help="SeedPass command line interface")
# Global option shared across all commands
fingerprint_option = typer.Option(
None,
"--fingerprint",
"-f",
help="Specify which seed profile to use",
)
# Sub command groups
entry_app = typer.Typer(help="Manage individual entries")
vault_app = typer.Typer(help="Manage the entire vault")
nostr_app = typer.Typer(help="Interact with Nostr relays")
config_app = typer.Typer(help="Get or set configuration values")
fingerprint_app = typer.Typer(help="Manage seed profiles")
util_app = typer.Typer(help="Utility commands")
api_app = typer.Typer(help="Run the API server")
app.add_typer(entry_app, name="entry")
app.add_typer(vault_app, name="vault")
app.add_typer(nostr_app, name="nostr")
app.add_typer(config_app, name="config")
app.add_typer(fingerprint_app, name="fingerprint")
app.add_typer(util_app, name="util")
app.add_typer(api_app, name="api")
def _get_pm(ctx: typer.Context) -> PasswordManager:
"""Return a PasswordManager optionally selecting a fingerprint."""
pm = PasswordManager()
fp = ctx.obj.get("fingerprint")
if fp:
# `select_fingerprint` will initialize managers
pm.select_fingerprint(fp)
return pm
@app.callback()
def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None:
"""SeedPass CLI entry point."""
ctx.obj = {"fingerprint": fingerprint}
@entry_app.command("list")
def entry_list(
ctx: typer.Context,
sort: str = typer.Option(
"index", "--sort", help="Sort by 'index', 'label', or 'username'"
),
kind: Optional[str] = typer.Option(None, "--kind", help="Filter by entry type"),
archived: bool = typer.Option(False, "--archived", help="Include archived"),
) -> None:
"""List entries in the vault."""
pm = _get_pm(ctx)
entries = pm.entry_manager.list_entries(
sort_by=sort, filter_kind=kind, include_archived=archived
)
for idx, label, username, url, is_archived in entries:
line = f"{idx}: {label}"
if username:
line += f" ({username})"
if url:
line += f" {url}"
if is_archived:
line += " [archived]"
typer.echo(line)
@entry_app.command("search")
def entry_search(ctx: typer.Context, query: str) -> None:
"""Search entries."""
pm = _get_pm(ctx)
results = pm.entry_manager.search_entries(query)
if not results:
typer.echo("No matching entries found")
return
for idx, label, username, url, _arch in results:
line = f"{idx}: {label}"
if username:
line += f" ({username})"
if url:
line += f" {url}"
typer.echo(line)
@entry_app.command("get")
def entry_get(ctx: typer.Context, query: str) -> None:
"""Retrieve a single entry's secret."""
pm = _get_pm(ctx)
matches = pm.entry_manager.search_entries(query)
if len(matches) == 0:
typer.echo("No matching entries found")
raise typer.Exit(code=1)
if len(matches) > 1:
typer.echo("Matches:")
for idx, label, username, _url, _arch in matches:
name = f"{idx}: {label}"
if username:
name += f" ({username})"
typer.echo(name)
raise typer.Exit(code=1)
index = matches[0][0]
entry = pm.entry_manager.retrieve_entry(index)
etype = entry.get("type", entry.get("kind"))
if etype == EntryType.PASSWORD.value:
length = int(entry.get("length", 12))
password = pm.password_generator.generate_password(length, index)
typer.echo(password)
elif etype == EntryType.TOTP.value:
code = pm.entry_manager.get_totp_code(index, pm.parent_seed)
typer.echo(code)
else:
typer.echo("Unsupported entry type")
raise typer.Exit(code=1)
@entry_app.command("add")
def entry_add(
ctx: typer.Context,
label: str,
length: int = typer.Option(12, "--length"),
username: Optional[str] = typer.Option(None, "--username"),
url: Optional[str] = typer.Option(None, "--url"),
) -> None:
"""Add a new password entry and output its index."""
pm = _get_pm(ctx)
index = pm.entry_manager.add_entry(label, length, username, url)
typer.echo(str(index))
@entry_app.command("add-totp")
def entry_add_totp(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
secret: Optional[str] = typer.Option(None, "--secret", help="Import secret"),
period: int = typer.Option(30, "--period", help="TOTP period in seconds"),
digits: int = typer.Option(6, "--digits", help="Number of TOTP digits"),
) -> None:
"""Add a TOTP entry and output the otpauth URI."""
pm = _get_pm(ctx)
uri = pm.entry_manager.add_totp(
label,
pm.parent_seed,
index=index,
secret=secret,
period=period,
digits=digits,
)
typer.echo(uri)
@entry_app.command("add-ssh")
def entry_add_ssh(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add an SSH key entry and output its index."""
pm = _get_pm(ctx)
idx = pm.entry_manager.add_ssh_key(
label,
pm.parent_seed,
index=index,
notes=notes,
)
typer.echo(str(idx))
@entry_app.command("add-pgp")
def entry_add_pgp(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
key_type: str = typer.Option("ed25519", "--key-type", help="Key type"),
user_id: str = typer.Option("", "--user-id", help="User ID"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a PGP key entry and output its index."""
pm = _get_pm(ctx)
idx = pm.entry_manager.add_pgp_key(
label,
pm.parent_seed,
index=index,
key_type=key_type,
user_id=user_id,
notes=notes,
)
typer.echo(str(idx))
@entry_app.command("add-nostr")
def entry_add_nostr(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a Nostr key entry and output its index."""
pm = _get_pm(ctx)
idx = pm.entry_manager.add_nostr_key(
label,
index=index,
notes=notes,
)
typer.echo(str(idx))
@entry_app.command("add-seed")
def entry_add_seed(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
words: int = typer.Option(24, "--words", help="Word count"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a derived seed phrase entry and output its index."""
pm = _get_pm(ctx)
idx = pm.entry_manager.add_seed(
label,
pm.parent_seed,
index=index,
words_num=words,
notes=notes,
)
typer.echo(str(idx))
@entry_app.command("add-key-value")
def entry_add_key_value(
ctx: typer.Context,
label: str,
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."""
pm = _get_pm(ctx)
idx = pm.entry_manager.add_key_value(label, value, notes=notes)
typer.echo(str(idx))
@entry_app.command("add-managed-account")
def entry_add_managed_account(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a managed account seed entry and output its index."""
pm = _get_pm(ctx)
idx = pm.entry_manager.add_managed_account(
label,
pm.parent_seed,
index=index,
notes=notes,
)
typer.echo(str(idx))
@entry_app.command("modify")
def entry_modify(
ctx: typer.Context,
entry_id: int,
label: Optional[str] = typer.Option(None, "--label"),
username: Optional[str] = typer.Option(None, "--username"),
url: Optional[str] = typer.Option(None, "--url"),
notes: Optional[str] = typer.Option(None, "--notes"),
period: Optional[int] = typer.Option(
None, "--period", help="TOTP period in seconds"
),
digits: Optional[int] = typer.Option(None, "--digits", help="TOTP digits"),
value: Optional[str] = typer.Option(None, "--value", help="New value"),
) -> None:
"""Modify an existing entry."""
pm = _get_pm(ctx)
pm.entry_manager.modify_entry(
entry_id,
username=username,
url=url,
notes=notes,
label=label,
period=period,
digits=digits,
value=value,
)
@entry_app.command("archive")
def entry_archive(ctx: typer.Context, entry_id: int) -> None:
"""Archive an entry."""
pm = _get_pm(ctx)
pm.entry_manager.archive_entry(entry_id)
typer.echo(str(entry_id))
@entry_app.command("unarchive")
def entry_unarchive(ctx: typer.Context, entry_id: int) -> None:
"""Restore an archived entry."""
pm = _get_pm(ctx)
pm.entry_manager.restore_entry(entry_id)
typer.echo(str(entry_id))
@entry_app.command("totp-codes")
def entry_totp_codes(ctx: typer.Context) -> None:
"""Display all current TOTP codes."""
pm = _get_pm(ctx)
pm.handle_display_totp_codes()
@entry_app.command("export-totp")
def entry_export_totp(
ctx: typer.Context, file: str = typer.Option(..., help="Output file")
) -> None:
"""Export all TOTP secrets to a JSON file."""
pm = _get_pm(ctx)
data = pm.entry_manager.export_totp_entries(pm.parent_seed)
Path(file).write_text(json.dumps(data, indent=2))
typer.echo(str(file))
@vault_app.command("export")
def vault_export(
ctx: typer.Context, file: str = typer.Option(..., help="Output file")
) -> None:
"""Export the vault."""
pm = _get_pm(ctx)
pm.handle_export_database(Path(file))
typer.echo(str(file))
@vault_app.command("import")
def vault_import(
ctx: typer.Context, file: str = typer.Option(..., help="Input file")
) -> None:
"""Import a vault from an encrypted JSON file."""
pm = _get_pm(ctx)
pm.handle_import_database(Path(file))
typer.echo(str(file))
@vault_app.command("change-password")
def vault_change_password(ctx: typer.Context) -> None:
"""Change the master password used for encryption."""
pm = _get_pm(ctx)
pm.change_password()
@vault_app.command("lock")
def vault_lock(ctx: typer.Context) -> None:
"""Lock the vault and clear sensitive data from memory."""
pm = _get_pm(ctx)
pm.lock_vault()
typer.echo("locked")
@vault_app.command("stats")
def vault_stats(ctx: typer.Context) -> None:
"""Display statistics about the current seed profile."""
pm = _get_pm(ctx)
stats = pm.get_profile_stats()
typer.echo(json.dumps(stats, indent=2))
@vault_app.command("reveal-parent-seed")
def vault_reveal_parent_seed(
ctx: typer.Context,
file: Optional[str] = typer.Option(
None, "--file", help="Save encrypted seed to this path"
),
) -> None:
"""Display the parent seed and optionally write an encrypted backup file."""
pm = _get_pm(ctx)
pm.handle_backup_reveal_parent_seed(Path(file) if file else None)
@nostr_app.command("sync")
def nostr_sync(ctx: typer.Context) -> None:
"""Sync with configured Nostr relays."""
pm = _get_pm(ctx)
event_id = pm.sync_vault()
if event_id:
typer.echo(event_id)
else:
typer.echo("Error: Failed to sync vault")
@nostr_app.command("get-pubkey")
def nostr_get_pubkey(ctx: typer.Context) -> None:
"""Display the active profile's npub."""
pm = _get_pm(ctx)
npub = pm.nostr_client.key_manager.get_npub()
typer.echo(npub)
@config_app.command("get")
def config_get(ctx: typer.Context, key: str) -> None:
"""Get a configuration value."""
pm = _get_pm(ctx)
value = pm.config_manager.load_config(require_pin=False).get(key)
if value is None:
typer.echo("Key not found")
else:
typer.echo(str(value))
@config_app.command("set")
def config_set(ctx: typer.Context, key: str, value: str) -> None:
"""Set a configuration value."""
pm = _get_pm(ctx)
cfg = pm.config_manager
mapping = {
"inactivity_timeout": lambda v: cfg.set_inactivity_timeout(float(v)),
"secret_mode_enabled": lambda v: cfg.set_secret_mode_enabled(
v.lower() in ("1", "true", "yes", "y", "on")
),
"clipboard_clear_delay": lambda v: cfg.set_clipboard_clear_delay(int(v)),
"additional_backup_path": lambda v: cfg.set_additional_backup_path(v or None),
"relays": lambda v: cfg.set_relays(
[r.strip() for r in v.split(",") if r.strip()], require_pin=False
),
}
action = mapping.get(key)
if action is None:
typer.echo("Unknown key")
raise typer.Exit(code=1)
try:
action(value)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo("Updated")
@config_app.command("toggle-secret-mode")
def config_toggle_secret_mode(ctx: typer.Context) -> None:
"""Interactively enable or disable secret mode."""
pm = _get_pm(ctx)
cfg = pm.config_manager
try:
enabled = cfg.get_secret_mode_enabled()
delay = cfg.get_clipboard_clear_delay()
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error loading settings: {exc}")
raise typer.Exit(code=1)
typer.echo(f"Secret mode is currently {'ON' if enabled else 'OFF'}")
choice = (
typer.prompt(
"Enable secret mode? (y/n, blank to keep)", default="", show_default=False
)
.strip()
.lower()
)
if choice in ("y", "yes"):
enabled = True
elif choice in ("n", "no"):
enabled = False
inp = typer.prompt(
f"Clipboard clear delay in seconds [{delay}]", default="", show_default=False
).strip()
if inp:
try:
delay = int(inp)
if delay <= 0:
typer.echo("Delay must be positive")
raise typer.Exit(code=1)
except ValueError:
typer.echo("Invalid number")
raise typer.Exit(code=1)
try:
cfg.set_secret_mode_enabled(enabled)
cfg.set_clipboard_clear_delay(delay)
pm.secret_mode_enabled = enabled
pm.clipboard_clear_delay = delay
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
status = "enabled" if enabled else "disabled"
typer.echo(f"Secret mode {status}.")
@fingerprint_app.command("list")
def fingerprint_list(ctx: typer.Context) -> None:
"""List available seed profiles."""
pm = _get_pm(ctx)
for fp in pm.fingerprint_manager.list_fingerprints():
typer.echo(fp)
@fingerprint_app.command("add")
def fingerprint_add(ctx: typer.Context) -> None:
"""Create a new seed profile."""
pm = _get_pm(ctx)
pm.add_new_fingerprint()
@fingerprint_app.command("remove")
def fingerprint_remove(ctx: typer.Context, fingerprint: str) -> None:
"""Remove a seed profile."""
pm = _get_pm(ctx)
pm.fingerprint_manager.remove_fingerprint(fingerprint)
@fingerprint_app.command("switch")
def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None:
"""Switch to another seed profile."""
pm = _get_pm(ctx)
pm.select_fingerprint(fingerprint)
@util_app.command("generate-password")
def generate_password(ctx: typer.Context, length: int = 24) -> None:
"""Generate a strong password."""
pm = _get_pm(ctx)
password = pm.password_generator.generate_password(length)
typer.echo(password)
@util_app.command("verify-checksum")
def verify_checksum(ctx: typer.Context) -> None:
"""Verify the SeedPass script checksum."""
pm = _get_pm(ctx)
pm.handle_verify_checksum()
@util_app.command("update-checksum")
def update_checksum(ctx: typer.Context) -> None:
"""Regenerate the script checksum file."""
pm = _get_pm(ctx)
pm.handle_update_script_checksum()
@api_app.command("start")
def api_start(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> None:
"""Start the SeedPass API server."""
token = api_module.start_server(ctx.obj.get("fingerprint"))
typer.echo(f"API token: {token}")
uvicorn.run(api_module.app, host=host, port=port)
@api_app.command("stop")
def api_stop(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> None:
"""Stop the SeedPass API server."""
import requests
try:
requests.post(
f"http://{host}:{port}/api/v1/shutdown",
headers={"Authorization": f"Bearer {api_module._token}"},
timeout=2,
)
except Exception as exc: # pragma: no cover - best effort
typer.echo(f"Failed to stop server: {exc}")

233
src/tests/test_api.py Normal file
View File

@@ -0,0 +1,233 @@
from types import SimpleNamespace
from pathlib import Path
import sys
import pytest
from fastapi.testclient import TestClient
sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass import api
@pytest.fixture
def client(monkeypatch):
dummy = SimpleNamespace(
entry_manager=SimpleNamespace(
search_entries=lambda q: [(1, "Site", "user", "url", False)],
retrieve_entry=lambda i: {"label": "Site"},
add_entry=lambda *a, **k: 1,
modify_entry=lambda *a, **k: None,
archive_entry=lambda i: None,
restore_entry=lambda i: None,
),
config_manager=SimpleNamespace(
load_config=lambda require_pin=False: {"k": "v"},
set_pin=lambda v: None,
set_password_hash=lambda v: None,
set_relays=lambda v, require_pin=False: None,
set_inactivity_timeout=lambda v: None,
set_additional_backup_path=lambda v: None,
set_secret_mode_enabled=lambda v: None,
set_clipboard_clear_delay=lambda v: None,
),
fingerprint_manager=SimpleNamespace(list_fingerprints=lambda: ["fp"]),
nostr_client=SimpleNamespace(
key_manager=SimpleNamespace(get_npub=lambda: "np")
),
)
monkeypatch.setattr(api, "PasswordManager", lambda: dummy)
monkeypatch.setenv("SEEDPASS_CORS_ORIGINS", "http://example.com")
token = api.start_server()
client = TestClient(api.app)
return client, token
def test_cors_and_auth(client):
cl, token = client
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
res = cl.get("/api/v1/entry", params={"query": "s"}, headers=headers)
assert res.status_code == 200
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_invalid_token(client):
cl, _token = client
res = cl.get(
"/api/v1/entry",
params={"query": "s"},
headers={"Authorization": "Bearer bad"},
)
assert res.status_code == 401
def test_get_entry_by_id(client):
cl, token = client
headers = {
"Authorization": f"Bearer {token}",
"Origin": "http://example.com",
}
res = cl.get("/api/v1/entry/1", headers=headers)
assert res.status_code == 200
assert res.json() == {"label": "Site"}
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_get_config_value(client):
cl, token = client
headers = {
"Authorization": f"Bearer {token}",
"Origin": "http://example.com",
}
res = cl.get("/api/v1/config/k", headers=headers)
assert res.status_code == 200
assert res.json() == {"key": "k", "value": "v"}
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_list_fingerprint(client):
cl, token = client
headers = {
"Authorization": f"Bearer {token}",
"Origin": "http://example.com",
}
res = cl.get("/api/v1/fingerprint", headers=headers)
assert res.status_code == 200
assert res.json() == ["fp"]
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_get_nostr_pubkey(client):
cl, token = client
headers = {
"Authorization": f"Bearer {token}",
"Origin": "http://example.com",
}
res = cl.get("/api/v1/nostr/pubkey", headers=headers)
assert res.status_code == 200
assert res.json() == {"npub": "np"}
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_create_modify_archive_entry(client):
cl, token = client
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
res = cl.post(
"/api/v1/entry",
json={"label": "test", "length": 12},
headers=headers,
)
assert res.status_code == 200
assert res.json() == {"id": 1}
res = cl.put(
"/api/v1/entry/1",
json={"username": "bob"},
headers=headers,
)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
res = cl.post("/api/v1/entry/1/archive", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "archived"}
res = cl.post("/api/v1/entry/1/unarchive", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "active"}
def test_update_config(client):
cl, token = client
called = {}
def set_timeout(val):
called["val"] = val
api._pm.config_manager.set_inactivity_timeout = set_timeout
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
res = cl.put(
"/api/v1/config/inactivity_timeout",
json={"value": 42},
headers=headers,
)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert called["val"] == 42
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_change_password_route(client):
cl, token = client
called = {}
api._pm.change_password = lambda: called.setdefault("called", True)
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
res = cl.post("/api/v1/change-password", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert called.get("called") is True
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_update_config_unknown_key(client):
cl, token = client
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
res = cl.put(
"/api/v1/config/bogus",
json={"value": 1},
headers=headers,
)
assert res.status_code == 400
def test_shutdown(client, monkeypatch):
cl, token = client
calls = {}
class Loop:
def call_soon(self, func, *args):
calls["func"] = func
calls["args"] = args
monkeypatch.setattr(api.asyncio, "get_event_loop", lambda: Loop())
headers = {
"Authorization": f"Bearer {token}",
"Origin": "http://example.com",
}
res = cl.post("/api/v1/shutdown", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "shutting down"}
assert calls["func"] is sys.exit
assert calls["args"] == (0,)
assert res.headers.get("access-control-allow-origin") == "http://example.com"
@pytest.mark.parametrize(
"method,path",
[
("get", "/api/v1/entry/1"),
("get", "/api/v1/config/k"),
("get", "/api/v1/fingerprint"),
("get", "/api/v1/nostr/pubkey"),
("post", "/api/v1/shutdown"),
("post", "/api/v1/entry"),
("put", "/api/v1/entry/1"),
("put", "/api/v1/config/inactivity_timeout"),
("post", "/api/v1/entry/1/archive"),
("post", "/api/v1/entry/1/unarchive"),
("post", "/api/v1/change-password"),
("post", "/api/v1/vault/lock"),
],
)
def test_invalid_token_other_endpoints(client, method, path):
cl, _token = client
req = getattr(cl, method)
kwargs = {"headers": {"Authorization": "Bearer bad"}}
if method in {"post", "put"}:
kwargs["json"] = {}
res = req(path, **kwargs)
assert res.status_code == 401

View File

@@ -0,0 +1,302 @@
from types import SimpleNamespace
from pathlib import Path
import pytest
from seedpass import api
from test_api import client
def test_create_and_modify_totp_entry(client):
cl, token = client
calls = {}
def add_totp(label, seed, **kwargs):
calls["create"] = kwargs
return "uri"
def modify(idx, **kwargs):
calls["modify"] = (idx, kwargs)
api._pm.entry_manager.add_totp = add_totp
api._pm.entry_manager.modify_entry = modify
api._pm.entry_manager.get_next_index = lambda: 5
api._pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}"}
res = cl.post(
"/api/v1/entry",
json={
"type": "totp",
"label": "T",
"index": 1,
"secret": "abc",
"period": 60,
"digits": 8,
"notes": "n",
},
headers=headers,
)
assert res.status_code == 200
assert res.json() == {"id": 5, "uri": "uri"}
assert calls["create"] == {
"index": 1,
"secret": "abc",
"period": 60,
"digits": 8,
"notes": "n",
"archived": False,
}
res = cl.put(
"/api/v1/entry/5",
json={"period": 90, "digits": 6},
headers=headers,
)
assert res.status_code == 200
assert calls["modify"][0] == 5
assert calls["modify"][1]["period"] == 90
assert calls["modify"][1]["digits"] == 6
def test_create_and_modify_ssh_entry(client):
cl, token = client
calls = {}
def add_ssh(label, seed, **kwargs):
calls["create"] = kwargs
return 2
def modify(idx, **kwargs):
calls["modify"] = (idx, kwargs)
api._pm.entry_manager.add_ssh_key = add_ssh
api._pm.entry_manager.modify_entry = modify
api._pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}"}
res = cl.post(
"/api/v1/entry",
json={"type": "ssh", "label": "S", "index": 2, "notes": "n"},
headers=headers,
)
assert res.status_code == 200
assert res.json() == {"id": 2}
assert calls["create"] == {"index": 2, "notes": "n", "archived": False}
res = cl.put(
"/api/v1/entry/2",
json={"notes": "x"},
headers=headers,
)
assert res.status_code == 200
assert calls["modify"][0] == 2
assert calls["modify"][1]["notes"] == "x"
def test_update_config_secret_mode(client):
cl, token = client
called = {}
def set_secret(val):
called["val"] = val
api._pm.config_manager.set_secret_mode_enabled = set_secret
headers = {"Authorization": f"Bearer {token}"}
res = cl.put(
"/api/v1/config/secret_mode_enabled",
json={"value": True},
headers=headers,
)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert called["val"] is True
def test_totp_export_endpoint(client):
cl, token = client
api._pm.entry_manager.export_totp_entries = lambda seed: {"entries": ["x"]}
api._pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}"}
res = cl.get("/api/v1/totp/export", headers=headers)
assert res.status_code == 200
assert res.json() == {"entries": ["x"]}
def test_totp_codes_endpoint(client):
cl, token = client
api._pm.entry_manager.list_entries = lambda **kw: [(0, "Email", None, None, False)]
api._pm.entry_manager.get_totp_code = lambda i, s: "123456"
api._pm.entry_manager.get_totp_time_remaining = lambda i: 30
api._pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}"}
res = cl.get("/api/v1/totp", headers=headers)
assert res.status_code == 200
assert res.json() == {
"codes": [
{"id": 0, "label": "Email", "code": "123456", "seconds_remaining": 30}
]
}
def test_parent_seed_endpoint(client, tmp_path):
cl, token = client
api._pm.parent_seed = "seed"
called = {}
api._pm.encryption_manager = SimpleNamespace(
encrypt_and_save_file=lambda data, path: called.setdefault("path", path)
)
headers = {"Authorization": f"Bearer {token}"}
res = cl.get("/api/v1/parent-seed", headers=headers)
assert res.status_code == 200
assert res.json() == {"seed": "seed"}
out = tmp_path / "bk.enc"
res = cl.get("/api/v1/parent-seed", params={"file": str(out)}, headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "saved", "path": str(out)}
assert called["path"] == out
def test_fingerprint_endpoints(client):
cl, token = client
calls = {}
api._pm.add_new_fingerprint = lambda: calls.setdefault("add", True)
api._pm.fingerprint_manager.remove_fingerprint = lambda fp: calls.setdefault(
"remove", fp
)
api._pm.select_fingerprint = lambda fp: calls.setdefault("select", fp)
headers = {"Authorization": f"Bearer {token}"}
res = cl.post("/api/v1/fingerprint", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert calls.get("add") is True
res = cl.delete("/api/v1/fingerprint/abc", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "deleted"}
assert calls.get("remove") == "abc"
res = cl.post(
"/api/v1/fingerprint/select",
json={"fingerprint": "xyz"},
headers=headers,
)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert calls.get("select") == "xyz"
def test_checksum_endpoints(client):
cl, token = client
calls = {}
api._pm.handle_verify_checksum = lambda: calls.setdefault("verify", True)
api._pm.handle_update_script_checksum = lambda: calls.setdefault("update", True)
headers = {"Authorization": f"Bearer {token}"}
res = cl.post("/api/v1/checksum/verify", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert calls.get("verify") is True
res = cl.post("/api/v1/checksum/update", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert calls.get("update") is True
def test_vault_import_via_path(client, tmp_path):
cl, token = client
called = {}
def import_db(path):
called["path"] = path
api._pm.handle_import_database = import_db
file_path = tmp_path / "b.json"
file_path.write_text("{}")
headers = {"Authorization": f"Bearer {token}"}
res = cl.post(
"/api/v1/vault/import",
json={"path": str(file_path)},
headers=headers,
)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert called["path"] == file_path
def test_vault_import_via_upload(client, tmp_path):
cl, token = client
called = {}
def import_db(path):
called["path"] = path
api._pm.handle_import_database = import_db
file_path = tmp_path / "c.json"
file_path.write_text("{}")
headers = {"Authorization": f"Bearer {token}"}
with open(file_path, "rb") as fh:
res = cl.post(
"/api/v1/vault/import",
files={"file": ("c.json", fh.read())},
headers=headers,
)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert isinstance(called.get("path"), Path)
def test_vault_lock_endpoint(client):
cl, token = client
called = {}
def lock():
called["locked"] = True
api._pm.locked = True
api._pm.lock_vault = lock
api._pm.locked = False
headers = {"Authorization": f"Bearer {token}"}
res = cl.post("/api/v1/vault/lock", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "locked"}
assert called.get("locked") is True
assert api._pm.locked is True
api._pm.unlock_vault = lambda: setattr(api._pm, "locked", False)
api._pm.unlock_vault()
assert api._pm.locked is False
def test_secret_mode_endpoint(client):
cl, token = client
called = {}
def set_secret(val):
called.setdefault("enabled", val)
def set_delay(val):
called.setdefault("delay", val)
api._pm.config_manager.set_secret_mode_enabled = set_secret
api._pm.config_manager.set_clipboard_clear_delay = set_delay
headers = {"Authorization": f"Bearer {token}"}
res = cl.post(
"/api/v1/secret-mode",
json={"enabled": True, "delay": 12},
headers=headers,
)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert called["enabled"] is True
assert called["delay"] == 12

View File

@@ -0,0 +1,13 @@
from test_api import client
def test_profile_stats_endpoint(client):
cl, token = client
stats = {"total_entries": 1}
# monkeypatch set _pm.get_profile_stats after client fixture started
import seedpass.api as api
api._pm.get_profile_stats = lambda: stats
res = cl.get("/api/v1/stats", headers={"Authorization": f"Bearer {token}"})
assert res.status_code == 200
assert res.json() == stats

View File

@@ -0,0 +1,44 @@
import pytest
from types import SimpleNamespace
from typer.testing import CliRunner
from seedpass.cli import app
from seedpass import cli
runner = CliRunner()
@pytest.mark.parametrize(
"key,value,method,expected",
[
("secret_mode_enabled", "true", "set_secret_mode_enabled", True),
("clipboard_clear_delay", "10", "set_clipboard_clear_delay", 10),
("additional_backup_path", "", "set_additional_backup_path", None),
(
"relays",
"wss://a.com, wss://b.com",
"set_relays",
["wss://a.com", "wss://b.com"],
),
],
)
def test_config_set_variants(monkeypatch, key, value, method, expected):
called = {}
def func(val, **kwargs):
called["val"] = val
called.update(kwargs)
pm = SimpleNamespace(
config_manager=SimpleNamespace(**{method: func}),
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["config", "set", key, value])
assert result.exit_code == 0
assert "Updated" in result.stdout
assert called.get("val") == expected
if key == "relays":
assert called.get("require_pin") is False

View File

@@ -0,0 +1,112 @@
import pytest
from types import SimpleNamespace
from typer.testing import CliRunner
from seedpass.cli import app
from seedpass import cli
runner = CliRunner()
@pytest.mark.parametrize(
"command,method,cli_args,expected_args,expected_kwargs,stdout",
[
(
"add-totp",
"add_totp",
[
"Label",
"--index",
"1",
"--secret",
"abc",
"--period",
"45",
"--digits",
"7",
],
("Label", "seed"),
{"index": 1, "secret": "abc", "period": 45, "digits": 7},
"otpauth://uri",
),
(
"add-ssh",
"add_ssh_key",
["Label", "--index", "2", "--notes", "n"],
("Label", "seed"),
{"index": 2, "notes": "n"},
"3",
),
(
"add-pgp",
"add_pgp_key",
[
"Label",
"--index",
"3",
"--key-type",
"rsa",
"--user-id",
"uid",
"--notes",
"n",
],
("Label", "seed"),
{"index": 3, "key_type": "rsa", "user_id": "uid", "notes": "n"},
"4",
),
(
"add-nostr",
"add_nostr_key",
["Label", "--index", "4", "--notes", "n"],
("Label",),
{"index": 4, "notes": "n"},
"5",
),
(
"add-seed",
"add_seed",
["Label", "--index", "5", "--words", "12", "--notes", "n"],
("Label", "seed"),
{"index": 5, "words_num": 12, "notes": "n"},
"6",
),
(
"add-key-value",
"add_key_value",
["Label", "--value", "val", "--notes", "note"],
("Label", "val"),
{"notes": "note"},
"7",
),
(
"add-managed-account",
"add_managed_account",
["Label", "--index", "7", "--notes", "n"],
("Label", "seed"),
{"index": 7, "notes": "n"},
"8",
),
],
)
def test_entry_add_commands(
monkeypatch, command, method, cli_args, expected_args, expected_kwargs, stdout
):
called = {}
def func(*args, **kwargs):
called["args"] = args
called["kwargs"] = kwargs
return stdout
pm = SimpleNamespace(
entry_manager=SimpleNamespace(**{method: func}),
parent_seed="seed",
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["entry", command] + cli_args)
assert result.exit_code == 0
assert stdout in result.stdout
assert called["args"] == expected_args
assert called["kwargs"] == expected_kwargs

View File

@@ -0,0 +1,44 @@
import types
from types import SimpleNamespace
from typer.testing import CliRunner
from seedpass.cli import app
from seedpass import cli
runner = CliRunner()
def _make_pm(called, enabled=False, delay=45):
cfg = SimpleNamespace(
get_secret_mode_enabled=lambda: enabled,
get_clipboard_clear_delay=lambda: delay,
set_secret_mode_enabled=lambda v: called.setdefault("enabled", v),
set_clipboard_clear_delay=lambda v: called.setdefault("delay", v),
)
pm = SimpleNamespace(
config_manager=cfg,
secret_mode_enabled=enabled,
clipboard_clear_delay=delay,
select_fingerprint=lambda fp: None,
)
return pm
def test_toggle_secret_mode_updates(monkeypatch):
called = {}
pm = _make_pm(called)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["config", "toggle-secret-mode"], input="y\n10\n")
assert result.exit_code == 0
assert called == {"enabled": True, "delay": 10}
assert "Secret mode enabled." in result.stdout
def test_toggle_secret_mode_keep(monkeypatch):
called = {}
pm = _make_pm(called, enabled=True, delay=30)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["config", "toggle-secret-mode"], input="\n\n")
assert result.exit_code == 0
assert called == {"enabled": True, "delay": 30}
assert "Secret mode enabled." in result.stdout

View File

@@ -0,0 +1,25 @@
import json
from types import SimpleNamespace
from typer.testing import CliRunner
from seedpass.cli import app
from seedpass import cli
runner = CliRunner()
def test_vault_stats_command(monkeypatch):
stats = {
"total_entries": 2,
"entries": {"password": 1, "totp": 1},
}
pm = SimpleNamespace(
get_profile_stats=lambda: stats, select_fingerprint=lambda fp: None
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["vault", "stats"])
assert result.exit_code == 0
out = result.stdout
# Output should be pretty JSON with the expected values
data = json.loads(out)
assert data == stats

449
src/tests/test_typer_cli.py Normal file
View File

@@ -0,0 +1,449 @@
import sys
from types import SimpleNamespace
from pathlib import Path
sys.path.append(str(Path(__file__).resolve().parents[1]))
from typer.testing import CliRunner
from seedpass.cli import app, PasswordManager
from seedpass import cli
from password_manager.entry_types import EntryType
runner = CliRunner()
def test_entry_list(monkeypatch):
called = {}
def list_entries(sort_by="index", filter_kind=None, include_archived=False):
called["args"] = (sort_by, filter_kind, include_archived)
return [(0, "Site", "user", "", False)]
pm = SimpleNamespace(
entry_manager=SimpleNamespace(list_entries=list_entries),
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["entry", "list"])
assert result.exit_code == 0
assert "Site" in result.stdout
assert called["args"] == ("index", None, False)
def test_entry_search(monkeypatch):
pm = SimpleNamespace(
entry_manager=SimpleNamespace(
search_entries=lambda q: [(1, "L", None, None, False)]
),
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
def test_entry_get_password(monkeypatch):
def search(q):
return [(2, "Example", "", "", False)]
entry = {"type": EntryType.PASSWORD.value, "length": 8}
pm = SimpleNamespace(
entry_manager=SimpleNamespace(
search_entries=search,
retrieve_entry=lambda i: entry,
get_totp_code=lambda i, s: "",
),
password_generator=SimpleNamespace(generate_password=lambda l, i: "pw"),
parent_seed="seed",
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["entry", "get", "ex"])
assert result.exit_code == 0
assert "pw" in result.stdout
def test_vault_export(monkeypatch, tmp_path):
called = {}
def export_db(path):
called["path"] = path
pm = SimpleNamespace(
handle_export_database=export_db, select_fingerprint=lambda fp: None
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
out_path = tmp_path / "out.json"
result = runner.invoke(app, ["vault", "export", "--file", str(out_path)])
assert result.exit_code == 0
assert called["path"] == out_path
def test_vault_import(monkeypatch, tmp_path):
called = {}
def import_db(path):
called["path"] = path
pm = SimpleNamespace(
handle_import_database=import_db, select_fingerprint=lambda fp: None
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
in_path = tmp_path / "in.json"
in_path.write_text("{}")
result = runner.invoke(app, ["vault", "import", "--file", str(in_path)])
assert result.exit_code == 0
assert called["path"] == in_path
def test_vault_change_password(monkeypatch):
called = {}
def change_pw():
called["called"] = True
pm = SimpleNamespace(change_password=change_pw, select_fingerprint=lambda fp: None)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["vault", "change-password"])
assert result.exit_code == 0
assert called.get("called") is True
def test_vault_lock(monkeypatch):
called = {}
def lock():
called["locked"] = True
pm.locked = True
pm = SimpleNamespace(
lock_vault=lock, locked=False, select_fingerprint=lambda fp: None
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["vault", "lock"])
assert result.exit_code == 0
assert called.get("locked") is True
assert pm.locked is True
def test_vault_reveal_parent_seed(monkeypatch, tmp_path):
called = {}
def reveal(path=None):
called["path"] = path
pm = SimpleNamespace(
handle_backup_reveal_parent_seed=reveal, select_fingerprint=lambda fp: None
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
out_path = tmp_path / "seed.enc"
result = runner.invoke(
app, ["vault", "reveal-parent-seed", "--file", str(out_path)]
)
assert result.exit_code == 0
assert called["path"] == out_path
def test_nostr_get_pubkey(monkeypatch):
pm = SimpleNamespace(
nostr_client=SimpleNamespace(
key_manager=SimpleNamespace(get_npub=lambda: "np")
),
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["nostr", "get-pubkey"])
assert result.exit_code == 0
assert "np" in result.stdout
def test_fingerprint_list(monkeypatch):
pm = SimpleNamespace(
fingerprint_manager=SimpleNamespace(list_fingerprints=lambda: ["a", "b"]),
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["fingerprint", "list"])
assert result.exit_code == 0
assert "a" in result.stdout and "b" in result.stdout
def test_fingerprint_add(monkeypatch):
called = {}
def add():
called["add"] = True
pm = SimpleNamespace(
add_new_fingerprint=add,
select_fingerprint=lambda fp: None,
fingerprint_manager=SimpleNamespace(),
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["fingerprint", "add"])
assert result.exit_code == 0
assert called.get("add") is True
def test_fingerprint_remove(monkeypatch):
called = {}
def remove(fp):
called["fp"] = fp
pm = SimpleNamespace(
fingerprint_manager=SimpleNamespace(remove_fingerprint=remove),
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["fingerprint", "remove", "abc"])
assert result.exit_code == 0
assert called.get("fp") == "abc"
def test_fingerprint_switch(monkeypatch):
called = {}
def switch(fp):
called["fp"] = fp
pm = SimpleNamespace(
select_fingerprint=switch, fingerprint_manager=SimpleNamespace()
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["fingerprint", "switch", "def"])
assert result.exit_code == 0
assert called.get("fp") == "def"
def test_config_get(monkeypatch):
pm = SimpleNamespace(
config_manager=SimpleNamespace(
load_config=lambda require_pin=False: {"x": "1"}
),
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["config", "get", "x"])
assert result.exit_code == 0
assert "1" in result.stdout
def test_config_set(monkeypatch):
called = {}
def set_timeout(val):
called["timeout"] = float(val)
pm = SimpleNamespace(
config_manager=SimpleNamespace(set_inactivity_timeout=set_timeout),
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["config", "set", "inactivity_timeout", "5"])
assert result.exit_code == 0
assert called["timeout"] == 5.0
assert "Updated" in result.stdout
def test_config_set_unknown_key(monkeypatch):
pm = SimpleNamespace(
config_manager=SimpleNamespace(), select_fingerprint=lambda fp: None
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["config", "set", "bogus", "val"])
assert result.exit_code != 0
assert "Unknown key" in result.stdout
def test_nostr_sync(monkeypatch):
called = {}
def sync_vault():
called["called"] = True
return "evt123"
pm = SimpleNamespace(sync_vault=sync_vault, select_fingerprint=lambda fp: None)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["nostr", "sync"])
assert result.exit_code == 0
assert called.get("called") is True
assert "evt123" in result.stdout
def test_generate_password(monkeypatch):
called = {}
def gen_pw(length):
called["length"] = length
return "secretpw"
pm = SimpleNamespace(
password_generator=SimpleNamespace(generate_password=gen_pw),
select_fingerprint=lambda fp: None,
)
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 "secretpw" in result.stdout
def test_api_start_passes_fingerprint(monkeypatch):
"""Ensure the API start command forwards the selected fingerprint."""
called = {}
def fake_start(fp=None):
called["fp"] = fp
return "tok"
monkeypatch.setattr(cli.api_module, "start_server", fake_start)
monkeypatch.setattr(cli, "uvicorn", SimpleNamespace(run=lambda *a, **k: None))
result = runner.invoke(app, ["--fingerprint", "abc", "api", "start"])
assert result.exit_code == 0
assert called.get("fp") == "abc"
def test_entry_add(monkeypatch):
called = {}
def add_entry(label, length, username=None, url=None):
called["args"] = (label, length, username, url)
return 2
pm = SimpleNamespace(
entry_manager=SimpleNamespace(add_entry=add_entry),
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(
app,
[
"entry",
"add",
"Example",
"--length",
"16",
"--username",
"bob",
"--url",
"ex.com",
],
)
assert result.exit_code == 0
assert "2" in result.stdout
assert called["args"] == ("Example", 16, "bob", "ex.com")
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)
pm = SimpleNamespace(
entry_manager=SimpleNamespace(modify_entry=modify_entry),
select_fingerprint=lambda fp: None,
)
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)
def test_entry_archive(monkeypatch):
called = {}
def archive_entry(i):
called["id"] = i
pm = SimpleNamespace(
entry_manager=SimpleNamespace(archive_entry=archive_entry),
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["entry", "archive", "3"])
assert result.exit_code == 0
assert "3" in result.stdout
assert called["id"] == 3
def test_entry_unarchive(monkeypatch):
called = {}
def restore_entry(i):
called["id"] = i
pm = SimpleNamespace(
entry_manager=SimpleNamespace(restore_entry=restore_entry),
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["entry", "unarchive", "4"])
assert result.exit_code == 0
assert "4" in result.stdout
assert called["id"] == 4
def test_entry_export_totp(monkeypatch, tmp_path):
called = {}
pm = SimpleNamespace(
entry_manager=SimpleNamespace(
export_totp_entries=lambda seed: called.setdefault("called", True)
or {"entries": []}
),
parent_seed="seed",
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
out = tmp_path / "t.json"
result = runner.invoke(app, ["entry", "export-totp", "--file", str(out)])
assert result.exit_code == 0
assert out.exists()
assert called.get("called") is True
def test_entry_totp_codes(monkeypatch):
called = {}
pm = SimpleNamespace(
handle_display_totp_codes=lambda: called.setdefault("called", True),
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["entry", "totp-codes"])
assert result.exit_code == 0
assert called.get("called") is True
def test_verify_checksum_command(monkeypatch):
called = {}
pm = SimpleNamespace(
handle_verify_checksum=lambda: called.setdefault("called", True),
handle_update_script_checksum=lambda: None,
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["util", "verify-checksum"])
assert result.exit_code == 0
assert called.get("called") is True
def test_update_checksum_command(monkeypatch):
called = {}
pm = SimpleNamespace(
handle_verify_checksum=lambda: None,
handle_update_script_checksum=lambda: called.setdefault("called", True),
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["util", "update-checksum"])
assert result.exit_code == 0
assert called.get("called") is True