mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 07:18:47 +00:00
Compare commits
224 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
0d3d972abb | ||
![]() |
aeb146f862 | ||
![]() |
7f503f0787 | ||
![]() |
7a8c0aef86 | ||
![]() |
dcd095d1af | ||
![]() |
b4f792ad67 | ||
![]() |
cc8fba9f12 | ||
![]() |
20896812a4 | ||
![]() |
144751ef02 | ||
![]() |
05d4bd94b6 | ||
![]() |
9adb539b84 | ||
![]() |
64339b0c20 | ||
![]() |
4ccdd2b3df | ||
![]() |
61f1f5c02f | ||
![]() |
77757152d7 | ||
![]() |
265817b67d | ||
![]() |
e3bd669668 | ||
![]() |
f58ed03e6f | ||
![]() |
6d82ef1815 | ||
![]() |
a4ddd120c8 | ||
![]() |
47da67b37c | ||
![]() |
27977612de | ||
![]() |
ceb2eb28ad | ||
![]() |
f83234c568 | ||
![]() |
37fc2779ad | ||
![]() |
73f5a80b33 | ||
![]() |
78bf5a4c64 | ||
![]() |
db3a60fc5c | ||
![]() |
365a098d70 | ||
![]() |
7cdb67e82e | ||
![]() |
0a078bbcf4 | ||
![]() |
8a5d1717f8 | ||
![]() |
05eae9bddd | ||
![]() |
3c1f6991a4 | ||
![]() |
a7a2e70321 | ||
![]() |
3020b681b8 | ||
![]() |
d3bd3b9e0a | ||
![]() |
4e787e362e | ||
![]() |
6d6a284096 | ||
![]() |
b76112f11b | ||
![]() |
b74c0993ca | ||
![]() |
d8cb21e527 | ||
![]() |
b57e19b657 | ||
![]() |
3f169747d1 | ||
![]() |
b4d60782af | ||
![]() |
f664c4099c | ||
![]() |
7dba8e138d | ||
![]() |
cfc7e455d5 | ||
![]() |
dcb5c6e805 | ||
![]() |
d4d475438f | ||
![]() |
627d69cf30 | ||
![]() |
c15776e37e | ||
![]() |
2f7b41c5dd | ||
![]() |
bb1d692798 | ||
![]() |
b621cffae6 | ||
![]() |
3937ccfb75 | ||
![]() |
0dd5b1f301 | ||
![]() |
715952fba6 | ||
![]() |
acb1126287 | ||
![]() |
bcd8002e1d | ||
![]() |
3ad7c1ab94 | ||
![]() |
4a20817094 | ||
![]() |
d3f2cb8256 | ||
![]() |
a3d45a117c | ||
![]() |
2d39d7a5bd | ||
![]() |
0396e99e0f | ||
![]() |
f677f4b445 | ||
![]() |
5b0051f76f | ||
![]() |
0490583fee | ||
![]() |
0edf4e5c83 | ||
![]() |
01fa1f4997 | ||
![]() |
ea2451f4a0 | ||
![]() |
5eb5e29094 | ||
![]() |
747e2be5a9 | ||
![]() |
db90d9caf0 | ||
![]() |
cb1e18c8ba | ||
![]() |
cf3803c422 | ||
![]() |
24a606fb70 | ||
![]() |
5850b68c9a | ||
![]() |
529eb5a0a6 | ||
![]() |
e0318c7850 | ||
![]() |
64a84c59d7 | ||
![]() |
aaf7b79e59 | ||
![]() |
8e7224dfd2 | ||
![]() |
61ffb073b5 | ||
![]() |
3e83616a4f | ||
![]() |
85ce777333 | ||
![]() |
b70585c55e | ||
![]() |
17e5d48fdf | ||
![]() |
8b180b8d9a | ||
![]() |
08f496e1e6 | ||
![]() |
93587a7502 | ||
![]() |
39114b0b8a | ||
![]() |
98f8bfa679 | ||
![]() |
b7042a70db | ||
![]() |
ed3f0da731 | ||
![]() |
f5653c9bb1 | ||
![]() |
e542248778 | ||
![]() |
3b0825633c | ||
![]() |
375fd571fa | ||
![]() |
59dbb885aa | ||
![]() |
160a8fac51 | ||
![]() |
bf5281804e | ||
![]() |
557af9745a | ||
![]() |
ec52d2eda0 | ||
![]() |
49544c0200 | ||
![]() |
e6ca36b8b7 | ||
![]() |
398f8fa59f | ||
![]() |
ff1f8bb4e1 | ||
![]() |
47f26292b1 | ||
![]() |
bdf6a038c2 | ||
![]() |
411c5df4b4 | ||
![]() |
9fc117b105 | ||
![]() |
63a5cd3190 | ||
![]() |
c80495eca6 | ||
![]() |
1507ba9553 | ||
![]() |
a01e0f0037 | ||
![]() |
a9d1d2f242 | ||
![]() |
64c174c385 | ||
![]() |
04862bce48 | ||
![]() |
2832b0150e | ||
![]() |
bfe65c9707 | ||
![]() |
c1f1adbb8f | ||
![]() |
d7df6679bd | ||
![]() |
856ef3dc32 | ||
![]() |
ad3f1bc80c | ||
![]() |
78c398ba4b | ||
![]() |
a94762e8a1 | ||
![]() |
70e05970f0 | ||
![]() |
ade572815a | ||
![]() |
c6a87e000d | ||
![]() |
6855c85329 | ||
![]() |
c3e2ff4b4b | ||
![]() |
c2512319ab | ||
![]() |
fedd0c352a | ||
![]() |
4dbfde4bf5 | ||
![]() |
c2809032fd | ||
![]() |
e508ff900a | ||
![]() |
ea579aaa5d | ||
![]() |
3aa944bb49 | ||
![]() |
f31e2663b6 | ||
![]() |
a6e18ae9c5 | ||
![]() |
c6d4b937cb | ||
![]() |
615be7d325 | ||
![]() |
8561e68d36 | ||
![]() |
5fce7836d9 | ||
![]() |
3502e34428 | ||
![]() |
724c0b883f | ||
![]() |
20dfc35f7e | ||
![]() |
29690d7c7b | ||
![]() |
876d98a785 | ||
![]() |
11b3707087 | ||
![]() |
d529877888 | ||
![]() |
b42ad0561c | ||
![]() |
64664cb0bb | ||
![]() |
b0ba723bdd | ||
![]() |
5eab7f879c | ||
![]() |
ddfe17b77b | ||
![]() |
8fe79a012b | ||
![]() |
d71a4912bd | ||
![]() |
98f841790a | ||
![]() |
d89fa7f707 | ||
![]() |
3d754b50f1 | ||
![]() |
4491dd35df | ||
![]() |
e175e29813 | ||
![]() |
a83001a799 | ||
![]() |
c1f195d74f | ||
![]() |
edea2d3a6f | ||
![]() |
462cef20c3 | ||
![]() |
726e88de19 | ||
![]() |
29243a37b8 | ||
![]() |
66dfb9d205 | ||
![]() |
c83dab7793 | ||
![]() |
7ef7246361 | ||
![]() |
b8a2e29f21 | ||
![]() |
37c78f608a | ||
![]() |
c8d09d6294 | ||
![]() |
dcda452da8 | ||
![]() |
eb1ab7662d | ||
![]() |
2874bf0f82 | ||
![]() |
d93f47e3ee | ||
![]() |
bc2c22ac10 | ||
![]() |
d679d52b66 | ||
![]() |
ae26190928 | ||
![]() |
474f2d134b | ||
![]() |
9976e5473f | ||
![]() |
df0279ac03 | ||
![]() |
6512b2f501 | ||
![]() |
beb690ba72 | ||
![]() |
9ee1bfad17 | ||
![]() |
3ff9843750 | ||
![]() |
450ff06d4e | ||
![]() |
728c4be6e1 | ||
![]() |
9f3a65b5b4 | ||
![]() |
47ea11b533 | ||
![]() |
d14385c4d2 | ||
![]() |
cd0f9624ae | ||
![]() |
51233dff9b | ||
![]() |
fac31cd99f | ||
![]() |
ecf8f4e722 | ||
![]() |
6c22e28512 | ||
![]() |
025fee3045 | ||
![]() |
5019c99c11 | ||
![]() |
e7021f480f | ||
![]() |
6c54d4d8e3 | ||
![]() |
f68adf9170 | ||
![]() |
72faee02b6 | ||
![]() |
6fca250856 | ||
![]() |
8bd9a75629 | ||
![]() |
7af92195c4 | ||
![]() |
c23b2e4913 | ||
![]() |
87149517d8 | ||
![]() |
c4bb8dfa64 | ||
![]() |
f1c24fb2ca | ||
![]() |
0818408f48 | ||
![]() |
576437223a | ||
![]() |
e5858cba38 | ||
![]() |
c787651899 | ||
![]() |
f3c223a9a1 | ||
![]() |
dfa85ad863 | ||
![]() |
1f7c538015 | ||
![]() |
8579cf7f3d | ||
![]() |
cca03b2f2e | ||
![]() |
49a5329bf6 |
27
.github/workflows/briefcase.yml
vendored
Normal file
27
.github/workflows/briefcase.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Build GUI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'seedpass-gui*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r src/requirements.txt
|
||||
pip install briefcase
|
||||
- name: Build with Briefcase
|
||||
run: briefcase build
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: seedpass-gui
|
||||
path: dist/**
|
14
.github/workflows/python-ci.yml
vendored
14
.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: |
|
||||
@@ -84,12 +84,24 @@ jobs:
|
||||
timeout-minutes: 16
|
||||
shell: bash
|
||||
run: scripts/run_ci_tests.sh
|
||||
- name: Run desktop tests
|
||||
timeout-minutes: 10
|
||||
shell: bash
|
||||
env:
|
||||
TOGA_BACKEND: toga_dummy
|
||||
run: scripts/run_gui_tests.sh
|
||||
- name: Upload pytest log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pytest-log-${{ matrix.os }}
|
||||
path: pytest.log
|
||||
- name: Upload GUI pytest log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gui-pytest-log-${{ matrix.os }}
|
||||
path: pytest_gui.log
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
168
README.md
168
README.md
@@ -21,6 +21,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Architecture Overview](#architecture-overview)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Installation](#installation)
|
||||
- [1. Clone the Repository](#1-clone-the-repository)
|
||||
@@ -31,7 +32,9 @@ 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)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
@@ -54,8 +57,9 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
||||
- **Optional External Backup Location:** Configure a second directory where backups are automatically copied.
|
||||
- **Auto-Lock on Inactivity:** Vault locks after a configurable timeout for additional security.
|
||||
- **Quick Unlock:** Optionally skip the password prompt after verifying once.
|
||||
- **Secret Mode:** Copy retrieved passwords directly to your clipboard and automatically clear it after a delay.
|
||||
- **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.
|
||||
@@ -65,9 +69,41 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
||||
- **Relay Management:** List, add, remove or reset configured Nostr relays.
|
||||
- **Offline Mode:** Disable all Nostr communication for local-only operation.
|
||||
|
||||
|
||||
A small on-screen notification area now shows queued messages for 10 seconds
|
||||
before fading.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
SeedPass follows a layered design. The **`seedpass.core`** package exposes the
|
||||
`PasswordManager` along with service classes (e.g. `VaultService` and
|
||||
`EntryService`) that implement the main API used across interfaces.
|
||||
The command line tool in **`seedpass.cli`** is a thin adapter built with Typer
|
||||
that delegates operations to this API layer.
|
||||
|
||||
The BeeWare desktop interface lives in **`seedpass_gui.app`** and can be
|
||||
started with either `seedpass-gui` or `python -m seedpass_gui`. It reuses the
|
||||
same service objects to unlock the vault, list entries and search through them.
|
||||
|
||||
An optional browser extension can communicate with the FastAPI server exposed by
|
||||
`seedpass.api` to manage entries from within the browser.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
core["seedpass.core"]
|
||||
cli["CLI"]
|
||||
api["FastAPI server"]
|
||||
gui["BeeWare GUI"]
|
||||
ext["Browser Extension"]
|
||||
|
||||
cli --> core
|
||||
gui --> core
|
||||
api --> core
|
||||
ext --> api
|
||||
```
|
||||
|
||||
See `docs/ARCHITECTURE.md` for details.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Python 3.8+** (3.11 or 3.12 recommended): Install Python from [python.org](https://www.python.org/downloads/) and be sure to check **"Add Python to PATH"** during setup. Using Python 3.13 is currently discouraged because some dependencies do not ship wheels for it yet, which can cause build failures on Windows unless you install the Visual C++ Build Tools.
|
||||
@@ -78,6 +114,9 @@ before fading.
|
||||
### Quick Installer
|
||||
|
||||
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
|
||||
@@ -87,6 +126,7 @@ bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/
|
||||
```bash
|
||||
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)" _ -b beta
|
||||
```
|
||||
Make sure the command ends right after `-b beta` with **no trailing parenthesis**.
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
@@ -96,6 +136,20 @@ Before running the script, install **Python 3.11** or **3.12** from [python.org]
|
||||
The Windows installer will attempt to install Git automatically if it is not already available. It also tries to install Python 3 using `winget`, `choco`, or `scoop` when Python is missing and recognizes the `py` launcher if `python` isn't on your PATH. If these tools are unavailable you'll see a link to download Python directly from <https://www.python.org/downloads/windows/>. When Python 3.13 or newer is detected without the Microsoft C++ build tools, the installer now attempts to download Python 3.12 automatically so you don't have to compile packages from source.
|
||||
|
||||
**Note:** If this fallback fails, install Python 3.12 manually or install the [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and rerun the installer.
|
||||
|
||||
#### Windows Nostr Sync Troubleshooting
|
||||
|
||||
When backing up or restoring from Nostr on Windows, a few issues are common:
|
||||
|
||||
* **Event loop errors** – Messages like `RuntimeError: Event loop is closed` usually mean the async runtime failed to initialize. Running SeedPass with `--verbose` provides more detail about which coroutine failed.
|
||||
* **Permission problems** – If you see `Access is denied` when writing to `~/.seedpass`, launch your terminal with "Run as administrator" so the app can create files in your profile directory.
|
||||
* **Missing dependencies** – Ensure `websockets` and other requirements are installed inside your virtual environment:
|
||||
|
||||
```bash
|
||||
pip install websockets
|
||||
```
|
||||
|
||||
Using increased log verbosity helps diagnose sync issues and confirm that the WebSocket connections to your configured relays succeed.
|
||||
### Uninstall
|
||||
|
||||
Run the matching uninstaller if you need to remove a previous installation or clean up an old `seedpass` command:
|
||||
@@ -182,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
|
||||
@@ -189,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.
|
||||
@@ -196,6 +253,53 @@ seedpass list --filter totp
|
||||
|
||||
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).
|
||||
|
||||
### Getting Started with the GUI
|
||||
|
||||
SeedPass also ships with a simple BeeWare desktop interface. Launch it from
|
||||
your virtual environment using any of the following commands:
|
||||
|
||||
```bash
|
||||
seedpass gui
|
||||
python -m seedpass_gui
|
||||
seedpass-gui
|
||||
```
|
||||
|
||||
Only `toga-core` and the headless `toga-dummy` backend are included by default.
|
||||
The quick installer automatically installs the correct BeeWare backend so the
|
||||
GUI works out of the box. If you set up SeedPass manually, install the backend
|
||||
for your platform:
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
pip install toga-gtk
|
||||
|
||||
# If you see build errors about "cairo" on Linux, install the cairo
|
||||
# development headers using your package manager, e.g.:
|
||||
sudo apt-get install libcairo2 libcairo2-dev
|
||||
|
||||
# Windows
|
||||
pip install toga-winforms
|
||||
|
||||
# macOS
|
||||
pip install toga-cocoa
|
||||
```
|
||||
|
||||
The GUI works with the same vault and configuration files as the CLI.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
core["seedpass.core"]
|
||||
cli["CLI"]
|
||||
api["FastAPI server"]
|
||||
gui["BeeWare GUI"]
|
||||
ext["Browser Extension"]
|
||||
|
||||
cli --> core
|
||||
gui --> core
|
||||
api --> core
|
||||
ext --> api
|
||||
```
|
||||
|
||||
### 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.
|
||||
@@ -305,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)**.
|
||||
@@ -326,11 +439,11 @@ When choosing **Add Entry**, you can now select from:
|
||||
|
||||
### Using Secret Mode
|
||||
|
||||
When **Secret Mode** is enabled, SeedPass copies retrieved passwords directly to your clipboard instead of displaying them on screen. The clipboard clears automatically after the delay you choose.
|
||||
When **Secret Mode** is enabled, SeedPass copies newly generated and retrieved passwords directly to your clipboard instead of displaying them on screen. The clipboard clears automatically after the delay you choose.
|
||||
|
||||
1. From the main menu open **Settings** and select **Toggle Secret Mode**.
|
||||
2. Choose how many seconds to keep passwords on the clipboard.
|
||||
3. Retrieve an entry and SeedPass will confirm the password was copied.
|
||||
3. Generate or retrieve an entry and SeedPass will confirm the password was copied.
|
||||
|
||||
### Viewing Entry Details
|
||||
|
||||
@@ -361,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
|
||||
@@ -442,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:
|
||||
@@ -494,6 +619,10 @@ If the checksum file is missing, generate it manually:
|
||||
python scripts/update_checksum.py
|
||||
```
|
||||
|
||||
If SeedPass prints a "script checksum mismatch" warning on startup, regenerate
|
||||
the checksum with `seedpass util update-checksum` or select "Generate Script
|
||||
Checksum" from the Settings menu.
|
||||
|
||||
To run mutation tests locally, generate coverage data first and then execute `mutmut`:
|
||||
|
||||
```bash
|
||||
@@ -537,8 +666,39 @@ scripts/vendor_dependencies.sh
|
||||
pyinstaller SeedPass.spec
|
||||
```
|
||||
|
||||
You can also produce packaged installers for the GUI with BeeWare's Briefcase:
|
||||
|
||||
```bash
|
||||
briefcase build
|
||||
```
|
||||
|
||||
Pre-built installers are published for each `seedpass-gui` tag. Visit the
|
||||
project's **Actions** or **Releases** page on GitHub to download the latest
|
||||
package for your platform.
|
||||
|
||||
The standalone executable will appear in the `dist/` directory. This process works on Windows, macOS and Linux but you must build on each platform for a native binary.
|
||||
|
||||
## Packaging with Briefcase
|
||||
|
||||
For step-by-step instructions see [docs/docs/content/01-getting-started/05-briefcase.md](docs/docs/content/01-getting-started/05-briefcase.md).
|
||||
|
||||
Install Briefcase and create a platform-specific scaffold:
|
||||
|
||||
```bash
|
||||
python -m pip install briefcase
|
||||
briefcase create
|
||||
```
|
||||
|
||||
Build and run the packaged GUI:
|
||||
|
||||
```bash
|
||||
briefcase build
|
||||
briefcase run
|
||||
```
|
||||
|
||||
You can also launch the GUI directly with `seedpass gui` or `seedpass-gui`.
|
||||
|
||||
|
||||
## Security Considerations
|
||||
|
||||
**Important:** The password you use to encrypt your parent seed is also required to decrypt the seed index data retrieved from Nostr. **It is imperative to remember this password** and be sure to use it with the same seed, as losing it means you won't be able to access your stored index. Secure your 12-word seed **and** your master password.
|
||||
|
93
dev-plan.md
93
dev-plan.md
@@ -1,93 +0,0 @@
|
||||
### SeedPass Road-to-1.0 — Detailed Development Plan
|
||||
|
||||
*(Assumes today = 1 July 2025, team of 1-3 devs, weekly release cadence)*
|
||||
|
||||
| Phase | Goal | Key Deliverables | Target Window |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
|
||||
| **0 – Vision Lock-in** | Be explicit about where you’re going so every later trade-off is easy. | • 2-page “north-star” doc covering product scope, security promises, platforms, and **“CLI is source of truth”** principle. <br>• Public roadmap Kanban board. | **Week 0** |
|
||||
| **1 – Package-ready Codebase** | Turn loose `src/` tree into a pip-installable library + console script. | • `pyproject.toml` with PEP-621 metadata, `setuptools-scm` dynamic version. <br>• Restructure to `seedpass/` (or keep `src/` but list `packages = ["seedpass"]`). <br>• Entry-point: `seedpass = "seedpass.main:cli"`. <br>• Dev extras: `pytest-cov`, `ruff`, `mypy`, `pre-commit`. <br>• Split pure business logic from I/O (e.g., encryption, BIP-85, vault ops) so GUI can reuse. | **Weeks 0-2** |
|
||||
| **2 – Local Quality Net** | Fail fast before CI runs. | • `make test` / `tox` quick matrix (3.10–3.12). <br>• 90 % line coverage gate. <br>• Static checks in pre-commit (black, ruff, mypy). | **Weeks 1-3** |
|
||||
| **3 – CI / Release Automation** | One Git tag → everything ships. | • GitHub Actions matrix (Ubuntu, macOS, Windows). <br>• Steps: install → unit tests → build wheels (`python -m build`) → PyInstaller one-file artefacts → upload to Release. <br>• Secrets for PyPI / code-signing left empty until 1.0. | **Weeks 2-4** |
|
||||
| **4 – OS-Native Packages** | Users can “apt install / brew install / flatpak install / download .exe”. | **Linux** • `stdeb` → `.deb`, `reprepro` mini-APT repo. <br>**Flatpak** • YAML manifest + GitHub Action to build & push to Flathub beta repo. <br>**Windows** • PyInstaller `--onefile` → NSIS installer. <br>**macOS** • Briefcase → notarised `.pkg` or `.dmg` (signing cert later). | **Weeks 4-8** |
|
||||
| **5 – Experimental GUI Track** | Ship a GUI **without** slowing CLI velocity. | • Decide stack (recommend **Textual** first; upgrade later to Toga or PySide). <br>• Create `seedpass.gui` package calling existing APIs; flag with `--gui`. <br>• Feature flag via env var `SEEDPASS_GUI=1` or CLI switch. <br>• Separate workflow that builds GUI artefacts, but does **not** block CLI releases. | **Weeks 6-12** (parallel) |
|
||||
| **6 – Plugin / Extensibility Layer** | Keep core slim while allowing future features. | • Define `entry_points={"seedpass.plugins": …}`. <br>• Document simple example plugin (e.g., custom password rule). <br>• Load plugins lazily to avoid startup cost. | **Weeks 10-14** |
|
||||
| **7 – Security & Hardening** | Turn security assumptions into guarantees before 1.0 | • SAST scan (Bandit, Semgrep). <br>• Threat-model doc: key-storage, BIP-85 determinism, Nostr backup flow. <br>• Repro-build check for PyInstaller artefacts. <br>• Signed releases (Sigstore, minisign). | **Weeks 12-16** |
|
||||
| **8 – 1.0 Launch Prep** | Final polish + docs. | • User manual (MkDocs, `docs.seedpass.org`). <br>• In-app `--check-update` hitting GitHub API. <br>• Blog post & template release notes. | **Weeks 16-18** |
|
||||
|
||||
---
|
||||
|
||||
### Ongoing Practices to Keep Development Nimble
|
||||
|
||||
| Practice | What to do |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| **Dynamic versioning** | Keep `version` dynamic via `setuptools-scm` / `hatch-vcs`; tag and push – nothing else. |
|
||||
| **Trunk-based dev** | Short-lived branches, PRs < 300 LOC; merge when tests pass. |
|
||||
| **Feature flags** | `seedpass.config.is_enabled("X")` so unfinished work can ship dark. |
|
||||
| **Fast feedback loops** | Local editable install; `invoke run --watch` (or `uvicorn --reload` for GUI) to hot-reload. |
|
||||
| **Weekly beta release** | Even during heavy GUI work, cut “beta” tags weekly; real users shake out regressions early. |
|
||||
|
||||
---
|
||||
|
||||
### First 2-Week Sprint (Concrete To-Dos)
|
||||
|
||||
1. **Bootstrap packaging**
|
||||
|
||||
```bash
|
||||
pip install --upgrade pip build setuptools_scm
|
||||
poetry init # if you prefer Poetry, else stick with setuptools
|
||||
```
|
||||
|
||||
Add `pyproject.toml`, move code to `seedpass/`.
|
||||
|
||||
2. **Console entry-point**
|
||||
In `seedpass/__main__.py` add `from .main import cli; cli()`.
|
||||
|
||||
3. **Editable dev install**
|
||||
`pip install -e .[dev]` → run `seedpass --help`.
|
||||
|
||||
4. **Set up pre-commit**
|
||||
`pre-commit install` with ruff + black + mypy hooks.
|
||||
|
||||
5. **GitHub Action skeleton** (`.github/workflows/ci.yml`)
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix: os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
python-version: ['3.12', '3.11']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with: {python-version: ${{ matrix.python-version }}}
|
||||
- run: pip install --upgrade pip
|
||||
- run: pip install -e .[dev]
|
||||
- run: pytest -n auto
|
||||
```
|
||||
|
||||
6. **Smoke PyInstaller locally**
|
||||
`pyinstaller --onefile seedpass/main.py` – fix missing data/hooks; check binary runs.
|
||||
|
||||
When that’s green, cut tag `v0.1.0-beta` and let CI build artefacts automatically.
|
||||
|
||||
---
|
||||
|
||||
### Choosing the GUI Path (decision by Week 6)
|
||||
|
||||
| If you value… | Choose |
|
||||
| ---------------------------------- | ---------------------------- |
|
||||
| Terminal-first UX, live coding | **Textual (Rich-TUI)** |
|
||||
| Native look, single code base | **Toga / Briefcase** |
|
||||
| Advanced widgets, designer tooling | **PySide-6 / Qt for Python** |
|
||||
|
||||
Prototype one screen (vault list + “Add” dialog) and benchmark bundle size + startup time with PyInstaller before committing.
|
||||
|
||||
---
|
||||
|
||||
## Recap
|
||||
|
||||
* **Packaging & CI first** – lets every future feature ride an established release train.
|
||||
* **GUI lives in its own layer** – CLI stays stable; dev cycles remain quick.
|
||||
* **Security & signing** land after functionality is stable, before v1.0 marketing push.
|
||||
|
||||
Follow the phase table, keep weekly betas flowing, and you’ll reach a polished, installer-ready, GUI-enhanced 1.0 in roughly four months without sacrificing day-to-day agility.
|
41
docs/ARCHITECTURE.md
Normal file
41
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# SeedPass Architecture
|
||||
|
||||
SeedPass follows a layered design that keeps the security-critical logic isolated in a reusable core package. Interfaces like the command line tool, REST API and graphical client act as thin adapters around this core.
|
||||
|
||||
## Core Components
|
||||
|
||||
- **`seedpass.core`** – houses all encryption, key derivation and vault management code.
|
||||
- **`VaultService`** and **`EntryService`** – thread-safe wrappers exposing the main API.
|
||||
- **`PasswordManager`** – orchestrates vault operations, migrations and Nostr sync.
|
||||
|
||||
## Adapters
|
||||
|
||||
- **CLI/TUI** – implemented in [`seedpass.cli`](src/seedpass/cli.py). The [Advanced CLI](docs/docs/content/01-getting-started/01-advanced_cli.md) guide details all commands.
|
||||
- **FastAPI server** – defined in [`seedpass.api`](src/seedpass/api.py). See the [API Reference](docs/docs/content/01-getting-started/02-api_reference.md) for endpoints.
|
||||
- **BeeWare GUI** – located in [`seedpass_gui`](src/seedpass_gui/app.py) and explained in the [GUI Adapter](docs/docs/content/01-getting-started/06-gui_adapter.md) page.
|
||||
|
||||
## Planned Extensions
|
||||
|
||||
SeedPass is built to support additional adapters. Planned or experimental options include:
|
||||
|
||||
- A browser extension communicating with the API
|
||||
- Automation scripts using the CLI
|
||||
- Additional vault import/export helpers described in [JSON Entries](docs/docs/content/01-getting-started/03-json_entries.md)
|
||||
|
||||
## Overview Diagram
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
core["seedpass.core"]
|
||||
cli["CLI / TUI"]
|
||||
api["FastAPI server"]
|
||||
gui["BeeWare GUI"]
|
||||
ext["Browser extension"]
|
||||
|
||||
cli --> core
|
||||
api --> core
|
||||
gui --> core
|
||||
ext --> api
|
||||
```
|
||||
|
||||
All adapters depend on the same core, allowing features to evolve without duplicating logic across interfaces.
|
@@ -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,10 +112,14 @@ 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` |
|
||||
|
||||
If you see a startup warning about a script checksum mismatch,
|
||||
run `seedpass util update-checksum` or choose "Generate Script Checksum"
|
||||
from the Settings menu to update the stored value.
|
||||
|
||||
### API Commands
|
||||
|
||||
Run or stop the local HTTP API.
|
||||
@@ -132,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.
|
||||
@@ -181,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.
|
||||
|
||||
@@ -219,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.
|
||||
|
@@ -2,6 +2,9 @@
|
||||
|
||||
This guide covers how to start the SeedPass API, authenticate requests, and interact with the available endpoints.
|
||||
|
||||
**Note:** All UI layers, including the CLI, BeeWare GUI, and future adapters, consume this REST API through service classes in `seedpass.core`. See [docs/gui_adapter.md](docs/gui_adapter.md) for more details on the GUI integration.
|
||||
|
||||
|
||||
## Starting the API
|
||||
|
||||
Run `seedpass api start` from your terminal. The command prints a one‑time token used for authentication:
|
||||
|
@@ -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
|
||||
|
29
docs/docs/content/01-getting-started/05-briefcase.md
Normal file
29
docs/docs/content/01-getting-started/05-briefcase.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Packaging the GUI with Briefcase
|
||||
|
||||
This project uses [BeeWare's Briefcase](https://beeware.org) to generate
|
||||
platform‑native installers. Once your development environment is set up,
|
||||
package the GUI by running the following commands from the repository root:
|
||||
|
||||
```bash
|
||||
# Create the application scaffold for your platform
|
||||
briefcase create
|
||||
|
||||
# Compile dependencies and produce a distributable bundle
|
||||
briefcase build
|
||||
|
||||
# Run the packaged application
|
||||
briefcase run
|
||||
```
|
||||
|
||||
## Command Overview
|
||||
|
||||
- **`briefcase create`** — generates the project scaffold for your
|
||||
operating system. Run this once per platform.
|
||||
- **`briefcase build`** — compiles dependencies and produces the
|
||||
distributable bundle.
|
||||
- **`briefcase run`** — launches the packaged application so you can test
|
||||
it locally.
|
||||
|
||||
After the initial creation step you can repeatedly run `briefcase build`
|
||||
followed by `briefcase run` to test your packaged application on Windows,
|
||||
macOS or Linux.
|
158
docs/docs/content/01-getting-started/06-gui_adapter.md
Normal file
158
docs/docs/content/01-getting-started/06-gui_adapter.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# BeeWare GUI Adapter
|
||||
|
||||
SeedPass ships with a proof-of-concept graphical interface built using [BeeWare](https://beeware.org). The GUI interacts with the same core services as the CLI by instantiating wrappers around `PasswordManager`.
|
||||
|
||||
|
||||
## Getting Started with the GUI
|
||||
|
||||
After installing the project dependencies, launch the desktop interface with one
|
||||
of the following commands:
|
||||
|
||||
```bash
|
||||
seedpass gui
|
||||
python -m seedpass_gui
|
||||
seedpass-gui
|
||||
```
|
||||
|
||||
Only `toga-core` and the headless `toga-dummy` backend ship with the project.
|
||||
The installation scripts automatically install the correct BeeWare backend so
|
||||
the GUI works out of the box. If you set up SeedPass manually, install the
|
||||
backend for your platform:
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
pip install toga-gtk
|
||||
|
||||
# If installation fails with cairo errors, install libcairo2-dev or the
|
||||
# cairo development package using your distro's package manager.
|
||||
|
||||
# Windows
|
||||
pip install toga-winforms
|
||||
|
||||
# macOS
|
||||
pip install toga-cocoa
|
||||
```
|
||||
|
||||
The GUI shares the same encrypted vault and configuration as the command line tool.
|
||||
|
||||
To generate a packaged binary, run `briefcase build` (after the initial `briefcase create`).
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
core["seedpass.core"]
|
||||
cli["CLI"]
|
||||
api["FastAPI server"]
|
||||
gui["BeeWare GUI"]
|
||||
ext["Browser Extension"]
|
||||
|
||||
cli --> core
|
||||
gui --> core
|
||||
api --> core
|
||||
ext --> api
|
||||
```
|
||||
|
||||
## VaultService and EntryService
|
||||
|
||||
`VaultService` provides thread-safe access to vault operations like exporting, importing, unlocking and locking the vault. `EntryService` exposes methods for listing, searching and modifying entries. Both classes live in `seedpass.core.api` and hold a `PasswordManager` instance protected by a `threading.Lock` to ensure safe concurrent access.
|
||||
|
||||
```python
|
||||
class VaultService:
|
||||
"""Thread-safe wrapper around vault operations."""
|
||||
def __init__(self, manager: PasswordManager) -> None:
|
||||
self._manager = manager
|
||||
self._lock = Lock()
|
||||
```
|
||||
|
||||
```python
|
||||
class EntryService:
|
||||
"""Thread-safe wrapper around entry operations."""
|
||||
def __init__(self, manager: PasswordManager) -> None:
|
||||
self._manager = manager
|
||||
self._lock = Lock()
|
||||
```
|
||||
|
||||
## BeeWare Windows
|
||||
|
||||
The GUI defines two main windows in `src/seedpass_gui/app.py`. `LockScreenWindow` prompts for the master password and then opens `MainWindow` to display the vault entries.
|
||||
|
||||
```python
|
||||
class LockScreenWindow(toga.Window):
|
||||
"""Window prompting for the master password."""
|
||||
def __init__(self, app: SeedPassApp, vault: VaultService, entries: EntryService) -> None:
|
||||
super().__init__("Unlock Vault")
|
||||
self.app = app
|
||||
self.vault = vault
|
||||
self.entries = entries
|
||||
...
|
||||
```
|
||||
|
||||
```python
|
||||
class MainWindow(toga.Window):
|
||||
"""Main application window showing vault entries."""
|
||||
def __init__(self, app: SeedPassApp, vault: VaultService, entries: EntryService) -> None:
|
||||
super().__init__("SeedPass")
|
||||
self.app = app
|
||||
self.vault = vault
|
||||
self.entries = entries
|
||||
...
|
||||
```
|
||||
|
||||
Each window receives the service instances and calls methods such as `vault.unlock()` or `entries.add_entry()` when buttons are pressed. This keeps the UI thin while reusing the core logic.
|
||||
|
||||
## Asynchronous Synchronization
|
||||
|
||||
`PasswordManager` performs network synchronization with Nostr using `asyncio`. Methods like `start_background_vault_sync()` create a coroutine that calls `sync_vault_async()` in a background thread or task without blocking the UI.
|
||||
|
||||
```python
|
||||
async def sync_vault_async(self, alt_summary: str | None = None) -> dict[str, list[str] | str] | None:
|
||||
"""Publish the current vault contents to Nostr and return event IDs."""
|
||||
...
|
||||
```
|
||||
|
||||
```python
|
||||
def start_background_vault_sync(self, alt_summary: str | None = None) -> None:
|
||||
if getattr(self, "offline_mode", False):
|
||||
return
|
||||
def _worker() -> None:
|
||||
asyncio.run(self.sync_vault_async(alt_summary=alt_summary))
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
threading.Thread(target=_worker, daemon=True).start()
|
||||
else:
|
||||
asyncio.create_task(self.sync_vault_async(alt_summary=alt_summary))
|
||||
```
|
||||
|
||||
This approach ensures synchronization happens asynchronously whether the GUI is running inside or outside an existing event loop.
|
||||
|
||||
## Relay Manager and Status Bar
|
||||
|
||||
The *Relays* button opens a dialog for adding or removing Nostr relay URLs. The
|
||||
status bar at the bottom of the main window shows when the last synchronization
|
||||
completed. It updates automatically when `sync_started` and `sync_finished`
|
||||
events are published on the internal pubsub bus.
|
||||
|
||||
When a ``vault_locked`` event is emitted, the GUI automatically returns to the
|
||||
lock screen so the session can be reopened with the master password.
|
||||
|
||||
|
||||
## Event Handling
|
||||
|
||||
The GUI subscribes to a few core events so the interface reacts automatically when the vault changes state. When `MainWindow` is created it registers callbacks for `sync_started`, `sync_finished` and `vault_locked` on the global pubsub `bus`:
|
||||
|
||||
```python
|
||||
bus.subscribe("sync_started", self.sync_started)
|
||||
bus.subscribe("sync_finished", self.sync_finished)
|
||||
bus.subscribe("vault_locked", self.vault_locked)
|
||||
```
|
||||
|
||||
Each handler updates the status bar or returns to the lock screen. The `cleanup` method removes these hooks when the window closes:
|
||||
|
||||
```python
|
||||
def cleanup(self, *args: object, **kwargs: object) -> None:
|
||||
bus.unsubscribe("sync_started", self.sync_started)
|
||||
bus.unsubscribe("sync_finished", self.sync_finished)
|
||||
bus.unsubscribe("vault_locked", self.vault_locked)
|
||||
```
|
||||
|
||||
The [TOTP window](../../02-api_reference.md#totp) demonstrates how such events keep the UI fresh: it shows live two-factor codes that reflect the latest vault data after synchronization.
|
@@ -16,6 +16,22 @@ This software was not developed by an experienced security expert and should be
|
||||
✔ Windows 10/11 • macOS 12+ • Any modern Linux
|
||||
SeedPass now uses the `portalocker` library for cross-platform file locking. No WSL or Cygwin required.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
core(seedpass.core)
|
||||
cli(CLI/TUI)
|
||||
gui(BeeWare GUI)
|
||||
ext(Browser extension)
|
||||
cli --> core
|
||||
gui --> core
|
||||
ext --> core
|
||||
```
|
||||
|
||||
SeedPass uses a modular design with a single core library that handles all
|
||||
security-critical logic. The current CLI/TUI adapter communicates with
|
||||
`seedpass.core`, and future interfaces like a BeeWare GUI and a browser
|
||||
extension can hook into the same layer. This architecture keeps the codebase
|
||||
maintainable while enabling a consistent experience on multiple platforms.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -54,6 +70,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
||||
- **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.
|
||||
@@ -74,6 +91,8 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
||||
### 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
|
||||
@@ -191,16 +210,17 @@ create a backup:
|
||||
seedpass
|
||||
|
||||
# Export your index
|
||||
seedpass export --file "~/seedpass_backup.json"
|
||||
seedpass vault export --file "~/seedpass_backup.json"
|
||||
|
||||
# Later you can restore it
|
||||
seedpass import --file "~/seedpass_backup.json"
|
||||
seedpass vault import --file "~/seedpass_backup.json"
|
||||
# Import also performs a Nostr sync to pull any changes
|
||||
|
||||
# Quickly find or retrieve entries
|
||||
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
|
||||
@@ -296,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)**.
|
||||
@@ -347,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` |
|
||||
|
||||
|
||||
@@ -481,6 +510,10 @@ If the checksum file is missing, generate it manually:
|
||||
python scripts/update_checksum.py
|
||||
```
|
||||
|
||||
If SeedPass reports a "script checksum mismatch" warning on startup,
|
||||
regenerate the checksum with `seedpass util update-checksum` or select
|
||||
"Generate Script Checksum" from the Settings menu.
|
||||
|
||||
To run mutation tests locally, generate coverage data first and then execute `mutmut`:
|
||||
|
||||
```bash
|
||||
|
@@ -84,6 +84,26 @@ flowchart TB
|
||||
<h2 class="section-title" id="architecture-heading">Architecture Overview</h2>
|
||||
<pre class="mermaid">
|
||||
---
|
||||
config:
|
||||
layout: fixed
|
||||
theme: base
|
||||
themeVariables:
|
||||
primaryColor: '#e94a39'
|
||||
primaryBorderColor: '#e94a39'
|
||||
lineColor: '#e94a39'
|
||||
look: classic
|
||||
---
|
||||
graph TD
|
||||
core(seedpass.core)
|
||||
cli(CLI/TUI)
|
||||
gui(BeeWare GUI)
|
||||
ext(Browser extension)
|
||||
cli --> core
|
||||
gui --> core
|
||||
ext --> core
|
||||
</pre>
|
||||
<pre class="mermaid">
|
||||
---
|
||||
config:
|
||||
layout: fixed
|
||||
theme: base
|
||||
|
@@ -1,78 +0,0 @@
|
||||
---
|
||||
|
||||
# SeedPass Feature Back‑Log (v2)
|
||||
|
||||
> **Encryption invariant** Everything at rest **and** in export remains cipher‑text that ultimately derives from the **profile master‑password + parent seed**. No unencrypted payload leaves the vault.
|
||||
>
|
||||
> **Surface rule** UI layers (CLI, GUI, future mobile) may *display* decrypted data **after** user unlock, but must never write plaintext to disk or network.
|
||||
|
||||
---
|
||||
|
||||
## Track vocabulary
|
||||
|
||||
| Label | Meaning |
|
||||
| ------------ | ------------------------------------------------------------------------------ |
|
||||
| **Core API** | `seedpass.api` – headless services consumed by CLI / GUI |
|
||||
| **Profile** | A fingerprint‑scoped vault: parent‑seed + hashed pw + entries |
|
||||
| **Entry** | One encrypted JSON blob on disk plus Nostr snapshot chunks and delta events |
|
||||
| **GUI MVP** | Desktop app built with PySide 6 announced in the v2 roadmap |
|
||||
|
||||
---
|
||||
|
||||
## Phase A • Core‑level enhancements (blockers for GUI)
|
||||
|
||||
| Prio | Feature | Notes |
|
||||
| ------ | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🔥 | **Encrypted Search API** | • `VaultService.search(query:str, *, kinds=None) -> List[EntryMeta]` <br>• Decrypt *only* whitelisted meta‑fields per `kind` (title, username, url, tags) for in‑memory matching. |
|
||||
| 🔥 | **Rich Listing / Sort / Filter** | • `list_entries(sort_by="updated", kind="note")` <br>• Sorting by `title` must decrypt that field on‑the‑fly. |
|
||||
| 🔥 | **Custom Relay Set (per profile)** | • `StateManager.state["relays"]: List[str]` <br>• CRUD CLI commands & GUI dialog. <br>• `NostrClient` reads from state at instantiation. |
|
||||
| ⚡ | **Session Lock & Idle Timeout** | • Config `SESSION_TIMEOUT` (default 15 min). <br>• `AuthGuard` clears in‑memory keys & seeds. <br>• CLI `seedpass lock` + GUI menu “Lock vault”. |
|
||||
|
||||
**Exit‑criteria** : All functions green in CI, consumed by both CLI (Typer) *and* a minimal Qt test harness.
|
||||
|
||||
---
|
||||
|
||||
## Phase B • Data Portability (encrypted only)
|
||||
|
||||
| Prio | Feature | Notes | |
|
||||
| ------ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| ⭐ | **Encrypted Profile Export** | • CLI `seedpass export --out myprofile.enc` <br>• Serialise *encrypted* entry files → single JSON wrapper → `EncryptionManager.encrypt_data()` <br>• Always require active profile unlock. | |
|
||||
| ⭐ | **Encrypted Profile Import / Merge** | • CLI \`seedpass import myprofile.enc \[--strategy skip | overwrite-newer]` <br>• Verify fingerprint match before ingest. <br>• Conflict policy pluggable; default `skip\`. |
|
||||
|
||||
---
|
||||
|
||||
## Phase C • Advanced secrets & sync
|
||||
|
||||
| Prio | Feature | Notes |
|
||||
| ------ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ◇ | **TOTP entry kind** | • `kind="totp_secret"` fields: title, issuer, username, secret\_key <br>• `secret_key` encrypted; handler uses `pyotp` to show current code. |
|
||||
| ◇ | **Manual Conflict Resolver** | • When `checksum` mismatch *and* both sides newer than last sync → prompt user (CLI) or modal (GUI). |
|
||||
|
||||
---
|
||||
|
||||
## Phase D • Desktop GUI MVP (Qt 6)
|
||||
|
||||
*Features here ride on the Core API; keep UI totally stateless.*
|
||||
|
||||
| Prio | Feature | Notes |
|
||||
| ------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 🔥 | **Login Window** | • Unlock profile with master pw. <br>• Profile switcher drop‑down. |
|
||||
| 🔥 | **Vault Window** | • Sidebar (Entries, Search, Backups, Settings). <br>• `QTableView` bound to `VaultService.list_entries()` <br>• Sort & basic filters built‑in. |
|
||||
| 🔥 | **Entry Editor Dialog** | • Dynamic form driven by `kinds.py`. <br>• Add / Edit. |
|
||||
| ⭐ | **Sync Status Bar** | • Pulsing icon + last sync timestamp; hooks into `SyncService` bus. |
|
||||
| ◇ | **Relay Manager Dialog** | • CRUD & ping test per relay. |
|
||||
|
||||
*Binary packaging (PyInstaller matrix build) is already tracked in the roadmap and is not duplicated here.*
|
||||
|
||||
---
|
||||
|
||||
## Phase E • Later / Research
|
||||
|
||||
• Hardware‑wallet unlock (SLIP‑39 share)
|
||||
• Background daemon (`seedpassd` + gRPC)
|
||||
• Mobile companion (Flutter FFI)
|
||||
• Federated search across multiple profiles
|
||||
|
||||
---
|
||||
|
||||
**Reminder:** *No plaintext exports, no on‑disk temp files, and no writing decrypted data to Nostr.* Everything funnels through the encryption stack or stays in memory for the current unlocked session only.
|
@@ -2,10 +2,50 @@
|
||||
name = "seedpass"
|
||||
version = "0.1.0"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project.scripts]
|
||||
seedpass = "seedpass.cli:app"
|
||||
seedpass-gui = "seedpass_gui.app:main"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
strict = true
|
||||
mypy_path = "src"
|
||||
|
||||
[tool.briefcase.app.seedpass-gui]
|
||||
formal-name = "SeedPass"
|
||||
description = "Deterministic password manager with a BeeWare GUI"
|
||||
sources = ["src"]
|
||||
requires = [
|
||||
"toga-core>=0.5.2",
|
||||
"colorama>=0.4.6",
|
||||
"termcolor>=1.1.0",
|
||||
"cryptography>=40.0.2",
|
||||
"bip-utils>=2.5.0",
|
||||
"bech32==1.2.0",
|
||||
"coincurve>=18.0.0",
|
||||
"mnemonic",
|
||||
"aiohttp>=3.12.14",
|
||||
"bcrypt",
|
||||
"portalocker>=2.8",
|
||||
"nostr-sdk>=0.43",
|
||||
"websocket-client==1.7.0",
|
||||
"websockets>=15.0.0",
|
||||
"tomli",
|
||||
"pgpy==0.6.0",
|
||||
"pyotp>=2.8.0",
|
||||
"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",
|
||||
"orjson",
|
||||
"argon2-cffi",
|
||||
]
|
||||
icon = "logo/png/SeedPass-Logo-24.png"
|
||||
|
113
refactor.md
113
refactor.md
@@ -1,113 +0,0 @@
|
||||
# SeedPass v2 Roadmap — CLI → Desktop GUI
|
||||
|
||||
> **Guiding principles**
|
||||
>
|
||||
> 1. **Core-first** – a headless, testable Python package (`seedpass.core`) that is 100 % GUI-agnostic.
|
||||
> 2. **Thin adapters** – CLI, GUI, and future mobile layers merely call the core API.
|
||||
> 3. **Stateless UI** – all persistence lives in core services; UI never touches vault files directly.
|
||||
> 4. **Parity at every step** – CLI must keep working while GUI evolves.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 • Tooling Baseline
|
||||
|
||||
| # | Task | Rationale |
|
||||
| --- | ---------------------------------------------------------------------------------------------- | --------------------------------- |
|
||||
| 0.1 | ✅ **Adopt `poetry`** (or `hatch`) for builds & dependency pins. | Single-source version + lockfile. |
|
||||
| 0.2 | ✅ **GitHub Actions**: lint (ruff), type-check (mypy), tests (pytest -q), coverage gate ≥ 85 %. | Prevent regressions. |
|
||||
| 0.3 | ✅ Pre-commit hooks: ruff –fix, black, isort. | Uniform style. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 • Finalize Core Refactor (CLI still primary)
|
||||
|
||||
> *Most of this is already drafted – here’s what must ship before GUI work starts.*
|
||||
|
||||
| # | Component | Must-have work |
|
||||
| --- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| 1.1 | **`kinds.py` registry + per-kind handler modules** | import-safe; handler signature `(data,fingerprint,**svc)` |
|
||||
| 1.2 | **`StateManager`** | JSON file w/ fcntl lock<br>keys: `last_bip85_idx`, `last_sync_ts` |
|
||||
| 1.3 | **Checksum inside entry metadata** | `sha256(json.dumps(data,sort_keys=True))` |
|
||||
| 1.4 | **Replaceable Nostr events** (kind 31111, `d` tag = `"{kindtag}{entry_num}"`) | publish/update/delete tombstone |
|
||||
| 1.5 | **Per-entry `EntryManager` / `BackupManager`** | Save / load / backup / restore individual encrypted files |
|
||||
| 1.6 | **CLI rewritten with Typer** | Typer commands map 1-to-1 with core service methods; preserves colours. |
|
||||
| 1.7 | **Legacy index migration command** | `seedpass migrate-legacy` – idempotent, uses `add_entry()` under the hood. |
|
||||
| 1.8 | **bcrypt + NFKD master password hash** | Stored per fingerprint. |
|
||||
|
||||
> **Exit-criteria:** end-to-end flow (`add → list → sync → restore`) green in CI and covered by tests.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 • Core API Hardening (prep for GUI)
|
||||
|
||||
| # | Task | Deliverable |
|
||||
| --- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| 2.1 | **Public Service Layer** (`seedpass.api`) | Facade classes:<br>`VaultService`, `ProfileService`, `SyncService` – *no* CLI / UI imports. |
|
||||
| 2.2 | **Thread-safe gate** | Re-entrancy locks so GUI threads can call core safely. |
|
||||
| 2.3 | **Fast in-process event bus** | Simple `pubsub.py` (observer pattern) for GUI to receive progress callbacks (e.g. sync progress, long ops). |
|
||||
| 2.4 | **Docstrings + pydantic models** | Typed request/response objects → eases RPC later (e.g. REST, gRPC). |
|
||||
| 2.5 | **Library packaging** | `python -m pip install .` gives importable `seedpass`. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 • Desktop GUI MVP
|
||||
|
||||
| # | Decision | Notes |
|
||||
| --- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| 3.0 | **Framework: PySide 6 (Qt 6)** | ✓ LGPL, ✓ native look, ✓ Python-first, ✓ WebEngine if needed. |
|
||||
| 3.1 | **Process model** | *Same* process; GUI thread ↔ core API via signals/slots.<br>(If we outgrow this, swap to a local gRPC server later.) |
|
||||
| 3.2 | **UI Skeleton (milestone “Hello Vault”)** | |
|
||||
| – | `LoginWindow` | master-password prompt → opens default profile |
|
||||
| – | `VaultWindow` | sidebar (Profiles, Entries, Backups) + stacked views |
|
||||
| – | `EntryTableView` | QTableView bound to `VaultService.list_entries()` |
|
||||
| – | `EntryEditorDialog` | Add / Edit forms – field set driven by `kinds.py` |
|
||||
| – | `SyncStatusBar` | pulse animation + last-sync timestamp |
|
||||
| 3.3 | **Icons / theming** | Start with Qt-built-in icons; later swap to SVG set. |
|
||||
| 3.4 | **Packaging** | `PyInstaller --onefile` for Win / macOS / Linux AppImage; GitHub Actions matrix build. |
|
||||
| 3.5 | **GUI E2E tests** | PyTest + pytest-qt (QtBot) smoke flows; run headless in CI (Xvfb). |
|
||||
|
||||
> **Stretch option:** wrap the same UI in **Tauri** later for a lighter binary (\~5 MB), reusing the core API through a local websocket RPC.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 • Unified Workflows & Coverage
|
||||
|
||||
| # | Task |
|
||||
| --- | --------------------------------------------------------------------------------------- |
|
||||
| 4.1 | Extend GitHub Actions to build GUI artifacts on every tag. |
|
||||
| 4.2 | Add synthetic coverage for GUI code paths (QtBot). |
|
||||
| 4.3 | Nightly job: spin up headless GUI, run `sync` against test relay, assert no exceptions. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 • Future-Proofing (post-GUI v1)
|
||||
|
||||
| Idea | Sketch |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| **Background daemon** | Optional `seedpassd` exposing Unix socket + JSON-RPC; both CLI & GUI become thin clients. |
|
||||
| **Hardware-wallet unlock** | Replace master password with HWW + SLIP-39 share; requires PyUSB bridge. |
|
||||
| **Mobile companion app** | Reuse core via BeeWare or Flutter FFI; sync over Nostr only (no local vault). |
|
||||
| **End-to-end test farm** | dedicated relay docker-compose + pytest-subprocess to fake flaky relays. |
|
||||
|
||||
---
|
||||
|
||||
## Deliverables Checklist
|
||||
|
||||
* [ ] Core refactor merged, tests ≥ 85 % coverage
|
||||
* [ ] `seedpass` installs and passes `python -m seedpass.cli --help`
|
||||
* [ ] `seedpass-gui` binary opens vault, lists entries, adds & edits, syncs
|
||||
* [ ] GitHub Actions builds binaries for Win/macOS/Linux on tag
|
||||
* [ ] `docs/ARCHITECTURE.md` diagrams core ↔ CLI ↔ GUI layers
|
||||
|
||||
When the above are ✅ we can ship `v2.0.0-beta.1` and invite early desktop testers.
|
||||
|
||||
---
|
||||
|
||||
### 🔑 Key Takeaways
|
||||
|
||||
1. **Keep all state & crypto in the core package.**
|
||||
2. **Expose a clean Python API first – GUI is “just another client.”**
|
||||
3. **Checksum + replaceable Nostr events give rock-solid sync & conflict handling.**
|
||||
4. **Lock files and StateManager prevent index reuse and vault corruption.**
|
||||
5. **The GUI sprint starts only after Phase 1 + 2 are fully green in CI.**
|
||||
|
@@ -20,7 +20,7 @@ cryptography==45.0.4
|
||||
ecdsa==0.19.1
|
||||
ed25519-blake2b==1.4.1
|
||||
execnet==2.1.1
|
||||
fastapi==0.116.0
|
||||
fastapi==0.116.1
|
||||
frozenlist==1.7.0
|
||||
glob2==0.7
|
||||
hypothesis==6.135.20
|
||||
@@ -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
|
||||
@@ -61,6 +61,7 @@ toml==0.10.2
|
||||
tomli==2.2.1
|
||||
urllib3==2.5.0
|
||||
uvicorn==0.35.0
|
||||
starlette==0.47.2
|
||||
httpx==0.28.1
|
||||
varint==1.0.2
|
||||
websocket-client==1.7.0
|
||||
|
@@ -38,11 +38,11 @@ consts.SCRIPT_CHECKSUM_FILE = consts.APP_DIR / "seedpass_script_checksum.txt"
|
||||
|
||||
from constants import APP_DIR, initialize_app
|
||||
from utils.key_derivation import derive_key_from_password, derive_index_key
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from password_manager.vault import Vault
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.entry_management import EntryManager
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
from seedpass.core.vault import Vault
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from nostr.client import NostrClient
|
||||
from utils.fingerprint import generate_fingerprint
|
||||
from utils.fingerprint_manager import FingerprintManager
|
||||
|
@@ -260,6 +260,10 @@ if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to install SeedPass package"
|
||||
}
|
||||
|
||||
Write-Info "Installing BeeWare GUI backend..."
|
||||
& "$VenvDir\Scripts\python.exe" -m pip install toga-winforms
|
||||
if ($LASTEXITCODE -ne 0) { Write-Warning "Failed to install GUI backend" }
|
||||
|
||||
# 5. Create launcher script
|
||||
Write-Info "Creating launcher script..."
|
||||
if (-not (Test-Path $LauncherDir)) { New-Item -ItemType Directory -Path $LauncherDir | Out-Null }
|
||||
@@ -279,6 +283,18 @@ if ($existingSeedpass -and $existingSeedpass.Source -ne $LauncherPath) {
|
||||
Write-Warning "Ensure '$LauncherDir' comes first in your PATH or remove the old installation."
|
||||
}
|
||||
|
||||
# Detect additional seedpass executables on PATH that are not our launcher
|
||||
$allSeedpass = Get-Command seedpass -All -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source
|
||||
$stale = @()
|
||||
foreach ($cmd in $allSeedpass) {
|
||||
if ($cmd -ne $LauncherPath) { $stale += $cmd }
|
||||
}
|
||||
if ($stale.Count -gt 0) {
|
||||
Write-Warning "Stale 'seedpass' executables detected:"
|
||||
foreach ($cmd in $stale) { Write-Warning " - $cmd" }
|
||||
Write-Warning "Remove or rename these to avoid launching outdated code."
|
||||
}
|
||||
|
||||
# 6. Add launcher directory to User's PATH if needed
|
||||
Write-Info "Checking if '$LauncherDir' is in your PATH..."
|
||||
$UserPath = [System.Environment]::GetEnvironmentVariable("Path", "User")
|
||||
|
@@ -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,15 +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;
|
||||
elif command -v dnf &> /dev/null; then sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y pkg-config xclip;
|
||||
elif command -v pacman &> /dev/null; then sudo pacman -Syu --noconfirm base-devel pkg-config xclip;
|
||||
else print_warning "Could not detect package manager. Ensure build tools 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
|
||||
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
|
||||
@@ -120,6 +143,14 @@ main() {
|
||||
pip install --upgrade pip
|
||||
pip install -r src/requirements.txt
|
||||
pip install -e .
|
||||
print_info "Installing platform-specific Toga backend..."
|
||||
if [ "$OS_NAME" = "Linux" ]; then
|
||||
print_info "Installing toga-gtk for Linux..."
|
||||
pip install toga-gtk
|
||||
elif [ "$OS_NAME" = "Darwin" ]; then
|
||||
print_info "Installing toga-cocoa for macOS..."
|
||||
pip install toga-cocoa
|
||||
fi
|
||||
deactivate
|
||||
|
||||
# 7. Create launcher script
|
||||
@@ -138,6 +169,23 @@ EOF2
|
||||
print_warning "Ensure '$LAUNCHER_DIR' comes first in your PATH or remove the old installation."
|
||||
fi
|
||||
|
||||
# Detect any additional seedpass executables on PATH that are not our launcher
|
||||
IFS=':' read -ra _sp_paths <<< "$PATH"
|
||||
stale_cmds=()
|
||||
for _dir in "${_sp_paths[@]}"; do
|
||||
_candidate="$_dir/seedpass"
|
||||
if [ -x "$_candidate" ] && [ "$_candidate" != "$LAUNCHER_PATH" ]; then
|
||||
stale_cmds+=("$_candidate")
|
||||
fi
|
||||
done
|
||||
if [ ${#stale_cmds[@]} -gt 0 ]; then
|
||||
print_warning "Stale 'seedpass' executables detected:"
|
||||
for cmd in "${stale_cmds[@]}"; do
|
||||
print_warning " - $cmd"
|
||||
done
|
||||
print_warning "Remove or rename these to avoid launching outdated code."
|
||||
fi
|
||||
|
||||
# 8. Final instructions
|
||||
print_success "Installation/update complete!"
|
||||
print_info "You can now launch the interactive TUI by typing: seedpass"
|
||||
|
32
scripts/run_gui_tests.sh
Executable file
32
scripts/run_gui_tests.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
pytest_args=(-vv --desktop -m desktop src/tests)
|
||||
if [[ "${RUNNER_OS:-}" == "Windows" ]]; then
|
||||
pytest_args+=(-n 1)
|
||||
fi
|
||||
|
||||
timeout_bin="timeout"
|
||||
if ! command -v "$timeout_bin" >/dev/null 2>&1; then
|
||||
if command -v gtimeout >/dev/null 2>&1; then
|
||||
timeout_bin="gtimeout"
|
||||
else
|
||||
timeout_bin=""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$timeout_bin" ]]; then
|
||||
$timeout_bin 10m pytest "${pytest_args[@]}" 2>&1 | tee pytest_gui.log
|
||||
status=${PIPESTATUS[0]}
|
||||
else
|
||||
echo "timeout command not found; running tests without timeout" >&2
|
||||
pytest "${pytest_args[@]}" 2>&1 | tee pytest_gui.log
|
||||
status=${PIPESTATUS[0]}
|
||||
fi
|
||||
|
||||
if [[ $status -eq 124 ]]; then
|
||||
echo "::error::Desktop tests exceeded 10-minute limit"
|
||||
tail -n 20 pytest_gui.log
|
||||
exit 1
|
||||
fi
|
||||
exit $status
|
@@ -14,7 +14,7 @@ from constants import SCRIPT_CHECKSUM_FILE, initialize_app
|
||||
def main() -> None:
|
||||
"""Calculate checksum for the main script and write it to SCRIPT_CHECKSUM_FILE."""
|
||||
initialize_app()
|
||||
script_path = SRC_DIR / "password_manager" / "manager.py"
|
||||
script_path = SRC_DIR / "seedpass/core" / "manager.py"
|
||||
if not update_checksum_file(str(script_path), str(SCRIPT_CHECKSUM_FILE)):
|
||||
raise SystemExit(f"Failed to update checksum for {script_path}")
|
||||
print(f"Updated checksum written to {SCRIPT_CHECKSUM_FILE}")
|
||||
|
@@ -9,9 +9,11 @@ logger = logging.getLogger(__name__)
|
||||
# -----------------------------------
|
||||
# Nostr Relay Connection Settings
|
||||
# -----------------------------------
|
||||
# Retry fewer times with a shorter wait by default
|
||||
MAX_RETRIES = 2 # Maximum number of retries for relay connections
|
||||
RETRY_DELAY = 1 # Seconds to wait before retrying a failed connection
|
||||
# Retry fewer times with a shorter wait by default. These values
|
||||
# act as defaults that can be overridden via ``ConfigManager``
|
||||
# entries ``nostr_max_retries`` and ``nostr_retry_delay``.
|
||||
MAX_RETRIES = 2 # Default maximum number of retry attempts
|
||||
RETRY_DELAY = 1 # Default seconds to wait before retrying
|
||||
MIN_HEALTHY_RELAYS = 2 # Minimum relays that should return data on startup
|
||||
|
||||
# -----------------------------------
|
||||
@@ -48,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
|
||||
|
||||
|
108
src/main.py
108
src/main.py
@@ -20,9 +20,9 @@ from termcolor import colored
|
||||
from utils.color_scheme import color_text
|
||||
import traceback
|
||||
|
||||
from password_manager.manager import PasswordManager
|
||||
from seedpass.core.manager import PasswordManager
|
||||
from nostr.client import NostrClient
|
||||
from password_manager.entry_types import EntryType
|
||||
from seedpass.core.entry_types import EntryType
|
||||
from constants import INACTIVITY_TIMEOUT, initialize_app
|
||||
from utils.password_prompt import PasswordPromptError
|
||||
from utils import (
|
||||
@@ -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:
|
||||
@@ -365,14 +383,15 @@ def handle_post_to_nostr(
|
||||
Handles the action of posting the encrypted password index to Nostr.
|
||||
"""
|
||||
try:
|
||||
event_id = password_manager.sync_vault(alt_summary=alt_summary)
|
||||
if event_id:
|
||||
print(
|
||||
colored(
|
||||
f"\N{WHITE HEAVY CHECK MARK} Sync complete. Event ID: {event_id}",
|
||||
"green",
|
||||
)
|
||||
)
|
||||
result = password_manager.sync_vault(alt_summary=alt_summary)
|
||||
if result:
|
||||
print(colored("\N{WHITE HEAVY CHECK MARK} Sync complete.", "green"))
|
||||
print("Event IDs:")
|
||||
print(f" manifest: {result['manifest_id']}")
|
||||
for cid in result["chunk_ids"]:
|
||||
print(f" chunk: {cid}")
|
||||
for did in result["delta_ids"]:
|
||||
print(f" delta: {did}")
|
||||
logging.info("Encrypted index posted to Nostr successfully.")
|
||||
else:
|
||||
print(colored("\N{CROSS MARK} Sync failed…", "red"))
|
||||
@@ -389,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
|
||||
@@ -406,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"))
|
||||
@@ -432,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:
|
||||
@@ -1023,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:
|
||||
@@ -1226,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}")
|
||||
@@ -1244,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}")
|
||||
@@ -1254,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}")
|
||||
@@ -1264,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}")
|
||||
|
@@ -14,6 +14,7 @@ class ChunkMeta:
|
||||
id: str
|
||||
size: int
|
||||
hash: str
|
||||
event_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@@ -8,6 +8,7 @@ from typing import List, Optional, Tuple, TYPE_CHECKING
|
||||
import hashlib
|
||||
import asyncio
|
||||
import gzip
|
||||
import threading
|
||||
import websockets
|
||||
|
||||
# Imports from the nostr-sdk library
|
||||
@@ -20,18 +21,19 @@ from nostr_sdk import (
|
||||
Kind,
|
||||
KindStandard,
|
||||
Tag,
|
||||
RelayUrl,
|
||||
)
|
||||
from datetime import timedelta
|
||||
from nostr_sdk import EventId, Timestamp
|
||||
|
||||
from .key_manager import KeyManager as SeedPassKeyManager
|
||||
from .backup_models import Manifest, ChunkMeta, KIND_MANIFEST, KIND_SNAPSHOT_CHUNK
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
from constants import MAX_RETRIES, RETRY_DELAY
|
||||
from utils.file_lock import exclusive_lock
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - imported for type hints
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
# Backwards compatibility for tests that patch these symbols
|
||||
KeyManager = SeedPassKeyManager
|
||||
@@ -46,6 +48,9 @@ DEFAULT_RELAYS = [
|
||||
"wss://relay.primal.net",
|
||||
]
|
||||
|
||||
# Identifier prefix for replaceable manifest events
|
||||
MANIFEST_ID_PREFIX = "seedpass-manifest-"
|
||||
|
||||
|
||||
def prepare_snapshot(
|
||||
encrypted_bytes: bytes, limit: int
|
||||
@@ -78,6 +83,7 @@ def prepare_snapshot(
|
||||
id=f"seedpass-chunk-{i:04d}",
|
||||
size=len(chunk),
|
||||
hash=hashlib.sha256(chunk).hexdigest(),
|
||||
event_id=None,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -134,6 +140,7 @@ class NostrClient:
|
||||
self.last_error: Optional[str] = None
|
||||
|
||||
self.delta_threshold = 100
|
||||
self._state_lock = threading.Lock()
|
||||
self.current_manifest: Manifest | None = None
|
||||
self.current_manifest_id: str | None = None
|
||||
self._delta_events: list[str] = []
|
||||
@@ -167,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."""
|
||||
@@ -294,8 +313,8 @@ class NostrClient:
|
||||
|
||||
if retries is None or delay is None:
|
||||
if self.config_manager is None:
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from password_manager.vault import Vault
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.vault import Vault
|
||||
|
||||
cfg_mgr = ConfigManager(
|
||||
Vault(self.encryption_manager, self.fingerprint_dir),
|
||||
@@ -309,8 +328,7 @@ class NostrClient:
|
||||
|
||||
self.connect()
|
||||
self.last_error = None
|
||||
attempt = 0
|
||||
while True:
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
result = asyncio.run(self._retrieve_json_from_nostr())
|
||||
if result is not None:
|
||||
@@ -318,10 +336,9 @@ class NostrClient:
|
||||
except Exception as e:
|
||||
self.last_error = str(e)
|
||||
logger.error("Failed to retrieve events from Nostr: %s", e)
|
||||
if attempt >= retries:
|
||||
break
|
||||
attempt += 1
|
||||
time.sleep(delay)
|
||||
if attempt < retries - 1:
|
||||
sleep_time = delay * (2**attempt)
|
||||
time.sleep(sleep_time)
|
||||
return None
|
||||
|
||||
async def _retrieve_json_from_nostr(self) -> Optional[bytes]:
|
||||
@@ -364,6 +381,7 @@ class NostrClient:
|
||||
start = time.perf_counter()
|
||||
if self.offline_mode or not self.relays:
|
||||
return Manifest(ver=1, algo="gzip", chunks=[]), ""
|
||||
await self.ensure_manifest_is_current()
|
||||
await self._connect_async()
|
||||
manifest, chunks = prepare_snapshot(encrypted_bytes, limit)
|
||||
for meta, chunk in zip(manifest.chunks, chunks):
|
||||
@@ -372,7 +390,13 @@ class NostrClient:
|
||||
[Tag.identifier(meta.id)]
|
||||
)
|
||||
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
|
||||
await self.client.send_event(event)
|
||||
result = await self.client.send_event(event)
|
||||
try:
|
||||
meta.event_id = (
|
||||
result.id.to_hex() if hasattr(result, "id") else str(result)
|
||||
)
|
||||
except Exception:
|
||||
meta.event_id = None
|
||||
|
||||
manifest_json = json.dumps(
|
||||
{
|
||||
@@ -383,22 +407,106 @@ class NostrClient:
|
||||
}
|
||||
)
|
||||
|
||||
manifest_identifier = f"{MANIFEST_ID_PREFIX}{self.fingerprint}"
|
||||
manifest_event = (
|
||||
EventBuilder(Kind(KIND_MANIFEST), manifest_json)
|
||||
.tags([Tag.identifier(manifest_identifier)])
|
||||
.build(self.keys.public_key())
|
||||
.sign_with_keys(self.keys)
|
||||
)
|
||||
result = await self.client.send_event(manifest_event)
|
||||
manifest_id = result.id.to_hex() if hasattr(result, "id") else str(result)
|
||||
self.current_manifest = manifest
|
||||
self.current_manifest_id = manifest_id
|
||||
# Record when this snapshot was published for future delta events
|
||||
self.current_manifest.delta_since = int(time.time())
|
||||
self._delta_events = []
|
||||
await self.client.send_event(manifest_event)
|
||||
with self._state_lock:
|
||||
self.current_manifest = manifest
|
||||
self.current_manifest_id = manifest_identifier
|
||||
# Record when this snapshot was published for future delta events
|
||||
self.current_manifest.delta_since = int(time.time())
|
||||
self._delta_events = []
|
||||
if getattr(self, "verbose_timing", False):
|
||||
duration = time.perf_counter() - start
|
||||
logger.info("publish_snapshot completed in %.2f seconds", duration)
|
||||
return manifest, manifest_id
|
||||
return manifest, manifest_identifier
|
||||
|
||||
async def _fetch_chunks_with_retry(
|
||||
self, manifest_event
|
||||
) -> tuple[Manifest, list[bytes]] | None:
|
||||
"""Retrieve all chunks referenced by ``manifest_event`` with retries."""
|
||||
|
||||
pubkey = self.keys.public_key()
|
||||
timeout = timedelta(seconds=10)
|
||||
|
||||
try:
|
||||
data = json.loads(manifest_event.content())
|
||||
manifest = Manifest(
|
||||
ver=data["ver"],
|
||||
algo=data["algo"],
|
||||
chunks=[ChunkMeta(**c) for c in data["chunks"]],
|
||||
delta_since=(
|
||||
int(data["delta_since"])
|
||||
if data.get("delta_since") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if self.config_manager is None:
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.vault import Vault
|
||||
|
||||
cfg_mgr = ConfigManager(
|
||||
Vault(self.encryption_manager, self.fingerprint_dir),
|
||||
self.fingerprint_dir,
|
||||
)
|
||||
else:
|
||||
cfg_mgr = self.config_manager
|
||||
cfg = cfg_mgr.load_config(require_pin=False)
|
||||
max_retries = int(cfg.get("nostr_max_retries", MAX_RETRIES))
|
||||
delay = float(cfg.get("nostr_retry_delay", RETRY_DELAY))
|
||||
|
||||
chunks: list[bytes] = []
|
||||
for meta in manifest.chunks:
|
||||
chunk_bytes: bytes | None = None
|
||||
for attempt in range(max_retries):
|
||||
cf = Filter().author(pubkey).kind(Kind(KIND_SNAPSHOT_CHUNK))
|
||||
if meta.event_id:
|
||||
cf = cf.id(EventId.parse(meta.event_id))
|
||||
else:
|
||||
cf = cf.identifier(meta.id)
|
||||
cf = cf.limit(1)
|
||||
cev = (await self.client.fetch_events(cf, timeout)).to_vec()
|
||||
if cev:
|
||||
candidate = base64.b64decode(cev[0].content().encode("utf-8"))
|
||||
if hashlib.sha256(candidate).hexdigest() == meta.hash:
|
||||
chunk_bytes = candidate
|
||||
break
|
||||
if attempt < max_retries - 1:
|
||||
await asyncio.sleep(delay * (2**attempt))
|
||||
if chunk_bytes is None:
|
||||
return None
|
||||
chunks.append(chunk_bytes)
|
||||
|
||||
ident = None
|
||||
try:
|
||||
tags_obj = manifest_event.tags()
|
||||
ident = tags_obj.identifier()
|
||||
except Exception:
|
||||
tags = getattr(manifest_event, "tags", None)
|
||||
if callable(tags):
|
||||
tags = tags()
|
||||
if tags:
|
||||
tag = tags[0]
|
||||
if hasattr(tag, "as_vec"):
|
||||
vec = tag.as_vec()
|
||||
if vec and len(vec) >= 2:
|
||||
ident = vec[1]
|
||||
elif isinstance(tag, (list, tuple)) and len(tag) >= 2:
|
||||
ident = tag[1]
|
||||
elif isinstance(tag, str):
|
||||
ident = tag
|
||||
with self._state_lock:
|
||||
self.current_manifest = manifest
|
||||
self.current_manifest_id = ident
|
||||
return manifest, chunks
|
||||
|
||||
async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None:
|
||||
"""Retrieve the latest manifest and all snapshot chunks."""
|
||||
@@ -406,54 +514,76 @@ class NostrClient:
|
||||
return None
|
||||
await self._connect_async()
|
||||
|
||||
self.last_error = None
|
||||
pubkey = self.keys.public_key()
|
||||
f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).limit(1)
|
||||
ident = f"{MANIFEST_ID_PREFIX}{self.fingerprint}"
|
||||
f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).identifier(ident).limit(1)
|
||||
timeout = timedelta(seconds=10)
|
||||
events = (await self.client.fetch_events(f, timeout)).to_vec()
|
||||
try:
|
||||
events = (await self.client.fetch_events(f, timeout)).to_vec()
|
||||
except Exception as e: # pragma: no cover - network errors
|
||||
self.last_error = str(e)
|
||||
logger.error(
|
||||
"Failed to fetch manifest from relays %s: %s",
|
||||
self.relays,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
if not events:
|
||||
return None
|
||||
manifest_event = events[0]
|
||||
manifest_raw = manifest_event.content()
|
||||
data = json.loads(manifest_raw)
|
||||
manifest = Manifest(
|
||||
ver=data["ver"],
|
||||
algo=data["algo"],
|
||||
chunks=[ChunkMeta(**c) for c in data["chunks"]],
|
||||
delta_since=(
|
||||
int(data["delta_since"])
|
||||
if data.get("delta_since") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
chunks: list[bytes] = []
|
||||
for meta in manifest.chunks:
|
||||
cf = (
|
||||
Filter()
|
||||
.author(pubkey)
|
||||
.kind(Kind(KIND_SNAPSHOT_CHUNK))
|
||||
.identifier(meta.id)
|
||||
.limit(1)
|
||||
)
|
||||
cev = (await self.client.fetch_events(cf, timeout)).to_vec()
|
||||
if not cev:
|
||||
raise ValueError(f"Missing chunk {meta.id}")
|
||||
chunk_bytes = base64.b64decode(cev[0].content().encode("utf-8"))
|
||||
if hashlib.sha256(chunk_bytes).hexdigest() != meta.hash:
|
||||
raise ValueError(f"Checksum mismatch for chunk {meta.id}")
|
||||
chunks.append(chunk_bytes)
|
||||
for manifest_event in events:
|
||||
try:
|
||||
result = await self._fetch_chunks_with_retry(manifest_event)
|
||||
if result is not None:
|
||||
return result
|
||||
except Exception as e: # pragma: no cover - network errors
|
||||
self.last_error = str(e)
|
||||
logger.error(
|
||||
"Error retrieving snapshot from relays %s: %s",
|
||||
self.relays,
|
||||
e,
|
||||
)
|
||||
|
||||
self.current_manifest = manifest
|
||||
man_id = getattr(manifest_event, "id", None)
|
||||
if hasattr(man_id, "to_hex"):
|
||||
man_id = man_id.to_hex()
|
||||
self.current_manifest_id = man_id
|
||||
return manifest, chunks
|
||||
if self.last_error is None:
|
||||
self.last_error = "Snapshot not found on relays"
|
||||
|
||||
return None
|
||||
|
||||
async def ensure_manifest_is_current(self) -> None:
|
||||
"""Verify the local manifest is up to date before publishing."""
|
||||
if self.offline_mode or not self.relays:
|
||||
return
|
||||
await self._connect_async()
|
||||
pubkey = self.keys.public_key()
|
||||
ident = f"{MANIFEST_ID_PREFIX}{self.fingerprint}"
|
||||
f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).identifier(ident).limit(1)
|
||||
timeout = timedelta(seconds=10)
|
||||
try:
|
||||
events = (await self.client.fetch_events(f, timeout)).to_vec()
|
||||
except Exception:
|
||||
return
|
||||
if not events:
|
||||
return
|
||||
try:
|
||||
data = json.loads(events[0].content())
|
||||
remote = data.get("delta_since")
|
||||
if remote is not None:
|
||||
remote = int(remote)
|
||||
except Exception:
|
||||
return
|
||||
with self._state_lock:
|
||||
local = self.current_manifest.delta_since if self.current_manifest else None
|
||||
if remote is not None and (local is None or remote > local):
|
||||
self.last_error = "Manifest out of date"
|
||||
raise RuntimeError("Manifest out of date")
|
||||
|
||||
async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str:
|
||||
"""Publish a delta event referencing a manifest."""
|
||||
if self.offline_mode or not self.relays:
|
||||
return ""
|
||||
await self.ensure_manifest_is_current()
|
||||
await self._connect_async()
|
||||
|
||||
content = base64.b64encode(delta_bytes).decode("utf-8")
|
||||
@@ -467,24 +597,29 @@ class NostrClient:
|
||||
)
|
||||
if hasattr(created_at, "secs"):
|
||||
created_at = created_at.secs
|
||||
if self.current_manifest is not None:
|
||||
self.current_manifest.delta_since = int(created_at)
|
||||
manifest_json = json.dumps(
|
||||
{
|
||||
"ver": self.current_manifest.ver,
|
||||
"algo": self.current_manifest.algo,
|
||||
"chunks": [meta.__dict__ for meta in self.current_manifest.chunks],
|
||||
"delta_since": self.current_manifest.delta_since,
|
||||
}
|
||||
)
|
||||
manifest_event = (
|
||||
EventBuilder(Kind(KIND_MANIFEST), manifest_json)
|
||||
.tags([Tag.identifier(self.current_manifest_id)])
|
||||
.build(self.keys.public_key())
|
||||
.sign_with_keys(self.keys)
|
||||
)
|
||||
manifest_event = None
|
||||
with self._state_lock:
|
||||
if self.current_manifest is not None:
|
||||
self.current_manifest.delta_since = int(created_at)
|
||||
manifest_json = json.dumps(
|
||||
{
|
||||
"ver": self.current_manifest.ver,
|
||||
"algo": self.current_manifest.algo,
|
||||
"chunks": [
|
||||
meta.__dict__ for meta in self.current_manifest.chunks
|
||||
],
|
||||
"delta_since": self.current_manifest.delta_since,
|
||||
}
|
||||
)
|
||||
manifest_event = (
|
||||
EventBuilder(Kind(KIND_MANIFEST), manifest_json)
|
||||
.tags([Tag.identifier(self.current_manifest_id)])
|
||||
.build(self.keys.public_key())
|
||||
.sign_with_keys(self.keys)
|
||||
)
|
||||
self._delta_events.append(delta_id)
|
||||
if manifest_event is not None:
|
||||
await self.client.send_event(manifest_event)
|
||||
self._delta_events.append(delta_id)
|
||||
return delta_id
|
||||
|
||||
async def fetch_deltas_since(self, version: int) -> list[bytes]:
|
||||
@@ -502,12 +637,16 @@ class NostrClient:
|
||||
)
|
||||
timeout = timedelta(seconds=10)
|
||||
events = (await self.client.fetch_events(f, timeout)).to_vec()
|
||||
events.sort(
|
||||
key=lambda ev: getattr(ev, "created_at", getattr(ev, "timestamp", 0))
|
||||
)
|
||||
deltas: list[bytes] = []
|
||||
for ev in events:
|
||||
deltas.append(base64.b64decode(ev.content().encode("utf-8")))
|
||||
|
||||
if self.current_manifest is not None:
|
||||
snap_size = sum(c.size for c in self.current_manifest.chunks)
|
||||
manifest = self.get_current_manifest()
|
||||
if manifest is not None:
|
||||
snap_size = sum(c.size for c in manifest.chunks)
|
||||
if (
|
||||
len(deltas) >= self.delta_threshold
|
||||
or sum(len(d) for d in deltas) > snap_size
|
||||
@@ -526,6 +665,21 @@ class NostrClient:
|
||||
await self.client.send_event(exp_event)
|
||||
return deltas
|
||||
|
||||
def get_current_manifest(self) -> Manifest | None:
|
||||
"""Thread-safe access to ``current_manifest``."""
|
||||
with self._state_lock:
|
||||
return self.current_manifest
|
||||
|
||||
def get_current_manifest_id(self) -> str | None:
|
||||
"""Thread-safe access to ``current_manifest_id``."""
|
||||
with self._state_lock:
|
||||
return self.current_manifest_id
|
||||
|
||||
def get_delta_events(self) -> list[str]:
|
||||
"""Thread-safe snapshot of pending delta event IDs."""
|
||||
with self._state_lock:
|
||||
return list(self._delta_events)
|
||||
|
||||
def close_client_pool(self) -> None:
|
||||
"""Disconnects the client from all relays."""
|
||||
try:
|
||||
|
@@ -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
|
||||
@@ -25,10 +25,14 @@ freezegun
|
||||
pyperclip
|
||||
qrcode>=8.2
|
||||
typer>=0.12.3
|
||||
fastapi>=0.116.0
|
||||
fastapi>=0.116.1
|
||||
uvicorn>=0.35.0
|
||||
starlette>=0.47.2
|
||||
httpx>=0.28.1
|
||||
requests>=2.32
|
||||
python-multipart
|
||||
orjson
|
||||
argon2-cffi
|
||||
toga-core>=0.5.2
|
||||
pillow
|
||||
toga-dummy>=0.5.2 # for headless GUI tests
|
||||
|
@@ -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
|
||||
@@ -27,3 +27,4 @@ requests>=2.32
|
||||
python-multipart
|
||||
orjson
|
||||
argon2-cffi
|
||||
toga-core>=0.5.2
|
||||
|
@@ -14,8 +14,9 @@ import asyncio
|
||||
import sys
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from password_manager.manager import PasswordManager
|
||||
from password_manager.entry_types import EntryType
|
||||
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:
|
||||
@@ -554,14 +571,40 @@ def backup_parent_seed(
|
||||
|
||||
|
||||
@app.post("/api/v1/change-password")
|
||||
def change_password(authorization: str | None = Header(None)) -> dict[str, str]:
|
||||
def change_password(
|
||||
data: dict, 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()
|
||||
_pm.change_password(data.get("old", ""), data.get("new", ""))
|
||||
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."""
|
||||
|
@@ -1,15 +1,32 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
import json
|
||||
|
||||
import typer
|
||||
import sys
|
||||
|
||||
from password_manager.manager import PasswordManager
|
||||
from password_manager.entry_types import EntryType
|
||||
from seedpass.core.manager import PasswordManager
|
||||
from seedpass.core.entry_types import EntryType
|
||||
from seedpass.core.api import (
|
||||
VaultService,
|
||||
ProfileService,
|
||||
SyncService,
|
||||
EntryService,
|
||||
ConfigService,
|
||||
UtilityService,
|
||||
NostrService,
|
||||
ChangePasswordRequest,
|
||||
UnlockRequest,
|
||||
BackupParentSeedRequest,
|
||||
ProfileSwitchRequest,
|
||||
ProfileRemoveRequest,
|
||||
)
|
||||
import uvicorn
|
||||
from . import api as api_module
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import subprocess
|
||||
|
||||
app = typer.Typer(
|
||||
help="SeedPass command line interface",
|
||||
@@ -52,6 +69,43 @@ def _get_pm(ctx: typer.Context) -> PasswordManager:
|
||||
return pm
|
||||
|
||||
|
||||
def _get_services(
|
||||
ctx: typer.Context,
|
||||
) -> tuple[VaultService, ProfileService, SyncService]:
|
||||
"""Return service layer instances for the current context."""
|
||||
|
||||
pm = _get_pm(ctx)
|
||||
return VaultService(pm), ProfileService(pm), SyncService(pm)
|
||||
|
||||
|
||||
def _get_entry_service(ctx: typer.Context) -> EntryService:
|
||||
pm = _get_pm(ctx)
|
||||
return EntryService(pm)
|
||||
|
||||
|
||||
def _get_config_service(ctx: typer.Context) -> ConfigService:
|
||||
pm = _get_pm(ctx)
|
||||
return ConfigService(pm)
|
||||
|
||||
|
||||
def _get_util_service(ctx: typer.Context) -> UtilityService:
|
||||
pm = _get_pm(ctx)
|
||||
return UtilityService(pm)
|
||||
|
||||
|
||||
def _get_nostr_service(ctx: typer.Context) -> NostrService:
|
||||
pm = _get_pm(ctx)
|
||||
return NostrService(pm)
|
||||
|
||||
|
||||
def _gui_backend_available() -> bool:
|
||||
"""Return True if a platform-specific BeeWare backend is installed."""
|
||||
for pkg in ("toga_gtk", "toga_winforms", "toga_cocoa"):
|
||||
if importlib.util.find_spec(pkg) is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None:
|
||||
"""SeedPass CLI entry point.
|
||||
@@ -68,14 +122,14 @@ def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) ->
|
||||
def entry_list(
|
||||
ctx: typer.Context,
|
||||
sort: str = typer.Option(
|
||||
"index", "--sort", help="Sort by 'index', 'label', or 'username'"
|
||||
"index", "--sort", help="Sort by 'index', 'label', or 'updated'"
|
||||
),
|
||||
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(
|
||||
service = _get_entry_service(ctx)
|
||||
entries = service.list_entries(
|
||||
sort_by=sort, filter_kind=kind, include_archived=archived
|
||||
)
|
||||
for idx, label, username, url, is_archived in entries:
|
||||
@@ -90,15 +144,25 @@ def entry_list(
|
||||
|
||||
|
||||
@entry_app.command("search")
|
||||
def entry_search(ctx: typer.Context, query: str) -> None:
|
||||
def entry_search(
|
||||
ctx: typer.Context,
|
||||
query: str,
|
||||
kind: List[str] = typer.Option(
|
||||
None,
|
||||
"--kind",
|
||||
"-k",
|
||||
help="Filter by entry kinds (can be repeated)",
|
||||
),
|
||||
) -> None:
|
||||
"""Search entries."""
|
||||
pm = _get_pm(ctx)
|
||||
results = pm.entry_manager.search_entries(query)
|
||||
service = _get_entry_service(ctx)
|
||||
kinds = list(kind) if kind else None
|
||||
results = service.search_entries(query, kinds=kinds)
|
||||
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:
|
||||
@@ -109,29 +173,29 @@ def entry_search(ctx: typer.Context, query: str) -> None:
|
||||
@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)
|
||||
service = _get_entry_service(ctx)
|
||||
matches = service.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}"
|
||||
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)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
index = matches[0][0]
|
||||
entry = pm.entry_manager.retrieve_entry(index)
|
||||
entry = service.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)
|
||||
password = service.generate_password(length, index)
|
||||
typer.echo(password)
|
||||
elif etype == EntryType.TOTP.value:
|
||||
code = pm.entry_manager.get_totp_code(index, pm.parent_seed)
|
||||
code = service.get_totp_code(index)
|
||||
typer.echo(code)
|
||||
else:
|
||||
typer.echo("Unsupported entry type")
|
||||
@@ -145,12 +209,50 @@ 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."""
|
||||
pm = _get_pm(ctx)
|
||||
index = pm.entry_manager.add_entry(label, length, username, url)
|
||||
service = _get_entry_service(ctx)
|
||||
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))
|
||||
pm.sync_vault()
|
||||
|
||||
|
||||
@entry_app.command("add-totp")
|
||||
@@ -163,17 +265,15 @@ def entry_add_totp(
|
||||
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(
|
||||
service = _get_entry_service(ctx)
|
||||
uri = service.add_totp(
|
||||
label,
|
||||
pm.parent_seed,
|
||||
index=index,
|
||||
secret=secret,
|
||||
period=period,
|
||||
digits=digits,
|
||||
)
|
||||
typer.echo(uri)
|
||||
pm.sync_vault()
|
||||
|
||||
|
||||
@entry_app.command("add-ssh")
|
||||
@@ -184,15 +284,13 @@ def entry_add_ssh(
|
||||
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(
|
||||
service = _get_entry_service(ctx)
|
||||
idx = service.add_ssh_key(
|
||||
label,
|
||||
pm.parent_seed,
|
||||
index=index,
|
||||
notes=notes,
|
||||
)
|
||||
typer.echo(str(idx))
|
||||
pm.sync_vault()
|
||||
|
||||
|
||||
@entry_app.command("add-pgp")
|
||||
@@ -205,17 +303,15 @@ def entry_add_pgp(
|
||||
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(
|
||||
service = _get_entry_service(ctx)
|
||||
idx = service.add_pgp_key(
|
||||
label,
|
||||
pm.parent_seed,
|
||||
index=index,
|
||||
key_type=key_type,
|
||||
user_id=user_id,
|
||||
notes=notes,
|
||||
)
|
||||
typer.echo(str(idx))
|
||||
pm.sync_vault()
|
||||
|
||||
|
||||
@entry_app.command("add-nostr")
|
||||
@@ -226,14 +322,13 @@ def entry_add_nostr(
|
||||
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(
|
||||
service = _get_entry_service(ctx)
|
||||
idx = service.add_nostr_key(
|
||||
label,
|
||||
index=index,
|
||||
notes=notes,
|
||||
)
|
||||
typer.echo(str(idx))
|
||||
pm.sync_vault()
|
||||
|
||||
|
||||
@entry_app.command("add-seed")
|
||||
@@ -245,30 +340,28 @@ def entry_add_seed(
|
||||
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(
|
||||
service = _get_entry_service(ctx)
|
||||
idx = service.add_seed(
|
||||
label,
|
||||
pm.parent_seed,
|
||||
index=index,
|
||||
words_num=words,
|
||||
words=words,
|
||||
notes=notes,
|
||||
)
|
||||
typer.echo(str(idx))
|
||||
pm.sync_vault()
|
||||
|
||||
|
||||
@entry_app.command("add-key-value")
|
||||
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."""
|
||||
pm = _get_pm(ctx)
|
||||
idx = pm.entry_manager.add_key_value(label, value, notes=notes)
|
||||
service = _get_entry_service(ctx)
|
||||
idx = service.add_key_value(label, key, value, notes=notes)
|
||||
typer.echo(str(idx))
|
||||
pm.sync_vault()
|
||||
|
||||
|
||||
@entry_app.command("add-managed-account")
|
||||
@@ -279,15 +372,13 @@ def entry_add_managed_account(
|
||||
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(
|
||||
service = _get_entry_service(ctx)
|
||||
idx = service.add_managed_account(
|
||||
label,
|
||||
pm.parent_seed,
|
||||
index=index,
|
||||
notes=notes,
|
||||
)
|
||||
typer.echo(str(idx))
|
||||
pm.sync_vault()
|
||||
|
||||
|
||||
@entry_app.command("modify")
|
||||
@@ -302,12 +393,13 @@ 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."""
|
||||
pm = _get_pm(ctx)
|
||||
service = _get_entry_service(ctx)
|
||||
try:
|
||||
pm.entry_manager.modify_entry(
|
||||
service.modify_entry(
|
||||
entry_id,
|
||||
username=username,
|
||||
url=url,
|
||||
@@ -315,37 +407,36 @@ def entry_modify(
|
||||
label=label,
|
||||
period=period,
|
||||
digits=digits,
|
||||
key=key,
|
||||
value=value,
|
||||
)
|
||||
except ValueError as e:
|
||||
typer.echo(str(e))
|
||||
sys.stdout.flush()
|
||||
raise typer.Exit(code=1)
|
||||
pm.sync_vault()
|
||||
|
||||
|
||||
@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)
|
||||
service = _get_entry_service(ctx)
|
||||
service.archive_entry(entry_id)
|
||||
typer.echo(str(entry_id))
|
||||
pm.sync_vault()
|
||||
|
||||
|
||||
@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)
|
||||
service = _get_entry_service(ctx)
|
||||
service.restore_entry(entry_id)
|
||||
typer.echo(str(entry_id))
|
||||
pm.sync_vault()
|
||||
|
||||
|
||||
@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()
|
||||
service = _get_entry_service(ctx)
|
||||
service.display_totp_codes()
|
||||
|
||||
|
||||
@entry_app.command("export-totp")
|
||||
@@ -353,8 +444,8 @@ 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)
|
||||
service = _get_entry_service(ctx)
|
||||
data = service.export_totp_entries()
|
||||
Path(file).write_text(json.dumps(data, indent=2))
|
||||
typer.echo(str(file))
|
||||
|
||||
@@ -363,9 +454,10 @@ def entry_export_totp(
|
||||
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))
|
||||
"""Export the vault profile to an encrypted file."""
|
||||
vault_service, _profile, _sync = _get_services(ctx)
|
||||
data = vault_service.export_profile()
|
||||
Path(file).write_bytes(data)
|
||||
typer.echo(str(file))
|
||||
|
||||
|
||||
@@ -373,33 +465,63 @@ def vault_export(
|
||||
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))
|
||||
pm.sync_vault()
|
||||
"""Import a vault profile from an encrypted file."""
|
||||
vault_service, _profile, _sync = _get_services(ctx)
|
||||
data = Path(file).read_bytes()
|
||||
vault_service.import_profile(data)
|
||||
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_service, _profile, _sync = _get_services(ctx)
|
||||
old_pw = typer.prompt("Current password", hide_input=True)
|
||||
new_pw = typer.prompt("New password", hide_input=True, confirmation_prompt=True)
|
||||
try:
|
||||
vault_service.change_password(
|
||||
ChangePasswordRequest(old_password=old_pw, new_password=new_pw)
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - pass through errors
|
||||
typer.echo(f"Error: {exc}")
|
||||
raise typer.Exit(code=1)
|
||||
typer.echo("Password updated")
|
||||
|
||||
|
||||
@vault_app.command("unlock")
|
||||
def vault_unlock(ctx: typer.Context) -> None:
|
||||
"""Unlock the vault for the active profile."""
|
||||
vault_service, _profile, _sync = _get_services(ctx)
|
||||
password = typer.prompt("Master password", hide_input=True)
|
||||
try:
|
||||
resp = vault_service.unlock(UnlockRequest(password=password))
|
||||
except Exception as exc: # pragma: no cover - pass through errors
|
||||
typer.echo(f"Error: {exc}")
|
||||
raise typer.Exit(code=1)
|
||||
typer.echo(f"Unlocked in {resp.duration:.2f}s")
|
||||
|
||||
|
||||
@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()
|
||||
vault_service, _profile, _sync = _get_services(ctx)
|
||||
vault_service.lock()
|
||||
typer.echo("locked")
|
||||
|
||||
|
||||
@app.command("lock")
|
||||
def root_lock(ctx: typer.Context) -> None:
|
||||
"""Lock the vault for the active profile."""
|
||||
vault_service, _profile, _sync = _get_services(ctx)
|
||||
vault_service.lock()
|
||||
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()
|
||||
vault_service, _profile, _sync = _get_services(ctx)
|
||||
stats = vault_service.stats()
|
||||
typer.echo(json.dumps(stats, indent=2))
|
||||
|
||||
|
||||
@@ -411,17 +533,25 @@ def vault_reveal_parent_seed(
|
||||
),
|
||||
) -> 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)
|
||||
vault_service, _profile, _sync = _get_services(ctx)
|
||||
password = typer.prompt("Master password", hide_input=True)
|
||||
vault_service.backup_parent_seed(
|
||||
BackupParentSeedRequest(path=Path(file) if file else None, password=password)
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
_vault, _profile, sync_service = _get_services(ctx)
|
||||
model = sync_service.sync()
|
||||
if model:
|
||||
typer.echo("Event IDs:")
|
||||
typer.echo(f"- manifest: {model.manifest_id}")
|
||||
for cid in model.chunk_ids:
|
||||
typer.echo(f"- chunk: {cid}")
|
||||
for did in model.delta_ids:
|
||||
typer.echo(f"- delta: {did}")
|
||||
else:
|
||||
typer.echo("Error: Failed to sync vault")
|
||||
|
||||
@@ -429,16 +559,49 @@ def nostr_sync(ctx: typer.Context) -> None:
|
||||
@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()
|
||||
service = _get_nostr_service(ctx)
|
||||
npub = service.get_pubkey()
|
||||
typer.echo(npub)
|
||||
|
||||
|
||||
@nostr_app.command("list-relays")
|
||||
def nostr_list_relays(ctx: typer.Context) -> None:
|
||||
"""Display configured Nostr relays."""
|
||||
service = _get_nostr_service(ctx)
|
||||
relays = service.list_relays()
|
||||
for i, r in enumerate(relays, 1):
|
||||
typer.echo(f"{i}: {r}")
|
||||
|
||||
|
||||
@nostr_app.command("add-relay")
|
||||
def nostr_add_relay(ctx: typer.Context, url: str) -> None:
|
||||
"""Add a relay URL."""
|
||||
service = _get_nostr_service(ctx)
|
||||
try:
|
||||
service.add_relay(url)
|
||||
except Exception as exc: # pragma: no cover - pass through errors
|
||||
typer.echo(f"Error: {exc}")
|
||||
raise typer.Exit(code=1)
|
||||
typer.echo("Added")
|
||||
|
||||
|
||||
@nostr_app.command("remove-relay")
|
||||
def nostr_remove_relay(ctx: typer.Context, idx: int) -> None:
|
||||
"""Remove a relay by index (1-based)."""
|
||||
service = _get_nostr_service(ctx)
|
||||
try:
|
||||
service.remove_relay(idx)
|
||||
except Exception as exc: # pragma: no cover - pass through errors
|
||||
typer.echo(f"Error: {exc}")
|
||||
raise typer.Exit(code=1)
|
||||
typer.echo("Removed")
|
||||
|
||||
|
||||
@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)
|
||||
service = _get_config_service(ctx)
|
||||
value = service.get(key)
|
||||
if value is None:
|
||||
typer.echo("Key not found")
|
||||
else:
|
||||
@@ -448,43 +611,18 @@ def config_get(ctx: typer.Context, key: str) -> None:
|
||||
@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
|
||||
),
|
||||
"kdf_iterations": lambda v: cfg.set_kdf_iterations(int(v)),
|
||||
"kdf_mode": lambda v: cfg.set_kdf_mode(v),
|
||||
"backup_interval": lambda v: cfg.set_backup_interval(float(v)),
|
||||
"nostr_max_retries": lambda v: cfg.set_nostr_max_retries(int(v)),
|
||||
"nostr_retry_delay": lambda v: cfg.set_nostr_retry_delay(float(v)),
|
||||
"min_uppercase": lambda v: cfg.set_min_uppercase(int(v)),
|
||||
"min_lowercase": lambda v: cfg.set_min_lowercase(int(v)),
|
||||
"min_digits": lambda v: cfg.set_min_digits(int(v)),
|
||||
"min_special": lambda v: cfg.set_min_special(int(v)),
|
||||
"quick_unlock": lambda v: cfg.set_quick_unlock(
|
||||
v.lower() in ("1", "true", "yes", "y", "on")
|
||||
),
|
||||
"verbose_timing": lambda v: cfg.set_verbose_timing(
|
||||
v.lower() in ("1", "true", "yes", "y", "on")
|
||||
),
|
||||
}
|
||||
|
||||
action = mapping.get(key)
|
||||
if action is None:
|
||||
typer.echo("Unknown key")
|
||||
raise typer.Exit(code=1)
|
||||
service = _get_config_service(ctx)
|
||||
|
||||
try:
|
||||
action(value)
|
||||
val = (
|
||||
[r.strip() for r in value.split(",") if r.strip()]
|
||||
if key == "relays"
|
||||
else value
|
||||
)
|
||||
service.set(key, val)
|
||||
except KeyError:
|
||||
typer.echo("Unknown key")
|
||||
raise typer.Exit(code=1)
|
||||
except Exception as exc: # pragma: no cover - pass through errors
|
||||
typer.echo(f"Error: {exc}")
|
||||
raise typer.Exit(code=1)
|
||||
@@ -494,12 +632,15 @@ def config_set(ctx: typer.Context, key: str, value: str) -> None:
|
||||
|
||||
@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
|
||||
"""Interactively enable or disable secret mode.
|
||||
|
||||
When enabled, newly generated and retrieved passwords are copied to the
|
||||
clipboard instead of printed to the screen.
|
||||
"""
|
||||
service = _get_config_service(ctx)
|
||||
try:
|
||||
enabled = cfg.get_secret_mode_enabled()
|
||||
delay = cfg.get_clipboard_clear_delay()
|
||||
enabled = service.get_secret_mode_enabled()
|
||||
delay = service.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)
|
||||
@@ -531,10 +672,7 @@ def config_toggle_secret_mode(ctx: typer.Context) -> None:
|
||||
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
|
||||
service.set_secret_mode(enabled, delay)
|
||||
except Exception as exc: # pragma: no cover - pass through errors
|
||||
typer.echo(f"Error: {exc}")
|
||||
raise typer.Exit(code=1)
|
||||
@@ -546,10 +684,9 @@ def config_toggle_secret_mode(ctx: typer.Context) -> None:
|
||||
@config_app.command("toggle-offline")
|
||||
def config_toggle_offline(ctx: typer.Context) -> None:
|
||||
"""Enable or disable offline mode."""
|
||||
pm = _get_pm(ctx)
|
||||
cfg = pm.config_manager
|
||||
service = _get_config_service(ctx)
|
||||
try:
|
||||
enabled = cfg.get_offline_mode()
|
||||
enabled = service.get_offline_mode()
|
||||
except Exception as exc: # pragma: no cover - pass through errors
|
||||
typer.echo(f"Error loading settings: {exc}")
|
||||
raise typer.Exit(code=1)
|
||||
@@ -568,8 +705,7 @@ def config_toggle_offline(ctx: typer.Context) -> None:
|
||||
enabled = False
|
||||
|
||||
try:
|
||||
cfg.set_offline_mode(enabled)
|
||||
pm.offline_mode = enabled
|
||||
service.set_offline_mode(enabled)
|
||||
except Exception as exc: # pragma: no cover - pass through errors
|
||||
typer.echo(f"Error: {exc}")
|
||||
raise typer.Exit(code=1)
|
||||
@@ -581,52 +717,97 @@ def config_toggle_offline(ctx: typer.Context) -> None:
|
||||
@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():
|
||||
_vault, profile_service, _sync = _get_services(ctx)
|
||||
for fp in profile_service.list_profiles():
|
||||
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()
|
||||
_vault, profile_service, _sync = _get_services(ctx)
|
||||
profile_service.add_profile()
|
||||
|
||||
|
||||
@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)
|
||||
_vault, profile_service, _sync = _get_services(ctx)
|
||||
profile_service.remove_profile(ProfileRemoveRequest(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)
|
||||
_vault, profile_service, _sync = _get_services(ctx)
|
||||
password = typer.prompt("Master password", hide_input=True)
|
||||
profile_service.switch_profile(
|
||||
ProfileSwitchRequest(fingerprint=fingerprint, password=password)
|
||||
)
|
||||
|
||||
|
||||
@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."""
|
||||
pm = _get_pm(ctx)
|
||||
password = pm.password_generator.generate_password(length)
|
||||
service = _get_util_service(ctx)
|
||||
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)
|
||||
|
||||
|
||||
@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()
|
||||
service = _get_util_service(ctx)
|
||||
service.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()
|
||||
service = _get_util_service(ctx)
|
||||
service.update_checksum()
|
||||
|
||||
|
||||
@api_app.command("start")
|
||||
@@ -652,5 +833,46 @@ def api_stop(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> N
|
||||
typer.echo(f"Failed to stop server: {exc}")
|
||||
|
||||
|
||||
@app.command()
|
||||
def gui() -> None:
|
||||
"""Launch the BeeWare GUI.
|
||||
|
||||
If the platform specific backend is missing, attempt to install it and
|
||||
retry launching the GUI.
|
||||
"""
|
||||
if not _gui_backend_available():
|
||||
if sys.platform.startswith("linux"):
|
||||
pkg = "toga-gtk"
|
||||
elif sys.platform == "win32":
|
||||
pkg = "toga-winforms"
|
||||
elif sys.platform == "darwin":
|
||||
pkg = "toga-cocoa"
|
||||
else:
|
||||
typer.echo(
|
||||
f"Unsupported platform '{sys.platform}' for BeeWare GUI.",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
typer.echo(f"Attempting to install {pkg} for GUI support...")
|
||||
try:
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install", pkg])
|
||||
typer.echo(f"Successfully installed {pkg}.")
|
||||
except subprocess.CalledProcessError as exc:
|
||||
typer.echo(f"Failed to install {pkg}: {exc}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not _gui_backend_available():
|
||||
typer.echo(
|
||||
"BeeWare GUI backend still unavailable after installation attempt.",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
from seedpass_gui.app import main
|
||||
|
||||
main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
@@ -1,10 +1,17 @@
|
||||
# password_manager/__init__.py
|
||||
# seedpass.core/__init__.py
|
||||
|
||||
"""Expose password manager components with lazy imports."""
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
__all__ = ["PasswordManager", "ConfigManager", "Vault", "EntryType"]
|
||||
__all__ = [
|
||||
"PasswordManager",
|
||||
"ConfigManager",
|
||||
"Vault",
|
||||
"EntryType",
|
||||
"StateManager",
|
||||
"StatsManager",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
@@ -16,4 +23,8 @@ def __getattr__(name: str):
|
||||
return import_module(".vault", __name__).Vault
|
||||
if name == "EntryType":
|
||||
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}'")
|
705
src/seedpass/core/api.py
Normal file
705
src/seedpass/core/api.py
Normal file
@@ -0,0 +1,705 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Service layer wrapping :class:`PasswordManager` operations.
|
||||
|
||||
These services provide thread-safe methods for common operations used by the CLI
|
||||
and API. Request and response payloads are represented using Pydantic models to
|
||||
allow easy validation and documentation.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
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):
|
||||
"""Parameters required to export the vault."""
|
||||
|
||||
path: Path
|
||||
|
||||
|
||||
class VaultExportResponse(BaseModel):
|
||||
"""Result of a vault export operation."""
|
||||
|
||||
path: Path
|
||||
|
||||
|
||||
class VaultImportRequest(BaseModel):
|
||||
"""Parameters required to import a vault."""
|
||||
|
||||
path: Path
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
"""Payload for :meth:`VaultService.change_password`."""
|
||||
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class UnlockRequest(BaseModel):
|
||||
"""Payload for unlocking the vault."""
|
||||
|
||||
password: str
|
||||
|
||||
|
||||
class UnlockResponse(BaseModel):
|
||||
"""Duration taken to unlock the vault."""
|
||||
|
||||
duration: float
|
||||
|
||||
|
||||
class BackupParentSeedRequest(BaseModel):
|
||||
"""Optional path to write the encrypted seed backup."""
|
||||
|
||||
path: Optional[Path] = None
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
class ProfileSwitchRequest(BaseModel):
|
||||
"""Select a different seed profile."""
|
||||
|
||||
fingerprint: str
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
class ProfileRemoveRequest(BaseModel):
|
||||
"""Remove a seed profile."""
|
||||
|
||||
fingerprint: str
|
||||
|
||||
|
||||
class SyncResponse(BaseModel):
|
||||
"""Information about uploaded events after syncing."""
|
||||
|
||||
manifest_id: str
|
||||
chunk_ids: List[str] = []
|
||||
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."""
|
||||
|
||||
def __init__(self, manager: PasswordManager) -> None:
|
||||
self._manager = manager
|
||||
self._lock = Lock()
|
||||
|
||||
def export_vault(self, req: VaultExportRequest) -> VaultExportResponse:
|
||||
"""Export the vault to ``req.path``."""
|
||||
|
||||
with self._lock:
|
||||
self._manager.handle_export_database(req.path)
|
||||
return VaultExportResponse(path=req.path)
|
||||
|
||||
def import_vault(self, req: VaultImportRequest) -> None:
|
||||
"""Import the vault from ``req.path`` and sync."""
|
||||
|
||||
with self._lock:
|
||||
self._manager.handle_import_database(req.path)
|
||||
self._manager.sync_vault()
|
||||
|
||||
def export_profile(self) -> bytes:
|
||||
"""Return encrypted profile data for backup."""
|
||||
|
||||
with self._lock:
|
||||
data = self._manager.vault.load_index()
|
||||
payload = json.dumps(data, sort_keys=True, separators=(",", ":")).encode(
|
||||
"utf-8"
|
||||
)
|
||||
return self._manager.vault.encryption_manager.encrypt_data(payload)
|
||||
|
||||
def import_profile(self, data: bytes) -> None:
|
||||
"""Restore a profile from ``data`` and sync."""
|
||||
|
||||
with self._lock:
|
||||
decrypted = self._manager.vault.encryption_manager.decrypt_data(data)
|
||||
index = json.loads(decrypted.decode("utf-8"))
|
||||
self._manager.vault.save_index(index)
|
||||
self._manager.sync_vault()
|
||||
|
||||
def change_password(self, req: ChangePasswordRequest) -> None:
|
||||
"""Change the master password."""
|
||||
|
||||
with self._lock:
|
||||
self._manager.change_password(req.old_password, req.new_password)
|
||||
|
||||
def unlock(self, req: UnlockRequest) -> UnlockResponse:
|
||||
"""Unlock the vault and return the duration."""
|
||||
|
||||
with self._lock:
|
||||
duration = self._manager.unlock_vault(req.password)
|
||||
return UnlockResponse(duration=duration)
|
||||
|
||||
def lock(self) -> None:
|
||||
"""Lock the vault and clear sensitive data."""
|
||||
|
||||
with self._lock:
|
||||
self._manager.lock_vault()
|
||||
|
||||
def backup_parent_seed(self, req: BackupParentSeedRequest) -> None:
|
||||
"""Backup and reveal the parent seed."""
|
||||
|
||||
with self._lock:
|
||||
self._manager.handle_backup_reveal_parent_seed(
|
||||
req.path, password=req.password
|
||||
)
|
||||
|
||||
def stats(self) -> Dict:
|
||||
"""Return statistics about the current profile."""
|
||||
|
||||
with self._lock:
|
||||
return self._manager.get_profile_stats()
|
||||
|
||||
|
||||
class ProfileService:
|
||||
"""Thread-safe wrapper around profile management operations."""
|
||||
|
||||
def __init__(self, manager: PasswordManager) -> None:
|
||||
self._manager = manager
|
||||
self._lock = Lock()
|
||||
|
||||
def list_profiles(self) -> List[str]:
|
||||
"""List available seed profiles."""
|
||||
|
||||
with self._lock:
|
||||
return list(self._manager.fingerprint_manager.list_fingerprints())
|
||||
|
||||
def add_profile(self) -> Optional[str]:
|
||||
"""Create a new seed profile and return its fingerprint if available."""
|
||||
|
||||
with self._lock:
|
||||
self._manager.add_new_fingerprint()
|
||||
return getattr(
|
||||
self._manager.fingerprint_manager, "current_fingerprint", None
|
||||
)
|
||||
|
||||
def remove_profile(self, req: ProfileRemoveRequest) -> None:
|
||||
"""Remove the specified seed profile."""
|
||||
|
||||
with self._lock:
|
||||
self._manager.fingerprint_manager.remove_fingerprint(req.fingerprint)
|
||||
|
||||
def switch_profile(self, req: ProfileSwitchRequest) -> None:
|
||||
"""Switch to ``req.fingerprint``."""
|
||||
|
||||
with self._lock:
|
||||
self._manager.select_fingerprint(req.fingerprint, password=req.password)
|
||||
|
||||
|
||||
class SyncService:
|
||||
"""Thread-safe wrapper around vault synchronization."""
|
||||
|
||||
def __init__(self, manager: PasswordManager) -> None:
|
||||
self._manager = manager
|
||||
self._lock = Lock()
|
||||
|
||||
def sync(self) -> Optional[SyncResponse]:
|
||||
"""Publish the vault to Nostr and return event info."""
|
||||
|
||||
with self._lock:
|
||||
bus.publish("sync_started")
|
||||
result = self._manager.sync_vault()
|
||||
bus.publish("sync_finished", result)
|
||||
if not result:
|
||||
return None
|
||||
return SyncResponse(**result)
|
||||
|
||||
def start_background_sync(self) -> None:
|
||||
"""Begin background synchronization if possible."""
|
||||
|
||||
with self._lock:
|
||||
self._manager.start_background_sync()
|
||||
|
||||
def start_background_vault_sync(self, summary: Optional[str] = None) -> None:
|
||||
"""Publish the vault in a background thread."""
|
||||
|
||||
with self._lock:
|
||||
self._manager.start_background_vault_sync(summary)
|
||||
|
||||
|
||||
class EntryService:
|
||||
"""Thread-safe wrapper around entry operations."""
|
||||
|
||||
def __init__(self, manager: PasswordManager) -> None:
|
||||
self._manager = manager
|
||||
self._lock = Lock()
|
||||
|
||||
def list_entries(
|
||||
self,
|
||||
sort_by: str = "index",
|
||||
filter_kind: str | None = None,
|
||||
include_archived: bool = False,
|
||||
):
|
||||
with self._lock:
|
||||
return self._manager.entry_manager.list_entries(
|
||||
sort_by=sort_by,
|
||||
filter_kind=filter_kind,
|
||||
include_archived=include_archived,
|
||||
)
|
||||
|
||||
def search_entries(
|
||||
self, query: str, kinds: list[str] | None = None
|
||||
) -> list[tuple[int, str, str | None, str | None, bool, EntryType]]:
|
||||
"""Search entries optionally filtering by ``kinds``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
query:
|
||||
Search string to match against entry metadata.
|
||||
kinds:
|
||||
Optional list of entry kinds to restrict the search.
|
||||
"""
|
||||
|
||||
with self._lock:
|
||||
return self._manager.entry_manager.search_entries(query, kinds=kinds)
|
||||
|
||||
def retrieve_entry(self, entry_id: int):
|
||||
with self._lock:
|
||||
return self._manager.entry_manager.retrieve_entry(entry_id)
|
||||
|
||||
def generate_password(self, length: int, index: int) -> str:
|
||||
with self._lock:
|
||||
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:
|
||||
return self._manager.entry_manager.get_totp_code(
|
||||
entry_id, self._manager.parent_seed
|
||||
)
|
||||
|
||||
def add_entry(
|
||||
self,
|
||||
label: str,
|
||||
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:
|
||||
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
|
||||
|
||||
def add_totp(
|
||||
self,
|
||||
label: str,
|
||||
*,
|
||||
index: int | None = None,
|
||||
secret: str | None = None,
|
||||
period: int = 30,
|
||||
digits: int = 6,
|
||||
) -> str:
|
||||
with self._lock:
|
||||
uri = self._manager.entry_manager.add_totp(
|
||||
label,
|
||||
self._manager.parent_seed,
|
||||
index=index,
|
||||
secret=secret,
|
||||
period=period,
|
||||
digits=digits,
|
||||
)
|
||||
self._manager.start_background_vault_sync()
|
||||
return uri
|
||||
|
||||
def add_ssh_key(
|
||||
self,
|
||||
label: str,
|
||||
*,
|
||||
index: int | None = None,
|
||||
notes: str = "",
|
||||
) -> int:
|
||||
with self._lock:
|
||||
idx = self._manager.entry_manager.add_ssh_key(
|
||||
label,
|
||||
self._manager.parent_seed,
|
||||
index=index,
|
||||
notes=notes,
|
||||
)
|
||||
self._manager.start_background_vault_sync()
|
||||
return idx
|
||||
|
||||
def add_pgp_key(
|
||||
self,
|
||||
label: str,
|
||||
*,
|
||||
index: int | None = None,
|
||||
key_type: str = "ed25519",
|
||||
user_id: str = "",
|
||||
notes: str = "",
|
||||
) -> int:
|
||||
with self._lock:
|
||||
idx = self._manager.entry_manager.add_pgp_key(
|
||||
label,
|
||||
self._manager.parent_seed,
|
||||
index=index,
|
||||
key_type=key_type,
|
||||
user_id=user_id,
|
||||
notes=notes,
|
||||
)
|
||||
self._manager.start_background_vault_sync()
|
||||
return idx
|
||||
|
||||
def add_nostr_key(
|
||||
self,
|
||||
label: str,
|
||||
*,
|
||||
index: int | None = None,
|
||||
notes: str = "",
|
||||
) -> int:
|
||||
with self._lock:
|
||||
idx = self._manager.entry_manager.add_nostr_key(
|
||||
label,
|
||||
self._manager.parent_seed,
|
||||
index=index,
|
||||
notes=notes,
|
||||
)
|
||||
self._manager.start_background_vault_sync()
|
||||
return idx
|
||||
|
||||
def add_seed(
|
||||
self,
|
||||
label: str,
|
||||
*,
|
||||
index: int | None = None,
|
||||
words: int = 24,
|
||||
notes: str = "",
|
||||
) -> int:
|
||||
with self._lock:
|
||||
idx = self._manager.entry_manager.add_seed(
|
||||
label,
|
||||
self._manager.parent_seed,
|
||||
index=index,
|
||||
words_num=words,
|
||||
notes=notes,
|
||||
)
|
||||
self._manager.start_background_vault_sync()
|
||||
return idx
|
||||
|
||||
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, key, value, notes=notes
|
||||
)
|
||||
self._manager.start_background_vault_sync()
|
||||
return idx
|
||||
|
||||
def add_managed_account(
|
||||
self,
|
||||
label: str,
|
||||
*,
|
||||
index: int | None = None,
|
||||
notes: str = "",
|
||||
) -> int:
|
||||
with self._lock:
|
||||
idx = self._manager.entry_manager.add_managed_account(
|
||||
label,
|
||||
self._manager.parent_seed,
|
||||
index=index,
|
||||
notes=notes,
|
||||
)
|
||||
self._manager.start_background_vault_sync()
|
||||
return idx
|
||||
|
||||
def modify_entry(
|
||||
self,
|
||||
entry_id: int,
|
||||
*,
|
||||
username: str | None = None,
|
||||
url: str | None = None,
|
||||
notes: str | None = None,
|
||||
label: str | None = None,
|
||||
period: int | None = None,
|
||||
digits: int | None = None,
|
||||
key: str | None = None,
|
||||
value: str | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._manager.entry_manager.modify_entry(
|
||||
entry_id,
|
||||
username=username,
|
||||
url=url,
|
||||
notes=notes,
|
||||
label=label,
|
||||
period=period,
|
||||
digits=digits,
|
||||
key=key,
|
||||
value=value,
|
||||
)
|
||||
self._manager.start_background_vault_sync()
|
||||
|
||||
def archive_entry(self, entry_id: int) -> None:
|
||||
with self._lock:
|
||||
self._manager.entry_manager.archive_entry(entry_id)
|
||||
self._manager.start_background_vault_sync()
|
||||
|
||||
def restore_entry(self, entry_id: int) -> None:
|
||||
with self._lock:
|
||||
self._manager.entry_manager.restore_entry(entry_id)
|
||||
self._manager.start_background_vault_sync()
|
||||
|
||||
def export_totp_entries(self) -> dict:
|
||||
with self._lock:
|
||||
return self._manager.entry_manager.export_totp_entries(
|
||||
self._manager.parent_seed
|
||||
)
|
||||
|
||||
def display_totp_codes(self) -> None:
|
||||
with self._lock:
|
||||
self._manager.handle_display_totp_codes()
|
||||
|
||||
|
||||
class ConfigService:
|
||||
"""Thread-safe wrapper around configuration access."""
|
||||
|
||||
def __init__(self, manager: PasswordManager) -> None:
|
||||
self._manager = manager
|
||||
self._lock = Lock()
|
||||
|
||||
def get(self, key: str):
|
||||
with self._lock:
|
||||
return self._manager.config_manager.load_config(require_pin=False).get(key)
|
||||
|
||||
def set(self, key: str, value: str) -> None:
|
||||
cfg = self._manager.config_manager
|
||||
mapping = {
|
||||
"inactivity_timeout": ("set_inactivity_timeout", float),
|
||||
"secret_mode_enabled": (
|
||||
"set_secret_mode_enabled",
|
||||
lambda v: v.lower() in ("1", "true", "yes", "y", "on"),
|
||||
),
|
||||
"clipboard_clear_delay": ("set_clipboard_clear_delay", int),
|
||||
"additional_backup_path": (
|
||||
"set_additional_backup_path",
|
||||
lambda v: v or None,
|
||||
),
|
||||
"relays": ("set_relays", lambda v: (v, {"require_pin": False})),
|
||||
"kdf_iterations": ("set_kdf_iterations", int),
|
||||
"kdf_mode": ("set_kdf_mode", lambda v: v),
|
||||
"backup_interval": ("set_backup_interval", float),
|
||||
"nostr_max_retries": ("set_nostr_max_retries", int),
|
||||
"nostr_retry_delay": ("set_nostr_retry_delay", float),
|
||||
"min_uppercase": ("set_min_uppercase", int),
|
||||
"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"),
|
||||
),
|
||||
}
|
||||
entry = mapping.get(key)
|
||||
if entry is None:
|
||||
raise KeyError(key)
|
||||
method_name, conv = entry
|
||||
with self._lock:
|
||||
result = conv(value)
|
||||
if (
|
||||
isinstance(result, tuple)
|
||||
and len(result) == 2
|
||||
and isinstance(result[1], dict)
|
||||
):
|
||||
arg, kwargs = result
|
||||
getattr(cfg, method_name)(arg, **kwargs)
|
||||
else:
|
||||
getattr(cfg, method_name)(result)
|
||||
|
||||
def get_secret_mode_enabled(self) -> bool:
|
||||
with self._lock:
|
||||
return self._manager.config_manager.get_secret_mode_enabled()
|
||||
|
||||
def get_clipboard_clear_delay(self) -> int:
|
||||
with self._lock:
|
||||
return self._manager.config_manager.get_clipboard_clear_delay()
|
||||
|
||||
def set_secret_mode(self, enabled: bool, delay: int) -> None:
|
||||
with self._lock:
|
||||
cfg = self._manager.config_manager
|
||||
cfg.set_secret_mode_enabled(enabled)
|
||||
cfg.set_clipboard_clear_delay(delay)
|
||||
self._manager.secret_mode_enabled = enabled
|
||||
self._manager.clipboard_clear_delay = delay
|
||||
|
||||
def get_offline_mode(self) -> bool:
|
||||
with self._lock:
|
||||
return self._manager.config_manager.get_offline_mode()
|
||||
|
||||
def set_offline_mode(self, enabled: bool) -> None:
|
||||
with self._lock:
|
||||
cfg = self._manager.config_manager
|
||||
cfg.set_offline_mode(enabled)
|
||||
self._manager.offline_mode = enabled
|
||||
|
||||
|
||||
class UtilityService:
|
||||
"""Miscellaneous helper operations."""
|
||||
|
||||
def __init__(self, manager: PasswordManager) -> None:
|
||||
self._manager = manager
|
||||
self._lock = Lock()
|
||||
|
||||
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:
|
||||
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:
|
||||
self._manager.handle_verify_checksum()
|
||||
|
||||
def update_checksum(self) -> None:
|
||||
with self._lock:
|
||||
self._manager.handle_update_script_checksum()
|
||||
|
||||
|
||||
class NostrService:
|
||||
"""Nostr related helper methods."""
|
||||
|
||||
def __init__(self, manager: PasswordManager) -> None:
|
||||
self._manager = manager
|
||||
self._lock = Lock()
|
||||
|
||||
def get_pubkey(self) -> str:
|
||||
with self._lock:
|
||||
return self._manager.nostr_client.key_manager.get_npub()
|
||||
|
||||
def list_relays(self) -> list[str]:
|
||||
with self._lock:
|
||||
return self._manager.state_manager.list_relays()
|
||||
|
||||
def add_relay(self, url: str) -> None:
|
||||
with self._lock:
|
||||
self._manager.state_manager.add_relay(url)
|
||||
self._manager.nostr_client.relays = (
|
||||
self._manager.state_manager.list_relays()
|
||||
)
|
||||
|
||||
def remove_relay(self, idx: int) -> None:
|
||||
with self._lock:
|
||||
self._manager.state_manager.remove_relay(idx)
|
||||
self._manager.nostr_client.relays = (
|
||||
self._manager.state_manager.list_relays()
|
||||
)
|
@@ -1,4 +1,4 @@
|
||||
# password_manager/backup.py
|
||||
# seedpass.core/backup.py
|
||||
|
||||
"""
|
||||
Backup Manager Module
|
||||
@@ -19,7 +19,7 @@ import traceback
|
||||
from pathlib import Path
|
||||
from termcolor import colored
|
||||
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from .config_manager import ConfigManager
|
||||
|
||||
from utils.file_lock import exclusive_lock
|
||||
from constants import APP_DIR
|
@@ -10,10 +10,10 @@ from utils.seed_prompt import masked_input
|
||||
|
||||
import bcrypt
|
||||
|
||||
from password_manager.vault import Vault
|
||||
from .vault import Vault
|
||||
from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS
|
||||
|
||||
from constants import INACTIVITY_TIMEOUT
|
||||
from constants import INACTIVITY_TIMEOUT, MAX_RETRIES, RETRY_DELAY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,12 +52,16 @@ class ConfigManager:
|
||||
"secret_mode_enabled": False,
|
||||
"clipboard_clear_delay": 45,
|
||||
"quick_unlock": False,
|
||||
"nostr_max_retries": 2,
|
||||
"nostr_retry_delay": 1.0,
|
||||
"nostr_max_retries": MAX_RETRIES,
|
||||
"nostr_retry_delay": float(RETRY_DELAY),
|
||||
"min_uppercase": 2,
|
||||
"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:
|
||||
@@ -77,12 +81,16 @@ class ConfigManager:
|
||||
data.setdefault("secret_mode_enabled", False)
|
||||
data.setdefault("clipboard_clear_delay", 45)
|
||||
data.setdefault("quick_unlock", False)
|
||||
data.setdefault("nostr_max_retries", 2)
|
||||
data.setdefault("nostr_retry_delay", 1.0)
|
||||
data.setdefault("nostr_max_retries", MAX_RETRIES)
|
||||
data.setdefault("nostr_retry_delay", float(RETRY_DELAY))
|
||||
data.setdefault("min_uppercase", 2)
|
||||
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
|
||||
@@ -251,7 +259,7 @@ class ConfigManager:
|
||||
# Password policy settings
|
||||
def get_password_policy(self) -> "PasswordPolicy":
|
||||
"""Return the password complexity policy."""
|
||||
from password_manager.password_generation import PasswordPolicy
|
||||
from .password_generation import PasswordPolicy
|
||||
|
||||
cfg = self.load_config(require_pin=False)
|
||||
return PasswordPolicy(
|
||||
@@ -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)
|
||||
@@ -303,7 +339,7 @@ class ConfigManager:
|
||||
def get_nostr_max_retries(self) -> int:
|
||||
"""Retrieve the configured Nostr retry count."""
|
||||
cfg = self.load_config(require_pin=False)
|
||||
return int(cfg.get("nostr_max_retries", 2))
|
||||
return int(cfg.get("nostr_max_retries", MAX_RETRIES))
|
||||
|
||||
def set_nostr_retry_delay(self, delay: float) -> None:
|
||||
"""Persist the delay between Nostr retry attempts."""
|
||||
@@ -316,7 +352,7 @@ class ConfigManager:
|
||||
def get_nostr_retry_delay(self) -> float:
|
||||
"""Retrieve the delay in seconds between Nostr retries."""
|
||||
cfg = self.load_config(require_pin=False)
|
||||
return float(cfg.get("nostr_retry_delay", 1.0))
|
||||
return float(cfg.get("nostr_retry_delay", float(RETRY_DELAY)))
|
||||
|
||||
def set_verbose_timing(self, enabled: bool) -> None:
|
||||
cfg = self.load_config(require_pin=False)
|
@@ -1,4 +1,4 @@
|
||||
# /src/password_manager/encryption.py
|
||||
# /src/seedpass.core/encryption.py
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
@@ -228,6 +228,7 @@ class EncryptionManager:
|
||||
relative_path: Optional[Path] = None,
|
||||
*,
|
||||
strict: bool = True,
|
||||
merge: bool = False,
|
||||
) -> bool:
|
||||
"""Decrypts data from Nostr and saves it.
|
||||
|
||||
@@ -249,6 +250,20 @@ class EncryptionManager:
|
||||
data = json_lib.loads(decrypted_data)
|
||||
else:
|
||||
data = json_lib.loads(decrypted_data.decode("utf-8"))
|
||||
if merge and (self.fingerprint_dir / relative_path).exists():
|
||||
current = self.load_json_data(relative_path)
|
||||
current_entries = current.get("entries", {})
|
||||
for idx, entry in data.get("entries", {}).items():
|
||||
cur_ts = current_entries.get(idx, {}).get("modified_ts", 0)
|
||||
new_ts = entry.get("modified_ts", 0)
|
||||
if idx not in current_entries or new_ts >= cur_ts:
|
||||
current_entries[idx] = entry
|
||||
current["entries"] = current_entries
|
||||
if "schema_version" in data:
|
||||
current["schema_version"] = max(
|
||||
current.get("schema_version", 0), data.get("schema_version", 0)
|
||||
)
|
||||
data = current
|
||||
self.save_json_data(data, relative_path) # This always saves in V2 format
|
||||
self.update_checksum(relative_path)
|
||||
logger.info("Index file from Nostr was processed and saved successfully.")
|
@@ -1,4 +1,4 @@
|
||||
# password_manager/entry_management.py
|
||||
# seedpass.core/entry_management.py
|
||||
|
||||
"""
|
||||
Entry Management Module
|
||||
@@ -27,18 +27,26 @@ import logging
|
||||
import hashlib
|
||||
import sys
|
||||
import shutil
|
||||
import time
|
||||
from typing import Optional, Tuple, Dict, Any, List
|
||||
from pathlib import Path
|
||||
|
||||
from termcolor import colored
|
||||
from password_manager.migrations import LATEST_VERSION
|
||||
from password_manager.entry_types import EntryType
|
||||
from password_manager.totp import TotpManager
|
||||
from .migrations import LATEST_VERSION
|
||||
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 password_manager.vault import Vault
|
||||
from password_manager.backup import BackupManager
|
||||
from .vault import Vault
|
||||
from .backup import BackupManager
|
||||
|
||||
|
||||
# Instantiate the logger
|
||||
@@ -97,6 +105,7 @@ class EntryManager:
|
||||
entry["word_count"] = entry["words"]
|
||||
entry.pop("words", None)
|
||||
entry.setdefault("tags", [])
|
||||
entry.setdefault("modified_ts", entry.get("updated", 0))
|
||||
logger.debug("Index loaded successfully.")
|
||||
self._index_cache = data
|
||||
return data
|
||||
@@ -150,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.
|
||||
@@ -167,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 "",
|
||||
@@ -176,10 +194,33 @@ class EntryManager:
|
||||
"type": EntryType.PASSWORD.value,
|
||||
"kind": EntryType.PASSWORD.value,
|
||||
"notes": notes,
|
||||
"modified_ts": int(time.time()),
|
||||
"custom_fields": custom_fields or [],
|
||||
"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)
|
||||
@@ -232,10 +273,13 @@ 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,
|
||||
"label": label,
|
||||
"modified_ts": int(time.time()),
|
||||
"index": index,
|
||||
"period": period,
|
||||
"digits": digits,
|
||||
@@ -244,11 +288,14 @@ 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,
|
||||
"label": label,
|
||||
"secret": secret,
|
||||
"modified_ts": int(time.time()),
|
||||
"period": period,
|
||||
"digits": digits,
|
||||
"archived": archived,
|
||||
@@ -287,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)] = {
|
||||
@@ -294,6 +347,7 @@ class EntryManager:
|
||||
"kind": EntryType.SSH.value,
|
||||
"index": index,
|
||||
"label": label,
|
||||
"modified_ts": int(time.time()),
|
||||
"notes": notes,
|
||||
"archived": archived,
|
||||
"tags": tags or [],
|
||||
@@ -312,7 +366,7 @@ class EntryManager:
|
||||
if not entry or (etype != EntryType.SSH.value and kind != EntryType.SSH.value):
|
||||
raise ValueError("Entry is not an SSH key entry")
|
||||
|
||||
from password_manager.password_generation import derive_ssh_key_pair
|
||||
from .password_generation import derive_ssh_key_pair
|
||||
|
||||
key_index = int(entry.get("index", index))
|
||||
return derive_ssh_key_pair(parent_seed, key_index)
|
||||
@@ -333,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)] = {
|
||||
@@ -340,6 +405,7 @@ class EntryManager:
|
||||
"kind": EntryType.PGP.value,
|
||||
"index": index,
|
||||
"label": label,
|
||||
"modified_ts": int(time.time()),
|
||||
"key_type": key_type,
|
||||
"user_id": user_id,
|
||||
"notes": notes,
|
||||
@@ -360,7 +426,7 @@ class EntryManager:
|
||||
if not entry or (etype != EntryType.PGP.value and kind != EntryType.PGP.value):
|
||||
raise ValueError("Entry is not a PGP key entry")
|
||||
|
||||
from password_manager.password_generation import derive_pgp_key
|
||||
from .password_generation import derive_pgp_key
|
||||
from local_bip85.bip85 import BIP85
|
||||
from bip_utils import Bip39SeedGenerator
|
||||
|
||||
@@ -375,6 +441,7 @@ class EntryManager:
|
||||
def add_nostr_key(
|
||||
self,
|
||||
label: str,
|
||||
parent_seed: str,
|
||||
index: int | None = None,
|
||||
notes: str = "",
|
||||
archived: bool = False,
|
||||
@@ -385,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)] = {
|
||||
@@ -392,6 +472,7 @@ class EntryManager:
|
||||
"kind": EntryType.NOSTR.value,
|
||||
"index": index,
|
||||
"label": label,
|
||||
"modified_ts": int(time.time()),
|
||||
"notes": notes,
|
||||
"archived": archived,
|
||||
"tags": tags or [],
|
||||
@@ -404,6 +485,7 @@ class EntryManager:
|
||||
def add_key_value(
|
||||
self,
|
||||
label: str,
|
||||
key: str,
|
||||
value: str,
|
||||
*,
|
||||
notes: str = "",
|
||||
@@ -421,6 +503,8 @@ 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,
|
||||
"archived": archived,
|
||||
@@ -473,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)] = {
|
||||
@@ -480,6 +574,7 @@ class EntryManager:
|
||||
"kind": EntryType.SEED.value,
|
||||
"index": index,
|
||||
"label": label,
|
||||
"modified_ts": int(time.time()),
|
||||
"word_count": words_num,
|
||||
"notes": notes,
|
||||
"archived": archived,
|
||||
@@ -501,7 +596,7 @@ class EntryManager:
|
||||
):
|
||||
raise ValueError("Entry is not a seed entry")
|
||||
|
||||
from password_manager.password_generation import derive_seed_phrase
|
||||
from .password_generation import derive_seed_phrase
|
||||
from local_bip85.bip85 import BIP85
|
||||
from bip_utils import Bip39SeedGenerator
|
||||
|
||||
@@ -530,7 +625,7 @@ class EntryManager:
|
||||
if index is None:
|
||||
index = self.get_next_index()
|
||||
|
||||
from password_manager.password_generation import derive_seed_phrase
|
||||
from .password_generation import derive_seed_phrase
|
||||
from local_bip85.bip85 import BIP85
|
||||
from bip_utils import Bip39SeedGenerator
|
||||
|
||||
@@ -540,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
|
||||
@@ -552,6 +649,7 @@ class EntryManager:
|
||||
"kind": EntryType.MANAGED_ACCOUNT.value,
|
||||
"index": index,
|
||||
"label": label,
|
||||
"modified_ts": int(time.time()),
|
||||
"word_count": word_count,
|
||||
"notes": notes,
|
||||
"fingerprint": fingerprint,
|
||||
@@ -576,7 +674,7 @@ class EntryManager:
|
||||
):
|
||||
raise ValueError("Entry is not a managed account entry")
|
||||
|
||||
from password_manager.password_generation import derive_seed_phrase
|
||||
from .password_generation import derive_seed_phrase
|
||||
from local_bip85.bip85 import BIP85
|
||||
from bip_utils import Bip39SeedGenerator
|
||||
|
||||
@@ -682,7 +780,8 @@ class EntryManager:
|
||||
):
|
||||
entry.setdefault("custom_fields", [])
|
||||
logger.debug(f"Retrieved entry at index {index}: {entry}")
|
||||
return entry
|
||||
clean = {k: v for k, v in entry.items() if k != "modified_ts"}
|
||||
return clean
|
||||
else:
|
||||
logger.warning(f"No entry found at index {index}.")
|
||||
print(colored(f"Warning: No entry found at index {index}.", "yellow"))
|
||||
@@ -708,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:
|
||||
"""
|
||||
@@ -724,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:
|
||||
@@ -752,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 = {
|
||||
@@ -766,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",
|
||||
@@ -778,6 +904,7 @@ class EntryManager:
|
||||
},
|
||||
EntryType.KEY_VALUE.value: {
|
||||
"label",
|
||||
"key",
|
||||
"value",
|
||||
"archived",
|
||||
"notes",
|
||||
@@ -858,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}.")
|
||||
@@ -887,6 +1017,30 @@ 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
|
||||
logger.debug(f"Modified entry at index {index}: {entry}")
|
||||
|
||||
@@ -922,10 +1076,17 @@ class EntryManager:
|
||||
include_archived: bool = False,
|
||||
verbose: bool = True,
|
||||
) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]:
|
||||
"""List entries in the index with optional sorting and filtering.
|
||||
"""List entries sorted and filtered according to the provided options.
|
||||
|
||||
By default archived entries are omitted unless ``include_archived`` is
|
||||
``True``.
|
||||
Parameters
|
||||
----------
|
||||
sort_by:
|
||||
Field to sort by. Supported values are ``"index"``, ``"label"`` and
|
||||
``"updated"``.
|
||||
filter_kind:
|
||||
Optional entry kind to restrict the results.
|
||||
|
||||
Archived entries are omitted unless ``include_archived`` is ``True``.
|
||||
"""
|
||||
try:
|
||||
data = self._load_index()
|
||||
@@ -941,11 +1102,14 @@ class EntryManager:
|
||||
idx_str, entry = item
|
||||
if sort_by == "index":
|
||||
return int(idx_str)
|
||||
if sort_by in {"website", "label"}:
|
||||
if sort_by == "label":
|
||||
# labels are stored in the index so no additional
|
||||
# decryption is required when sorting
|
||||
return entry.get("label", entry.get("website", "")).lower()
|
||||
if sort_by == "username":
|
||||
return entry.get("username", "").lower()
|
||||
raise ValueError("sort_by must be 'index', 'label', or 'username'")
|
||||
if sort_by == "updated":
|
||||
# sort newest first
|
||||
return -int(entry.get("updated", 0))
|
||||
raise ValueError("sort_by must be 'index', 'label', or 'updated'")
|
||||
|
||||
sorted_items = sorted(entries_data.items(), key=sort_key)
|
||||
|
||||
@@ -1045,9 +1209,14 @@ class EntryManager:
|
||||
return []
|
||||
|
||||
def search_entries(
|
||||
self, query: str
|
||||
) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]:
|
||||
"""Return entries matching the query across common fields."""
|
||||
self, query: str, kinds: List[str] | None = None
|
||||
) -> 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", {})
|
||||
|
||||
@@ -1055,78 +1224,42 @@ 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.value not in kinds:
|
||||
continue
|
||||
|
||||
label = entry.get("label", entry.get("website", ""))
|
||||
notes = entry.get("notes", "")
|
||||
username = (
|
||||
entry.get("username", "") if etype == EntryType.PASSWORD else None
|
||||
)
|
||||
url = entry.get("url", "") if etype == EntryType.PASSWORD else None
|
||||
tags = entry.get("tags", [])
|
||||
archived = entry.get("archived", entry.get("blacklisted", False))
|
||||
|
||||
label_match = query_lower in label.lower()
|
||||
notes_match = query_lower in notes.lower()
|
||||
username_match = bool(username) and query_lower in username.lower()
|
||||
url_match = bool(url) and query_lower in url.lower()
|
||||
tags_match = any(query_lower in str(t).lower() for t in tags)
|
||||
|
||||
if etype == EntryType.PASSWORD.value:
|
||||
username = entry.get("username", "")
|
||||
url = entry.get("url", "")
|
||||
custom_fields = entry.get("custom_fields", [])
|
||||
custom_match = any(
|
||||
query_lower in str(cf.get("label", "")).lower()
|
||||
or query_lower in str(cf.get("value", "")).lower()
|
||||
for cf in custom_fields
|
||||
if label_match or username_match or url_match or tags_match:
|
||||
results.append(
|
||||
(
|
||||
int(idx),
|
||||
label,
|
||||
username if username is not None else None,
|
||||
url if url is not None else None,
|
||||
archived,
|
||||
etype,
|
||||
)
|
||||
)
|
||||
if (
|
||||
label_match
|
||||
or query_lower in username.lower()
|
||||
or query_lower in url.lower()
|
||||
or notes_match
|
||||
or custom_match
|
||||
or tags_match
|
||||
):
|
||||
results.append(
|
||||
(
|
||||
int(idx),
|
||||
label,
|
||||
username,
|
||||
url,
|
||||
entry.get("archived", entry.get("blacklisted", False)),
|
||||
)
|
||||
)
|
||||
elif etype in (EntryType.KEY_VALUE.value, EntryType.MANAGED_ACCOUNT.value):
|
||||
value_field = str(entry.get("value", ""))
|
||||
custom_fields = entry.get("custom_fields", [])
|
||||
custom_match = any(
|
||||
query_lower in str(cf.get("label", "")).lower()
|
||||
or query_lower in str(cf.get("value", "")).lower()
|
||||
for cf in custom_fields
|
||||
)
|
||||
if (
|
||||
label_match
|
||||
or query_lower in value_field.lower()
|
||||
or notes_match
|
||||
or custom_match
|
||||
or tags_match
|
||||
):
|
||||
results.append(
|
||||
(
|
||||
int(idx),
|
||||
label,
|
||||
None,
|
||||
None,
|
||||
entry.get("archived", entry.get("blacklisted", False)),
|
||||
)
|
||||
)
|
||||
else:
|
||||
if label_match or notes_match or tags_match:
|
||||
results.append(
|
||||
(
|
||||
int(idx),
|
||||
label,
|
||||
None,
|
||||
None,
|
||||
entry.get("archived", entry.get("blacklisted", False)),
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
@@ -1,4 +1,4 @@
|
||||
# password_manager/entry_types.py
|
||||
# seedpass.core/entry_types.py
|
||||
"""Enumerations for entry types used by SeedPass."""
|
||||
|
||||
from enum import Enum
|
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
# password_manager/password_generation.py
|
||||
# seedpass.core/password_generation.py
|
||||
|
||||
"""
|
||||
Password Generation Module
|
||||
@@ -42,8 +42,13 @@ 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 password_manager.encryption import EncryptionManager
|
||||
from constants import (
|
||||
DEFAULT_PASSWORD_LENGTH,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
MAX_PASSWORD_LENGTH,
|
||||
SAFE_SPECIAL_CHARS,
|
||||
)
|
||||
from .encryption import EncryptionManager
|
||||
|
||||
# Instantiate the logger
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -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(
|
@@ -12,14 +12,14 @@ import asyncio
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from password_manager.vault import Vault
|
||||
from password_manager.backup import BackupManager
|
||||
from .vault import Vault
|
||||
from .backup import BackupManager
|
||||
from nostr.client import NostrClient
|
||||
from utils.key_derivation import (
|
||||
derive_index_key,
|
||||
EncryptionMode,
|
||||
)
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from .encryption import EncryptionManager
|
||||
from utils.checksum import json_checksum, canonical_json_dumps
|
||||
|
||||
logger = logging.getLogger(__name__)
|
27
src/seedpass/core/pubsub.py
Normal file
27
src/seedpass/core/pubsub.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from collections import defaultdict
|
||||
from typing import Callable, Dict, List, Any
|
||||
|
||||
|
||||
class PubSub:
|
||||
"""Simple in-process event bus using the observer pattern."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._subscribers: Dict[str, List[Callable[..., None]]] = defaultdict(list)
|
||||
|
||||
def subscribe(self, event: str, callback: Callable[..., None]) -> None:
|
||||
"""Register ``callback`` to be invoked when ``event`` is published."""
|
||||
self._subscribers[event].append(callback)
|
||||
|
||||
def unsubscribe(self, event: str, callback: Callable[..., None]) -> None:
|
||||
"""Unregister ``callback`` from ``event`` notifications."""
|
||||
if callback in self._subscribers.get(event, []):
|
||||
self._subscribers[event].remove(callback)
|
||||
|
||||
def publish(self, event: str, *args: Any, **kwargs: Any) -> None:
|
||||
"""Notify all subscribers of ``event`` passing ``*args`` and ``**kwargs``."""
|
||||
for callback in list(self._subscribers.get(event, [])):
|
||||
callback(*args, **kwargs)
|
||||
|
||||
|
||||
# Global bus instance for convenience
|
||||
bus = PubSub()
|
91
src/seedpass/core/state_manager.py
Normal file
91
src/seedpass/core/state_manager.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from utils.file_lock import exclusive_lock, shared_lock
|
||||
from nostr.client import DEFAULT_RELAYS
|
||||
|
||||
|
||||
class StateManager:
|
||||
"""Persist simple state values per profile."""
|
||||
|
||||
STATE_FILENAME = "seedpass_state.json"
|
||||
|
||||
def __init__(self, fingerprint_dir: Path) -> None:
|
||||
self.fingerprint_dir = Path(fingerprint_dir)
|
||||
self.state_path = self.fingerprint_dir / self.STATE_FILENAME
|
||||
|
||||
def _load(self) -> dict:
|
||||
if not self.state_path.exists():
|
||||
return {
|
||||
"last_bip85_idx": 0,
|
||||
"last_sync_ts": 0,
|
||||
"manifest_id": None,
|
||||
"delta_since": 0,
|
||||
"relays": list(DEFAULT_RELAYS),
|
||||
}
|
||||
with shared_lock(self.state_path) as fh:
|
||||
fh.seek(0)
|
||||
data = fh.read()
|
||||
if not data:
|
||||
return {
|
||||
"last_bip85_idx": 0,
|
||||
"last_sync_ts": 0,
|
||||
"manifest_id": None,
|
||||
"delta_since": 0,
|
||||
"relays": list(DEFAULT_RELAYS),
|
||||
}
|
||||
try:
|
||||
obj = json.loads(data.decode())
|
||||
except Exception:
|
||||
obj = {}
|
||||
obj.setdefault("last_bip85_idx", 0)
|
||||
obj.setdefault("last_sync_ts", 0)
|
||||
obj.setdefault("manifest_id", None)
|
||||
obj.setdefault("delta_since", 0)
|
||||
obj.setdefault("relays", list(DEFAULT_RELAYS))
|
||||
return obj
|
||||
|
||||
def _save(self, data: dict) -> None:
|
||||
with exclusive_lock(self.state_path) as fh:
|
||||
fh.seek(0)
|
||||
fh.truncate()
|
||||
fh.write(json.dumps(data, separators=(",", ":")).encode())
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
|
||||
@property
|
||||
def state(self) -> dict:
|
||||
return self._load()
|
||||
|
||||
def update_state(self, **kwargs) -> None:
|
||||
data = self._load()
|
||||
data.update(kwargs)
|
||||
self._save(data)
|
||||
|
||||
# Relay helpers
|
||||
def list_relays(self) -> List[str]:
|
||||
return self._load().get("relays", [])
|
||||
|
||||
def add_relay(self, url: str) -> None:
|
||||
data = self._load()
|
||||
relays = data.get("relays", [])
|
||||
if url in relays:
|
||||
raise ValueError("Relay already present")
|
||||
relays.append(url)
|
||||
data["relays"] = relays
|
||||
self._save(data)
|
||||
|
||||
def remove_relay(self, idx: int) -> None:
|
||||
data = self._load()
|
||||
relays = data.get("relays", [])
|
||||
if not 1 <= idx <= len(relays):
|
||||
raise ValueError("Invalid index")
|
||||
if len(relays) == 1:
|
||||
raise ValueError("At least one relay required")
|
||||
relays.pop(idx - 1)
|
||||
data["relays"] = relays
|
||||
self._save(data)
|
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
|
@@ -61,11 +61,11 @@ class Vault:
|
||||
return self.encryption_manager.get_encrypted_index()
|
||||
|
||||
def decrypt_and_save_index_from_nostr(
|
||||
self, encrypted_data: bytes, *, strict: bool = True
|
||||
self, encrypted_data: bytes, *, strict: bool = True, merge: bool = False
|
||||
) -> bool:
|
||||
"""Decrypt Nostr payload and overwrite the local index."""
|
||||
"""Decrypt Nostr payload and update the local index."""
|
||||
return self.encryption_manager.decrypt_and_save_index_from_nostr(
|
||||
encrypted_data, strict=strict
|
||||
encrypted_data, strict=strict, merge=merge
|
||||
)
|
||||
|
||||
# ----- Config helpers -----
|
11
src/seedpass_gui/__init__.py
Normal file
11
src/seedpass_gui/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Graphical user interface for SeedPass."""
|
||||
|
||||
from .app import SeedPassApp, build
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Launch the GUI application."""
|
||||
build().main_loop()
|
||||
|
||||
|
||||
__all__ = ["SeedPassApp", "main"]
|
4
src/seedpass_gui/__main__.py
Normal file
4
src/seedpass_gui/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
486
src/seedpass_gui/app.py
Normal file
486
src/seedpass_gui/app.py
Normal file
@@ -0,0 +1,486 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import toga
|
||||
from toga.style import Pack
|
||||
from toga.sources import ListSource
|
||||
from toga.style.pack import COLUMN, ROW
|
||||
|
||||
from seedpass.core.entry_types import EntryType
|
||||
from seedpass.core.manager import PasswordManager
|
||||
from seedpass.core.totp import TotpManager
|
||||
|
||||
from seedpass.core.api import (
|
||||
VaultService,
|
||||
EntryService,
|
||||
NostrService,
|
||||
UnlockRequest,
|
||||
)
|
||||
from seedpass.core.pubsub import bus
|
||||
|
||||
|
||||
class LockScreenWindow(toga.Window):
|
||||
"""Window prompting for the master password."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller: SeedPassApp,
|
||||
vault: VaultService,
|
||||
entries: EntryService,
|
||||
) -> None:
|
||||
super().__init__("Unlock Vault")
|
||||
# Store a reference to the SeedPass application instance separately from
|
||||
# the ``toga`` ``Window.app`` attribute to avoid conflicts.
|
||||
self.controller = controller
|
||||
self.vault = vault
|
||||
self.entries = entries
|
||||
|
||||
self.password_input = toga.PasswordInput(style=Pack(flex=1))
|
||||
self.message = toga.Label("", style=Pack(color="red"))
|
||||
unlock_button = toga.Button(
|
||||
"Unlock", on_press=self.handle_unlock, style=Pack(padding_top=10)
|
||||
)
|
||||
|
||||
box = toga.Box(style=Pack(direction=COLUMN, padding=20))
|
||||
box.add(toga.Label("Master Password:"))
|
||||
box.add(self.password_input)
|
||||
box.add(unlock_button)
|
||||
box.add(self.message)
|
||||
self.content = box
|
||||
|
||||
def handle_unlock(self, widget: toga.Widget) -> None:
|
||||
password = self.password_input.value or ""
|
||||
try:
|
||||
self.vault.unlock(UnlockRequest(password=password))
|
||||
except Exception as exc: # pragma: no cover - GUI error handling
|
||||
self.message.text = str(exc)
|
||||
return
|
||||
main = MainWindow(
|
||||
self.controller,
|
||||
self.vault,
|
||||
self.entries,
|
||||
self.controller.nostr_service,
|
||||
)
|
||||
self.controller.main_window = main
|
||||
main.show()
|
||||
self.close()
|
||||
|
||||
|
||||
class MainWindow(toga.Window):
|
||||
"""Main application window showing vault entries."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller: SeedPassApp,
|
||||
vault: VaultService,
|
||||
entries: EntryService,
|
||||
nostr: NostrService,
|
||||
) -> None:
|
||||
super().__init__("SeedPass", on_close=self.cleanup)
|
||||
# ``Window.app`` is reserved for the Toga ``App`` instance. Store the
|
||||
# SeedPass application reference separately.
|
||||
self.controller = controller
|
||||
self.vault = vault
|
||||
self.entries = entries
|
||||
self.nostr = nostr
|
||||
bus.subscribe("sync_started", self.sync_started)
|
||||
bus.subscribe("sync_finished", self.sync_finished)
|
||||
bus.subscribe("vault_locked", self.vault_locked)
|
||||
self.last_sync = None
|
||||
|
||||
self.entry_source = ListSource(["id", "label", "kind", "info1", "info2"])
|
||||
self.table = toga.Table(
|
||||
headings=["ID", "Label", "Kind", "Info 1", "Info 2"],
|
||||
data=self.entry_source,
|
||||
style=Pack(flex=1),
|
||||
)
|
||||
|
||||
add_button = toga.Button("Add", on_press=self.add_entry)
|
||||
edit_button = toga.Button("Edit", on_press=self.edit_entry)
|
||||
search_button = toga.Button("Search", on_press=self.search_entries)
|
||||
relay_button = toga.Button("Relays", on_press=self.manage_relays)
|
||||
totp_button = toga.Button("TOTP", on_press=self.show_totp_codes)
|
||||
sync_button = toga.Button("Sync", on_press=self.start_vault_sync)
|
||||
|
||||
button_box = toga.Box(style=Pack(direction=ROW, padding_top=5))
|
||||
button_box.add(add_button)
|
||||
button_box.add(edit_button)
|
||||
button_box.add(search_button)
|
||||
button_box.add(relay_button)
|
||||
button_box.add(totp_button)
|
||||
button_box.add(sync_button)
|
||||
|
||||
self.status = toga.Label("Last sync: never", style=Pack(padding_top=5))
|
||||
|
||||
box = toga.Box(style=Pack(direction=COLUMN, padding=10))
|
||||
box.add(self.table)
|
||||
box.add(button_box)
|
||||
box.add(self.status)
|
||||
self.content = box
|
||||
|
||||
self.refresh_entries()
|
||||
|
||||
def refresh_entries(self) -> None:
|
||||
self.entry_source.clear()
|
||||
for idx, label, username, url, _arch in self.entries.list_entries():
|
||||
entry = self.entries.retrieve_entry(idx)
|
||||
kind = (entry or {}).get("kind", (entry or {}).get("type", ""))
|
||||
info1 = ""
|
||||
info2 = ""
|
||||
if kind == EntryType.PASSWORD.value:
|
||||
info1 = username or ""
|
||||
info2 = url or ""
|
||||
elif kind == EntryType.KEY_VALUE.value:
|
||||
info1 = entry.get("value", "") if entry else ""
|
||||
else:
|
||||
info1 = str(entry.get("index", "")) if entry else ""
|
||||
self.entry_source.append(
|
||||
{
|
||||
"id": idx,
|
||||
"label": label,
|
||||
"kind": kind,
|
||||
"info1": info1,
|
||||
"info2": info2,
|
||||
}
|
||||
)
|
||||
|
||||
# --- Button handlers -------------------------------------------------
|
||||
def add_entry(self, widget: toga.Widget) -> None:
|
||||
dlg = EntryDialog(self, None)
|
||||
dlg.show()
|
||||
|
||||
def edit_entry(self, widget: toga.Widget) -> None:
|
||||
if self.table.selection is None:
|
||||
return
|
||||
entry_id = int(self.table.selection[0])
|
||||
dlg = EntryDialog(self, entry_id)
|
||||
dlg.show()
|
||||
|
||||
def search_entries(self, widget: toga.Widget) -> None:
|
||||
dlg = SearchDialog(self)
|
||||
dlg.show()
|
||||
|
||||
def manage_relays(self, widget: toga.Widget) -> None:
|
||||
dlg = RelayManagerDialog(self, self.nostr)
|
||||
dlg.show()
|
||||
|
||||
def show_totp_codes(self, widget: toga.Widget) -> None:
|
||||
win = TotpViewerWindow(self.controller, self.entries)
|
||||
win.show()
|
||||
|
||||
def start_vault_sync(self, widget: toga.Widget | None = None) -> None:
|
||||
"""Schedule a background vault synchronization."""
|
||||
|
||||
async def _runner() -> None:
|
||||
self.nostr.start_background_vault_sync()
|
||||
|
||||
self.controller.loop.create_task(_runner())
|
||||
|
||||
# --- PubSub callbacks -------------------------------------------------
|
||||
def sync_started(self, *args: object, **kwargs: object) -> None:
|
||||
self.status.text = "Syncing..."
|
||||
|
||||
def sync_finished(self, *args: object, **kwargs: object) -> None:
|
||||
self.last_sync = time.strftime("%H:%M:%S")
|
||||
self.status.text = f"Last sync: {self.last_sync}"
|
||||
|
||||
def vault_locked(self, *args: object, **kwargs: object) -> None:
|
||||
self.close()
|
||||
self.controller.main_window = None
|
||||
self.controller.lock_window.show()
|
||||
|
||||
def cleanup(self, *args: object, **kwargs: object) -> None:
|
||||
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):
|
||||
"""Dialog for adding or editing an entry."""
|
||||
|
||||
def __init__(self, main: MainWindow, entry_id: int | None) -> None:
|
||||
title = "Add Entry" if entry_id is None else "Edit Entry"
|
||||
super().__init__(title)
|
||||
self.main = main
|
||||
self.entry_id = entry_id
|
||||
|
||||
self.label_input = toga.TextInput(style=Pack(flex=1))
|
||||
self.kind_input = toga.Selection(
|
||||
items=[e.value for e in EntryType],
|
||||
style=Pack(flex=1),
|
||||
)
|
||||
self.kind_input.value = EntryType.PASSWORD.value
|
||||
self.username_input = toga.TextInput(style=Pack(flex=1))
|
||||
self.url_input = toga.TextInput(style=Pack(flex=1))
|
||||
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(
|
||||
"Save", on_press=self.save, style=Pack(padding_top=10)
|
||||
)
|
||||
|
||||
box = toga.Box(style=Pack(direction=COLUMN, padding=20))
|
||||
box.add(toga.Label("Label"))
|
||||
box.add(self.label_input)
|
||||
box.add(toga.Label("Kind"))
|
||||
box.add(self.kind_input)
|
||||
box.add(toga.Label("Username"))
|
||||
box.add(self.username_input)
|
||||
box.add(toga.Label("URL"))
|
||||
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)
|
||||
self.content = box
|
||||
|
||||
if entry_id is not None:
|
||||
entry = self.main.entries.retrieve_entry(entry_id)
|
||||
if entry:
|
||||
self.label_input.value = entry.get("label", "")
|
||||
kind = entry.get("kind", entry.get("type", EntryType.PASSWORD.value))
|
||||
self.kind_input.value = kind
|
||||
self.kind_input.enabled = False
|
||||
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:
|
||||
label = self.label_input.value or ""
|
||||
username = self.username_input.value or None
|
||||
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:
|
||||
if kind == EntryType.PASSWORD.value:
|
||||
entry_id = self.main.entries.add_entry(
|
||||
label, length, username=username, url=url
|
||||
)
|
||||
elif kind == EntryType.TOTP.value:
|
||||
entry_id = self.main.entries.add_totp(label)
|
||||
elif kind == EntryType.SSH.value:
|
||||
entry_id = self.main.entries.add_ssh_key(label)
|
||||
elif kind == EntryType.SEED.value:
|
||||
entry_id = self.main.entries.add_seed(label)
|
||||
elif kind == EntryType.PGP.value:
|
||||
entry_id = self.main.entries.add_pgp_key(label)
|
||||
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, key or "", value or ""
|
||||
)
|
||||
elif kind == EntryType.MANAGED_ACCOUNT.value:
|
||||
entry_id = self.main.entries.add_managed_account(label)
|
||||
else:
|
||||
entry_id = self.entry_id
|
||||
kwargs = {"label": label}
|
||||
if kind == EntryType.PASSWORD.value:
|
||||
kwargs.update({"username": username, "url": url})
|
||||
elif kind == EntryType.KEY_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 {}
|
||||
kind = entry.get("kind", entry.get("type", kind))
|
||||
info1 = ""
|
||||
info2 = ""
|
||||
if kind == EntryType.PASSWORD.value:
|
||||
info1 = username or ""
|
||||
info2 = url or ""
|
||||
elif kind == EntryType.KEY_VALUE.value:
|
||||
info1 = entry.get("value", value or "")
|
||||
else:
|
||||
info1 = str(entry.get("index", ""))
|
||||
|
||||
row = {
|
||||
"id": entry_id,
|
||||
"label": label,
|
||||
"kind": kind,
|
||||
"info1": info1,
|
||||
"info2": info2,
|
||||
}
|
||||
|
||||
if self.entry_id is None:
|
||||
self.main.entry_source.append(row)
|
||||
else:
|
||||
for existing in self.main.entry_source:
|
||||
if getattr(existing, "id", None) == entry_id:
|
||||
for key, value in row.items():
|
||||
setattr(existing, key, value)
|
||||
break
|
||||
|
||||
self.close()
|
||||
# schedule vault sync after saving
|
||||
getattr(self.main, "start_vault_sync", lambda *_: None)()
|
||||
|
||||
|
||||
class SearchDialog(toga.Window):
|
||||
"""Dialog for searching entries."""
|
||||
|
||||
def __init__(self, main: MainWindow) -> None:
|
||||
super().__init__("Search Entries")
|
||||
self.main = main
|
||||
self.query_input = toga.TextInput(style=Pack(flex=1))
|
||||
search_button = toga.Button(
|
||||
"Search", on_press=self.do_search, style=Pack(padding_top=10)
|
||||
)
|
||||
box = toga.Box(style=Pack(direction=COLUMN, padding=20))
|
||||
box.add(toga.Label("Query"))
|
||||
box.add(self.query_input)
|
||||
box.add(search_button)
|
||||
self.content = box
|
||||
|
||||
def do_search(self, widget: toga.Widget) -> None:
|
||||
query = self.query_input.value or ""
|
||||
results = self.main.entries.search_entries(query)
|
||||
self.main.entry_source.clear()
|
||||
for idx, label, username, url, _arch, _etype in results:
|
||||
self.main.entry_source.append(
|
||||
{
|
||||
"id": idx,
|
||||
"label": label,
|
||||
"kind": "",
|
||||
"info1": username or "",
|
||||
"info2": url or "",
|
||||
}
|
||||
)
|
||||
self.close()
|
||||
|
||||
|
||||
class TotpViewerWindow(toga.Window):
|
||||
"""Window displaying active TOTP codes."""
|
||||
|
||||
def __init__(self, controller: SeedPassApp, entries: EntryService) -> None:
|
||||
super().__init__("TOTP Codes", on_close=self.cleanup)
|
||||
self.controller = controller
|
||||
self.entries = entries
|
||||
|
||||
self.table = toga.Table(
|
||||
headings=["Label", "Code", "Seconds"],
|
||||
style=Pack(flex=1),
|
||||
)
|
||||
|
||||
box = toga.Box(style=Pack(direction=COLUMN, padding=20))
|
||||
box.add(self.table)
|
||||
self.content = box
|
||||
|
||||
self._running = True
|
||||
self.controller.loop.create_task(self._update_loop())
|
||||
self.refresh_codes()
|
||||
|
||||
async def _update_loop(self) -> None:
|
||||
while self._running:
|
||||
self.refresh_codes()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
def refresh_codes(self) -> None:
|
||||
self.table.data = []
|
||||
for idx, label, *_rest in self.entries.list_entries(
|
||||
filter_kind=EntryType.TOTP.value
|
||||
):
|
||||
entry = self.entries.retrieve_entry(idx)
|
||||
code = self.entries.get_totp_code(idx)
|
||||
period = int(entry.get("period", 30)) if entry else 30
|
||||
remaining = TotpManager.time_remaining(period)
|
||||
self.table.data.append((label, code, remaining))
|
||||
|
||||
def cleanup(self, *args: object, **kwargs: object) -> None:
|
||||
self._running = False
|
||||
|
||||
|
||||
class RelayManagerDialog(toga.Window):
|
||||
"""Dialog for managing relay URLs."""
|
||||
|
||||
def __init__(self, main: MainWindow, nostr: NostrService) -> None:
|
||||
super().__init__("Relays")
|
||||
self.main = main
|
||||
self.nostr = nostr
|
||||
|
||||
self.table = toga.Table(headings=["Index", "URL"], style=Pack(flex=1))
|
||||
self.new_input = toga.TextInput(style=Pack(flex=1))
|
||||
add_btn = toga.Button("Add", on_press=self.add_relay)
|
||||
remove_btn = toga.Button("Remove", on_press=self.remove_relay)
|
||||
self.message = toga.Label("", style=Pack(color="red"))
|
||||
|
||||
box = toga.Box(style=Pack(direction=COLUMN, padding=20))
|
||||
box.add(self.table)
|
||||
form = toga.Box(style=Pack(direction=ROW, padding_top=5))
|
||||
form.add(self.new_input)
|
||||
form.add(add_btn)
|
||||
form.add(remove_btn)
|
||||
box.add(form)
|
||||
box.add(self.message)
|
||||
self.content = box
|
||||
|
||||
self.refresh()
|
||||
|
||||
def refresh(self) -> None:
|
||||
self.table.data = []
|
||||
for i, url in enumerate(self.nostr.list_relays(), start=1):
|
||||
self.table.data.append((i, url))
|
||||
|
||||
def add_relay(self, widget: toga.Widget) -> None:
|
||||
url = self.new_input.value or ""
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
self.nostr.add_relay(url)
|
||||
except Exception as exc: # pragma: no cover - pass errors
|
||||
self.message.text = str(exc)
|
||||
return
|
||||
self.new_input.value = ""
|
||||
self.refresh()
|
||||
|
||||
def remove_relay(self, widget: toga.Widget, *, index: int | None = None) -> None:
|
||||
if index is None:
|
||||
if self.table.selection is None:
|
||||
return
|
||||
index = int(self.table.selection[0])
|
||||
try:
|
||||
self.nostr.remove_relay(index)
|
||||
except Exception as exc: # pragma: no cover - pass errors
|
||||
self.message.text = str(exc)
|
||||
return
|
||||
self.refresh()
|
||||
|
||||
|
||||
def build() -> SeedPassApp:
|
||||
"""Return a configured :class:`SeedPassApp` instance."""
|
||||
return SeedPassApp(formal_name="SeedPass", app_id="org.seedpass.gui")
|
||||
|
||||
|
||||
class SeedPassApp(toga.App):
|
||||
def startup(self) -> None: # pragma: no cover - GUI bootstrap
|
||||
pm = PasswordManager()
|
||||
self.vault_service = VaultService(pm)
|
||||
self.entry_service = EntryService(pm)
|
||||
self.nostr_service = NostrService(pm)
|
||||
self.lock_window = LockScreenWindow(
|
||||
self,
|
||||
self.vault_service,
|
||||
self.entry_service,
|
||||
)
|
||||
self.main_window = None
|
||||
self.lock_window.show()
|
||||
|
||||
|
||||
def main() -> None: # pragma: no cover - GUI bootstrap
|
||||
"""Run the BeeWare application."""
|
||||
build().main_loop()
|
@@ -5,8 +5,8 @@ from pathlib import Path
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.vault import Vault
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from seedpass.core.vault import Vault
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
from utils.key_derivation import (
|
||||
derive_index_key,
|
||||
derive_key_from_password,
|
||||
@@ -108,6 +108,7 @@ class DummyFilter:
|
||||
self.ids: list[str] = []
|
||||
self.limit_val: int | None = None
|
||||
self.since_val: int | None = None
|
||||
self.id_called: bool = False
|
||||
|
||||
def author(self, _pk):
|
||||
return self
|
||||
@@ -125,6 +126,11 @@ class DummyFilter:
|
||||
self.ids.append(ident)
|
||||
return self
|
||||
|
||||
def id(self, ident: str):
|
||||
self.id_called = True
|
||||
self.ids.append(ident)
|
||||
return self
|
||||
|
||||
def limit(self, val: int):
|
||||
self.limit_val = val
|
||||
return self
|
||||
@@ -167,6 +173,7 @@ class DummyRelayClient:
|
||||
self.manifests: list[DummyEvent] = []
|
||||
self.chunks: dict[str, DummyEvent] = {}
|
||||
self.deltas: list[DummyEvent] = []
|
||||
self.filters: list[DummyFilter] = []
|
||||
|
||||
async def add_relays(self, _relays):
|
||||
pass
|
||||
@@ -195,6 +202,7 @@ class DummyRelayClient:
|
||||
elif event.kind == KIND_SNAPSHOT_CHUNK:
|
||||
ident = event.tags[0] if event.tags else str(self.counter)
|
||||
self.chunks[ident] = event
|
||||
self.chunks[eid] = event
|
||||
elif event.kind == KIND_DELTA:
|
||||
if not hasattr(event, "created_at"):
|
||||
self.ts_counter += 1
|
||||
@@ -203,6 +211,7 @@ class DummyRelayClient:
|
||||
return DummySendResult(eid)
|
||||
|
||||
async def fetch_events(self, f, _timeout):
|
||||
self.filters.append(f)
|
||||
kind = getattr(f, "kind_val", None)
|
||||
limit = getattr(f, "limit_val", None)
|
||||
identifier = f.ids[0] if getattr(f, "ids", None) else None
|
||||
|
@@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.manager import PasswordManager, EncryptionMode
|
||||
from password_manager.config_manager import ConfigManager
|
||||
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
|
||||
|
||||
|
||||
class FakePasswordGenerator:
|
||||
|
@@ -4,9 +4,9 @@ from tempfile import TemporaryDirectory
|
||||
|
||||
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
def test_entry_manager_additional_backup(monkeypatch):
|
||||
|
@@ -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,
|
||||
@@ -179,12 +182,16 @@ def test_change_password_route(client):
|
||||
cl, token = client
|
||||
called = {}
|
||||
|
||||
api._pm.change_password = lambda: called.setdefault("called", True)
|
||||
api._pm.change_password = lambda o, n: called.setdefault("called", (o, n))
|
||||
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
|
||||
res = cl.post("/api/v1/change-password", headers=headers)
|
||||
res = cl.post(
|
||||
"/api/v1/change-password",
|
||||
headers=headers,
|
||||
json={"old": "old", "new": "new"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "ok"}
|
||||
assert called.get("called") is True
|
||||
assert called.get("called") == ("old", "new")
|
||||
assert res.headers.get("access-control-allow-origin") == "http://example.com"
|
||||
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
||||
@@ -291,8 +293,8 @@ def test_vault_lock_endpoint(client):
|
||||
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()
|
||||
api._pm.unlock_vault = lambda pw: setattr(api._pm, "locked", False)
|
||||
api._pm.unlock_vault("pw")
|
||||
assert api._pm.locked is False
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
@@ -8,10 +8,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.manager import PasswordManager, EncryptionMode
|
||||
from password_manager.config_manager import ConfigManager
|
||||
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
|
||||
|
||||
|
||||
class FakePasswordGenerator:
|
||||
|
@@ -6,9 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from 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)
|
||||
]
|
||||
|
@@ -10,10 +10,11 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from password_manager.manager import PasswordManager, EncryptionMode
|
||||
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)
|
||||
]
|
||||
|
||||
|
||||
|
@@ -6,7 +6,7 @@ import sys
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.manager import PasswordManager
|
||||
from seedpass.core.manager import PasswordManager
|
||||
from constants import MIN_HEALTHY_RELAYS
|
||||
|
||||
|
||||
|
@@ -4,8 +4,8 @@ from pathlib import Path
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.manager import PasswordManager
|
||||
import password_manager.manager as manager_module
|
||||
from seedpass.core.manager import PasswordManager
|
||||
import seedpass.core.manager as manager_module
|
||||
|
||||
|
||||
def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path):
|
||||
@@ -22,17 +22,12 @@ def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path):
|
||||
pm.config_manager = SimpleNamespace(get_quick_unlock=lambda: False)
|
||||
|
||||
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "1")
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.prompt_existing_password", lambda *_a, **_k: "pw"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
PasswordManager, "setup_encryption_manager", lambda *a, **k: True
|
||||
)
|
||||
monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda *a, **k: None)
|
||||
monkeypatch.setattr(PasswordManager, "initialize_managers", lambda *a, **k: None)
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.NostrClient", lambda *a, **kw: object()
|
||||
)
|
||||
monkeypatch.setattr("seedpass.core.manager.NostrClient", lambda *a, **kw: object())
|
||||
|
||||
calls = {"count": 0}
|
||||
|
||||
@@ -41,7 +36,7 @@ def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path):
|
||||
|
||||
monkeypatch.setattr(PasswordManager, "start_background_sync", fake_bg)
|
||||
|
||||
assert pm.handle_switch_fingerprint()
|
||||
assert pm.handle_switch_fingerprint(password="pw")
|
||||
assert calls["count"] == 1
|
||||
|
||||
|
||||
|
@@ -4,8 +4,8 @@ from tempfile import TemporaryDirectory
|
||||
|
||||
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
def test_backup_interval(monkeypatch):
|
||||
|
@@ -8,8 +8,8 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
def test_backup_restore_workflow(monkeypatch):
|
||||
|
@@ -5,7 +5,7 @@ import pytest
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from local_bip85.bip85 import BIP85, Bip85Error
|
||||
from password_manager.password_generation import (
|
||||
from seedpass.core.password_generation import (
|
||||
derive_ssh_key,
|
||||
derive_seed_phrase,
|
||||
)
|
||||
|
73
src/tests/test_cli_core_services.py
Normal file
73
src/tests/test_cli_core_services.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import typer
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from seedpass import cli
|
||||
from seedpass.cli import app
|
||||
from seedpass.core.entry_types import EntryType
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_cli_vault_unlock(monkeypatch):
|
||||
called = {}
|
||||
|
||||
def unlock_vault(pw):
|
||||
called["pw"] = pw
|
||||
return 0.5
|
||||
|
||||
pm = SimpleNamespace(unlock_vault=unlock_vault, select_fingerprint=lambda fp: None)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
monkeypatch.setattr(cli.typer, "prompt", lambda *a, **k: "pw")
|
||||
result = runner.invoke(app, ["vault", "unlock"])
|
||||
assert result.exit_code == 0
|
||||
assert "Unlocked in" in result.stdout
|
||||
assert called["pw"] == "pw"
|
||||
|
||||
|
||||
def test_cli_entry_add_search_sync(monkeypatch):
|
||||
calls = {}
|
||||
|
||||
def add_entry(label, length, username=None, url=None):
|
||||
calls["add"] = (label, length, username, url)
|
||||
return 1
|
||||
|
||||
def search_entries(q, kinds=None):
|
||||
calls["search"] = (q, kinds)
|
||||
return [(1, "Label", None, None, False, EntryType.PASSWORD)]
|
||||
|
||||
def start_background_vault_sync():
|
||||
calls["sync"] = True
|
||||
return {"manifest_id": "m", "chunk_ids": [], "delta_ids": []}
|
||||
|
||||
pm = SimpleNamespace(
|
||||
entry_manager=SimpleNamespace(
|
||||
add_entry=add_entry, search_entries=search_entries
|
||||
),
|
||||
start_background_vault_sync=start_background_vault_sync,
|
||||
sync_vault=lambda: {"manifest_id": "m", "chunk_ids": [], "delta_ids": []},
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
|
||||
# entry add
|
||||
result = runner.invoke(app, ["entry", "add", "Label"])
|
||||
assert result.exit_code == 0
|
||||
assert "1" in result.stdout
|
||||
assert calls["add"] == ("Label", 12, None, None)
|
||||
assert calls.get("sync") is True
|
||||
|
||||
# entry search
|
||||
result = runner.invoke(
|
||||
app, ["entry", "search", "lab", "--kind", "password", "--kind", "totp"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Label" in result.stdout
|
||||
assert calls["search"] == ("lab", ["password", "totp"])
|
||||
|
||||
# nostr sync
|
||||
result = runner.invoke(app, ["nostr", "sync"])
|
||||
assert result.exit_code == 0
|
||||
assert "manifest" in result.stdout.lower()
|
||||
assert calls.get("sync") is True
|
@@ -8,7 +8,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from typer.testing import CliRunner
|
||||
from seedpass import cli
|
||||
from password_manager.entry_types import EntryType
|
||||
from seedpass.core.entry_types import EntryType
|
||||
|
||||
|
||||
class DummyPM:
|
||||
@@ -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: [(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,
|
||||
@@ -40,10 +42,10 @@ class DummyPM:
|
||||
self.handle_display_totp_codes = lambda: None
|
||||
self.handle_export_database = lambda path: None
|
||||
self.handle_import_database = lambda path: None
|
||||
self.change_password = lambda: None
|
||||
self.change_password = lambda *a, **kw: None
|
||||
self.lock_vault = lambda: None
|
||||
self.get_profile_stats = lambda: {"n": 1}
|
||||
self.handle_backup_reveal_parent_seed = lambda path=None: None
|
||||
self.handle_backup_reveal_parent_seed = lambda path=None, **_: None
|
||||
self.handle_verify_checksum = lambda: None
|
||||
self.handle_update_script_checksum = lambda: None
|
||||
self.add_new_fingerprint = lambda: None
|
||||
@@ -53,7 +55,12 @@ class DummyPM:
|
||||
self.nostr_client = SimpleNamespace(
|
||||
key_manager=SimpleNamespace(get_npub=lambda: "npub")
|
||||
)
|
||||
self.sync_vault = lambda: "event"
|
||||
self.sync_vault = lambda: {
|
||||
"manifest_id": "event",
|
||||
"chunk_ids": ["c1"],
|
||||
"delta_ids": [],
|
||||
}
|
||||
self.start_background_vault_sync = lambda *a, **k: self.sync_vault()
|
||||
self.config_manager = SimpleNamespace(
|
||||
load_config=lambda require_pin=False: {"inactivity_timeout": 30},
|
||||
set_inactivity_timeout=lambda v: None,
|
||||
@@ -72,7 +79,7 @@ class DummyPM:
|
||||
)
|
||||
self.secret_mode_enabled = True
|
||||
self.clipboard_clear_delay = 30
|
||||
self.select_fingerprint = lambda fp: None
|
||||
self.select_fingerprint = lambda fp, **_: None
|
||||
|
||||
|
||||
def load_doc_commands() -> list[str]:
|
||||
@@ -80,7 +87,9 @@ def load_doc_commands() -> list[str]:
|
||||
cmds = set(re.findall(r"`seedpass ([^`<>]+)`", text))
|
||||
cmds = {c for c in cmds if "<" not in c and ">" not in c}
|
||||
cmds.discard("vault export")
|
||||
cmds.discard("vault export --file backup.json")
|
||||
cmds.discard("vault import")
|
||||
cmds.discard("vault import --file backup.json")
|
||||
return sorted(cmds)
|
||||
|
||||
|
||||
|
@@ -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",
|
||||
),
|
||||
@@ -115,14 +139,14 @@ def test_entry_add_commands(
|
||||
called["kwargs"] = kwargs
|
||||
return stdout
|
||||
|
||||
def sync_vault():
|
||||
def start_background_vault_sync():
|
||||
called["sync"] = True
|
||||
|
||||
pm = SimpleNamespace(
|
||||
entry_manager=SimpleNamespace(**{method: func}),
|
||||
parent_seed="seed",
|
||||
select_fingerprint=lambda fp: None,
|
||||
sync_vault=sync_vault,
|
||||
start_background_vault_sync=start_background_vault_sync,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["entry", command] + cli_args)
|
||||
|
@@ -6,9 +6,9 @@ import sys
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import main
|
||||
from password_manager.portable_backup import export_backup, import_backup
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from password_manager.backup import BackupManager
|
||||
from seedpass.core.portable_backup import export_backup, import_backup
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from helpers import create_vault, TEST_SEED
|
||||
|
||||
|
||||
|
93
src/tests/test_cli_integration.py
Normal file
93
src/tests/test_cli_integration.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import importlib
|
||||
import shutil
|
||||
from contextlib import redirect_stdout
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from tests.helpers import TEST_PASSWORD, TEST_SEED
|
||||
|
||||
import colorama
|
||||
import constants
|
||||
import seedpass.cli as cli_module
|
||||
import seedpass.core.manager as manager_module
|
||||
import utils.password_prompt as pwd_prompt
|
||||
|
||||
|
||||
def test_cli_integration(monkeypatch, tmp_path):
|
||||
"""Exercise basic CLI flows without interactive prompts."""
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setattr(colorama, "init", lambda *a, **k: None)
|
||||
monkeypatch.setattr(pwd_prompt, "colorama_init", lambda: None)
|
||||
importlib.reload(constants)
|
||||
importlib.reload(manager_module)
|
||||
importlib.reload(pwd_prompt)
|
||||
importlib.reload(cli_module)
|
||||
|
||||
# Bypass user prompts and background threads
|
||||
monkeypatch.setattr(manager_module, "prompt_seed_words", lambda *a, **k: TEST_SEED)
|
||||
monkeypatch.setattr(manager_module, "prompt_new_password", lambda: TEST_PASSWORD)
|
||||
monkeypatch.setattr(manager_module, "prompt_for_password", lambda: TEST_PASSWORD)
|
||||
monkeypatch.setattr(
|
||||
manager_module, "prompt_existing_password", lambda *a, **k: TEST_PASSWORD
|
||||
)
|
||||
monkeypatch.setattr(manager_module, "confirm_action", lambda *a, **k: True)
|
||||
monkeypatch.setattr(manager_module, "masked_input", lambda *_: TEST_SEED)
|
||||
monkeypatch.setattr(
|
||||
manager_module.PasswordManager, "start_background_sync", lambda *a, **k: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager_module.PasswordManager,
|
||||
"start_background_vault_sync",
|
||||
lambda *a, **k: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager_module.PasswordManager,
|
||||
"start_background_relay_check",
|
||||
lambda *a, **k: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager_module, "NostrClient", lambda *a, **k: SimpleNamespace()
|
||||
)
|
||||
|
||||
def auto_add(self):
|
||||
return self.setup_existing_seed(
|
||||
method="paste", seed=TEST_SEED, password=TEST_PASSWORD
|
||||
)
|
||||
|
||||
monkeypatch.setattr(manager_module.PasswordManager, "add_new_fingerprint", auto_add)
|
||||
monkeypatch.setattr("builtins.input", lambda *a, **k: "1")
|
||||
|
||||
buf = StringIO()
|
||||
with redirect_stdout(buf):
|
||||
try:
|
||||
cli_module.app(["fingerprint", "add"])
|
||||
except SystemExit as e:
|
||||
assert e.code == 0
|
||||
buf.truncate(0)
|
||||
buf.seek(0)
|
||||
|
||||
with redirect_stdout(buf):
|
||||
try:
|
||||
cli_module.app(["entry", "add", "Example", "--length", "8"])
|
||||
except SystemExit as e:
|
||||
assert e.code == 0
|
||||
buf.truncate(0)
|
||||
buf.seek(0)
|
||||
|
||||
with redirect_stdout(buf):
|
||||
try:
|
||||
cli_module.app(["entry", "get", "Example"])
|
||||
except SystemExit as e:
|
||||
assert e.code == 0
|
||||
lines = [line for line in buf.getvalue().splitlines() if line.strip()]
|
||||
password = lines[-1]
|
||||
assert len(password.strip()) >= 8
|
||||
|
||||
fm = manager_module.FingerprintManager(constants.APP_DIR)
|
||||
fp = fm.current_fingerprint
|
||||
assert fp is not None
|
||||
index_file = constants.APP_DIR / fp / "seedpass_entries_db.json.enc"
|
||||
assert index_file.exists()
|
||||
|
||||
shutil.rmtree(constants.APP_DIR, ignore_errors=True)
|
53
src/tests/test_cli_relays.py
Normal file
53
src/tests/test_cli_relays.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from types import SimpleNamespace
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from seedpass.cli import app
|
||||
from seedpass import cli
|
||||
|
||||
|
||||
class DummyService:
|
||||
def __init__(self, relays):
|
||||
self.relays = relays
|
||||
|
||||
def get_pubkey(self):
|
||||
return "npub"
|
||||
|
||||
def list_relays(self):
|
||||
return self.relays
|
||||
|
||||
def add_relay(self, url):
|
||||
if url in self.relays:
|
||||
raise ValueError("exists")
|
||||
self.relays.append(url)
|
||||
|
||||
def remove_relay(self, idx):
|
||||
if not 1 <= idx <= len(self.relays):
|
||||
raise ValueError("bad")
|
||||
if len(self.relays) == 1:
|
||||
raise ValueError("min")
|
||||
self.relays.pop(idx - 1)
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_cli_relay_crud(monkeypatch):
|
||||
relays = ["wss://a"]
|
||||
|
||||
def pm_factory(*a, **k):
|
||||
return SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr(cli, "PasswordManager", pm_factory)
|
||||
monkeypatch.setattr(cli, "NostrService", lambda pm: DummyService(relays))
|
||||
|
||||
result = runner.invoke(app, ["nostr", "list-relays"])
|
||||
assert "1: wss://a" in result.stdout
|
||||
|
||||
result = runner.invoke(app, ["nostr", "add-relay", "wss://b"])
|
||||
assert result.exit_code == 0
|
||||
assert "Added" in result.stdout
|
||||
assert relays == ["wss://a", "wss://b"]
|
||||
|
||||
result = runner.invoke(app, ["nostr", "remove-relay", "1"])
|
||||
assert result.exit_code == 0
|
||||
assert relays == ["wss://b"]
|
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import main
|
||||
from password_manager.entry_types import EntryType
|
||||
from seedpass.core.entry_types import EntryType
|
||||
|
||||
|
||||
def make_pm(search_results, entry=None, totp_code="123456"):
|
||||
@@ -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)
|
||||
|
@@ -6,10 +6,10 @@ from helpers import TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from password_manager.vault import Vault
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
from seedpass.core.vault import Vault
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from utils.key_derivation import derive_index_key, derive_key_from_password
|
||||
|
||||
|
||||
|
@@ -7,8 +7,8 @@ import sys
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from password_manager.vault import Vault
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.vault import Vault
|
||||
from nostr.client import DEFAULT_RELAYS
|
||||
from constants import INACTIVITY_TIMEOUT
|
||||
|
||||
@@ -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
|
||||
|
72
src/tests/test_core_services.py
Normal file
72
src/tests/test_core_services.py
Normal file
@@ -0,0 +1,72 @@
|
||||
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():
|
||||
called = {}
|
||||
|
||||
def unlock_vault(pw: str) -> float:
|
||||
called["pw"] = pw
|
||||
return 0.42
|
||||
|
||||
pm = SimpleNamespace(unlock_vault=unlock_vault)
|
||||
service = VaultService(pm)
|
||||
resp = service.unlock(UnlockRequest(password="secret"))
|
||||
assert called["pw"] == "secret"
|
||||
assert resp.duration == 0.42
|
||||
|
||||
|
||||
def test_entry_service_add_entry_and_search():
|
||||
called = {}
|
||||
|
||||
def add_entry(label, length, username=None, url=None):
|
||||
called["add"] = (label, length, username, url)
|
||||
return 5
|
||||
|
||||
def search_entries(q, kinds=None):
|
||||
called["search"] = (q, kinds)
|
||||
return [(5, "Example", username, url, False, EntryType.PASSWORD)]
|
||||
|
||||
def start_background_vault_sync():
|
||||
called["sync"] = True
|
||||
|
||||
username = "user"
|
||||
url = "https://ex.com"
|
||||
pm = SimpleNamespace(
|
||||
entry_manager=SimpleNamespace(
|
||||
add_entry=add_entry, search_entries=search_entries
|
||||
),
|
||||
start_background_vault_sync=start_background_vault_sync,
|
||||
)
|
||||
service = EntryService(pm)
|
||||
idx = service.add_entry("Example", 12, username, url)
|
||||
assert idx == 5
|
||||
assert called["add"] == ("Example", 12, username, url)
|
||||
assert called.get("sync") is True
|
||||
|
||||
results = service.search_entries("ex", kinds=["password"])
|
||||
assert results == [(5, "Example", username, url, False, EntryType.PASSWORD)]
|
||||
assert called["search"] == ("ex", ["password"])
|
||||
|
||||
|
||||
def test_sync_service_sync():
|
||||
called = {}
|
||||
|
||||
def sync_vault():
|
||||
called["sync"] = True
|
||||
return {
|
||||
"manifest_id": "m1",
|
||||
"chunk_ids": ["c1"],
|
||||
"delta_ids": ["d1"],
|
||||
}
|
||||
|
||||
pm = SimpleNamespace(sync_vault=sync_vault)
|
||||
service = SyncService(pm)
|
||||
resp = service.sync()
|
||||
assert called["sync"] is True
|
||||
assert resp.manifest_id == "m1"
|
||||
assert resp.chunk_ids == ["c1"]
|
||||
assert resp.delta_ids == ["d1"]
|
@@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.manager import PasswordManager, EncryptionMode
|
||||
from password_manager.config_manager import ConfigManager
|
||||
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_retrieve_entry_shows_custom_fields(monkeypatch, capsys):
|
||||
|
@@ -6,7 +6,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
|
||||
from password_manager.manager import PasswordManager
|
||||
from seedpass.core.manager import PasswordManager
|
||||
from utils.key_derivation import EncryptionMode
|
||||
|
||||
|
||||
|
49
src/tests/test_delta_merge.py
Normal file
49
src/tests/test_delta_merge.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import pytest
|
||||
|
||||
from helpers import create_vault
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
def _setup_mgr(path: Path):
|
||||
vault, _ = create_vault(path)
|
||||
cfg = ConfigManager(vault, path)
|
||||
backup = BackupManager(path, cfg)
|
||||
return vault, EntryManager(vault, backup)
|
||||
|
||||
|
||||
def test_merge_modified_ts():
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
base = Path(tmpdir)
|
||||
va, ema = _setup_mgr(base / "A")
|
||||
vb, emb = _setup_mgr(base / "B")
|
||||
|
||||
idx0 = ema.add_entry("a", 8)
|
||||
idx1 = ema.add_entry("b", 8)
|
||||
|
||||
# B starts from A's snapshot
|
||||
enc = va.get_encrypted_index() or b""
|
||||
vb.decrypt_and_save_index_from_nostr(enc, merge=False)
|
||||
emb.clear_cache()
|
||||
assert emb.retrieve_entry(idx0)["username"] == ""
|
||||
|
||||
ema.modify_entry(idx0, username="ua")
|
||||
delta_a = va.get_encrypted_index() or b""
|
||||
vb.decrypt_and_save_index_from_nostr(delta_a, merge=True)
|
||||
emb.clear_cache()
|
||||
assert emb.retrieve_entry(idx0)["username"] == "ua"
|
||||
|
||||
emb.modify_entry(idx1, username="ub")
|
||||
delta_b = vb.get_encrypted_index() or b""
|
||||
va.decrypt_and_save_index_from_nostr(delta_b, merge=True)
|
||||
ema.clear_cache()
|
||||
assert ema.retrieve_entry(idx1)["username"] == "ub"
|
||||
|
||||
assert ema.retrieve_entry(idx0)["username"] == "ua"
|
||||
assert ema.retrieve_entry(idx1)["username"] == "ub"
|
||||
assert emb.retrieve_entry(idx0)["username"] == "ua"
|
||||
assert emb.retrieve_entry(idx1)["username"] == "ub"
|
@@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.manager import PasswordManager, EncryptionMode
|
||||
from password_manager.config_manager import ConfigManager
|
||||
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
|
||||
|
||||
|
||||
class FakePasswordGenerator:
|
||||
|
@@ -8,7 +8,7 @@ import base64
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
from utils.checksum import verify_and_update_checksum
|
||||
|
||||
|
||||
|
@@ -8,7 +8,7 @@ import base64
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
|
||||
|
||||
def test_json_save_and_load_round_trip():
|
||||
|
@@ -5,10 +5,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.vault import Vault
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.vault import Vault
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
def test_list_entries_empty():
|
||||
|
@@ -8,10 +8,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.vault import Vault
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.vault import Vault
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
def test_add_and_retrieve_entry():
|
||||
@@ -44,7 +44,9 @@ def test_add_and_retrieve_entry():
|
||||
|
||||
data = enc_mgr.load_json_data(entry_mgr.index_file)
|
||||
assert str(index) in data.get("entries", {})
|
||||
assert data["entries"][str(index)] == entry
|
||||
stored = data["entries"][str(index)]
|
||||
stored.pop("modified_ts", None)
|
||||
assert stored == entry
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -71,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)
|
||||
@@ -114,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)),
|
||||
],
|
||||
)
|
||||
|
@@ -5,10 +5,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.vault import Vault
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.vault import Vault
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
def test_update_checksum_writes_to_expected_path():
|
||||
|
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)
|
@@ -8,11 +8,11 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.manager import PasswordManager, EncryptionMode
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from password_manager.totp import TotpManager
|
||||
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.totp import TotpManager
|
||||
|
||||
|
||||
class FakeNostrClient:
|
||||
@@ -42,9 +42,7 @@ def test_handle_export_totp_codes(monkeypatch, tmp_path):
|
||||
|
||||
export_path = tmp_path / "out.json"
|
||||
monkeypatch.setattr("builtins.input", lambda *a, **k: str(export_path))
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.confirm_action", lambda *_a, **_k: False
|
||||
)
|
||||
monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: False)
|
||||
|
||||
pm.handle_export_totp_codes()
|
||||
|
||||
|
@@ -9,7 +9,7 @@ import base64
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from utils.fingerprint import generate_fingerprint
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
|
||||
|
||||
def test_generate_fingerprint_deterministic():
|
||||
|
@@ -4,10 +4,10 @@ from tempfile import TemporaryDirectory
|
||||
|
||||
from helpers import create_vault, dummy_nostr_client
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from password_manager.manager import PasswordManager, EncryptionMode
|
||||
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:
|
||||
@@ -44,10 +44,11 @@ def test_full_sync_roundtrip(dummy_nostr_client):
|
||||
# Manager A publishes initial snapshot
|
||||
pm_a.entry_manager.add_entry("site1", 12)
|
||||
pm_a.sync_vault()
|
||||
manifest_id = relay.manifests[-1].id
|
||||
manifest_id = relay.manifests[-1].tags[0]
|
||||
|
||||
# Manager B retrieves snapshot
|
||||
pm_b.sync_index_from_nostr_if_missing()
|
||||
result = pm_b.attempt_initial_sync()
|
||||
assert result is True
|
||||
entries = pm_b.entry_manager.list_entries()
|
||||
assert [e[1] for e in entries] == ["site1"]
|
||||
|
||||
|
@@ -4,10 +4,10 @@ from tempfile import TemporaryDirectory
|
||||
|
||||
from helpers import create_vault, dummy_nostr_client
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from password_manager.manager import PasswordManager, EncryptionMode
|
||||
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:
|
||||
@@ -44,10 +44,11 @@ def test_full_sync_roundtrip(dummy_nostr_client):
|
||||
# Manager A publishes initial snapshot
|
||||
pm_a.entry_manager.add_entry("site1", 12)
|
||||
pm_a.sync_vault()
|
||||
manifest_id = relay.manifests[-1].id
|
||||
manifest_id = relay.manifests[-1].tags[0]
|
||||
|
||||
# Manager B retrieves snapshot
|
||||
pm_b.sync_index_from_nostr_if_missing()
|
||||
result = pm_b.attempt_initial_sync()
|
||||
assert result is True
|
||||
entries = pm_b.entry_manager.list_entries()
|
||||
assert [e[1] for e in entries] == ["site1"]
|
||||
|
||||
|
@@ -9,7 +9,7 @@ from utils.key_derivation import (
|
||||
derive_key_from_password_argon2,
|
||||
derive_index_key,
|
||||
)
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
|
||||
|
||||
cfg_values = st.one_of(
|
||||
|
115
src/tests/test_gui_features.py
Normal file
115
src/tests/test_gui_features.py
Normal file
@@ -0,0 +1,115 @@
|
||||
import os
|
||||
import toga
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.desktop
|
||||
|
||||
from seedpass.core.pubsub import bus
|
||||
from seedpass_gui.app import MainWindow, RelayManagerDialog
|
||||
import seedpass_gui.app
|
||||
|
||||
|
||||
class DummyNostr:
|
||||
def __init__(self):
|
||||
self.relays = ["wss://a"]
|
||||
|
||||
def list_relays(self):
|
||||
return list(self.relays)
|
||||
|
||||
def add_relay(self, url):
|
||||
self.relays.append(url)
|
||||
|
||||
def remove_relay(self, idx):
|
||||
self.relays.pop(idx - 1)
|
||||
|
||||
|
||||
class DummyEntries:
|
||||
def __init__(self):
|
||||
self.data = [(1, "Example", None, None, False)]
|
||||
self.code = "111111"
|
||||
|
||||
def list_entries(self, sort_by="index", filter_kind=None, include_archived=False):
|
||||
if filter_kind:
|
||||
return [(idx, label, None, None, False) for idx, label, *_ in self.data]
|
||||
return self.data
|
||||
|
||||
def search_entries(self, q):
|
||||
return []
|
||||
|
||||
def retrieve_entry(self, idx):
|
||||
return {"period": 30}
|
||||
|
||||
def get_totp_code(self, idx):
|
||||
return self.code
|
||||
|
||||
|
||||
class DummyController:
|
||||
def __init__(self):
|
||||
self.lock_window = types.SimpleNamespace(show=lambda: None)
|
||||
self.main_window = None
|
||||
self.vault_service = None
|
||||
self.entry_service = None
|
||||
self.nostr_service = None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def set_backend():
|
||||
os.environ["TOGA_BACKEND"] = "toga_dummy"
|
||||
import asyncio
|
||||
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
|
||||
|
||||
def test_relay_manager_add_remove():
|
||||
toga.App("T", "o")
|
||||
ctrl = DummyController()
|
||||
nostr = DummyNostr()
|
||||
win = MainWindow(ctrl, None, DummyEntries(), nostr)
|
||||
dlg = RelayManagerDialog(win, nostr)
|
||||
dlg.new_input.value = "wss://b"
|
||||
dlg.add_relay(None)
|
||||
assert nostr.relays == ["wss://a", "wss://b"]
|
||||
dlg.remove_relay(None, index=1)
|
||||
assert nostr.relays == ["wss://b"]
|
||||
|
||||
|
||||
def test_status_bar_updates_and_lock():
|
||||
toga.App("T2", "o2")
|
||||
ctrl = DummyController()
|
||||
nostr = DummyNostr()
|
||||
ctrl.lock_window = types.SimpleNamespace(show=lambda: setattr(ctrl, "locked", True))
|
||||
win = MainWindow(ctrl, None, DummyEntries(), nostr)
|
||||
ctrl.main_window = win
|
||||
bus.publish("sync_started")
|
||||
assert win.status.text == "Syncing..."
|
||||
bus.publish("sync_finished")
|
||||
assert "Last sync:" in win.status.text
|
||||
bus.publish("vault_locked")
|
||||
assert getattr(ctrl, "locked", False)
|
||||
assert ctrl.main_window is None
|
||||
|
||||
|
||||
def test_totp_viewer_refresh_on_sync(monkeypatch):
|
||||
toga.App("T3", "o3")
|
||||
ctrl = DummyController()
|
||||
nostr = DummyNostr()
|
||||
entries = DummyEntries()
|
||||
win = MainWindow(ctrl, None, entries, nostr)
|
||||
ctrl.main_window = win
|
||||
ctrl.loop = types.SimpleNamespace(create_task=lambda c: None)
|
||||
|
||||
# prevent background loop from running
|
||||
monkeypatch.setattr(
|
||||
seedpass_gui.app.TotpViewerWindow, "_update_loop", lambda self: None
|
||||
)
|
||||
|
||||
viewer = seedpass_gui.app.TotpViewerWindow(ctrl, entries)
|
||||
bus.subscribe("sync_finished", viewer.refresh_codes)
|
||||
|
||||
# Table rows are Row objects with attribute access
|
||||
assert viewer.table.data[0].code == "111111"
|
||||
entries.code = "222222"
|
||||
bus.publish("sync_finished")
|
||||
assert viewer.table.data[0].code == "222222"
|
168
src/tests/test_gui_headless.py
Normal file
168
src/tests/test_gui_headless.py
Normal file
@@ -0,0 +1,168 @@
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from toga.sources import ListSource
|
||||
|
||||
import toga
|
||||
import pytest
|
||||
|
||||
from seedpass.core.entry_types import EntryType
|
||||
|
||||
from seedpass_gui.app import LockScreenWindow, MainWindow, EntryDialog
|
||||
|
||||
|
||||
class FakeVault:
|
||||
def __init__(self):
|
||||
self.called = False
|
||||
|
||||
def unlock(self, request):
|
||||
self.called = True
|
||||
|
||||
|
||||
class FakeEntries:
|
||||
def __init__(self):
|
||||
self.added = []
|
||||
self.modified = []
|
||||
|
||||
def list_entries(self):
|
||||
return []
|
||||
|
||||
def search_entries(self, query, kinds=None):
|
||||
return []
|
||||
|
||||
def add_entry(self, label, length, username=None, url=None):
|
||||
self.added.append(("password", label, length, username, url))
|
||||
return 1
|
||||
|
||||
def add_totp(self, label):
|
||||
self.added.append(("totp", label))
|
||||
return 1
|
||||
|
||||
def add_ssh_key(self, label):
|
||||
self.added.append(("ssh", label))
|
||||
return 1
|
||||
|
||||
def add_seed(self, label):
|
||||
self.added.append(("seed", label))
|
||||
return 1
|
||||
|
||||
def add_pgp_key(self, label):
|
||||
self.added.append(("pgp", label))
|
||||
return 1
|
||||
|
||||
def add_nostr_key(self, label, seed=None):
|
||||
self.added.append(("nostr", label))
|
||||
return 1
|
||||
|
||||
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, key=None, value=None
|
||||
):
|
||||
self.modified.append((entry_id, username, url, label, key, value))
|
||||
|
||||
|
||||
def setup_module(module):
|
||||
os.environ["TOGA_BACKEND"] = "toga_dummy"
|
||||
import asyncio
|
||||
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
|
||||
|
||||
class FakeNostr:
|
||||
def list_relays(self):
|
||||
return []
|
||||
|
||||
def add_relay(self, url):
|
||||
pass
|
||||
|
||||
def remove_relay(self, idx):
|
||||
pass
|
||||
|
||||
|
||||
def test_unlock_creates_main_window():
|
||||
app = toga.App("Test", "org.example")
|
||||
controller = SimpleNamespace(main_window=None, nostr_service=FakeNostr())
|
||||
vault = FakeVault()
|
||||
entries = FakeEntries()
|
||||
win = LockScreenWindow(controller, vault, entries)
|
||||
win.password_input.value = "pw"
|
||||
win.handle_unlock(None)
|
||||
|
||||
assert vault.called
|
||||
assert isinstance(controller.main_window, MainWindow)
|
||||
controller.main_window.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind,expect",
|
||||
[
|
||||
(EntryType.PASSWORD.value, ("password", "L", 12, "u", "x")),
|
||||
(EntryType.TOTP.value, ("totp", "L")),
|
||||
(EntryType.SSH.value, ("ssh", "L")),
|
||||
(EntryType.SEED.value, ("seed", "L")),
|
||||
(EntryType.PGP.value, ("pgp", "L")),
|
||||
(EntryType.NOSTR.value, ("nostr", "L")),
|
||||
(EntryType.KEY_VALUE.value, ("key_value", "L", "k1", "val")),
|
||||
(EntryType.MANAGED_ACCOUNT.value, ("managed_account", "L")),
|
||||
],
|
||||
)
|
||||
def test_entrydialog_add_calls_service(kind, expect):
|
||||
toga.App("Test2", "org.example2")
|
||||
entries = FakeEntries()
|
||||
entries.retrieve_entry = lambda _id: {"kind": kind}
|
||||
source = ListSource(["id", "label", "kind", "info1", "info2"])
|
||||
main = SimpleNamespace(entries=entries, entry_source=source)
|
||||
|
||||
dlg = EntryDialog(main, None)
|
||||
dlg.label_input.value = "L"
|
||||
dlg.kind_input.value = kind
|
||||
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)
|
||||
|
||||
assert entries.added[-1] == expect
|
||||
assert len(main.entry_source) == 1
|
||||
row = main.entry_source[0]
|
||||
assert row.label == "L"
|
||||
assert row.kind == kind
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind,expected",
|
||||
[
|
||||
(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):
|
||||
toga.App("Edit", "org.edit")
|
||||
entries = FakeEntries()
|
||||
|
||||
def retrieve(_id):
|
||||
return {"kind": kind}
|
||||
|
||||
entries.retrieve_entry = retrieve
|
||||
source = ListSource(["id", "label", "kind", "info1", "info2"])
|
||||
source.append({"id": 1, "label": "Old", "kind": kind, "info1": "", "info2": ""})
|
||||
main = SimpleNamespace(entries=entries, entry_source=source)
|
||||
dlg = EntryDialog(main, 1)
|
||||
dlg.label_input.value = "New"
|
||||
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)
|
||||
|
||||
assert entries.modified[-1] == expected
|
||||
assert source[0].label == "New"
|
77
src/tests/test_gui_sync.py
Normal file
77
src/tests/test_gui_sync.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import os
|
||||
import types
|
||||
import asyncio
|
||||
import toga
|
||||
import pytest
|
||||
|
||||
from seedpass.core.pubsub import bus
|
||||
from seedpass_gui.app import MainWindow
|
||||
|
||||
|
||||
class DummyEntries:
|
||||
def list_entries(self, sort_by="index", filter_kind=None, include_archived=False):
|
||||
return []
|
||||
|
||||
def search_entries(self, q):
|
||||
return []
|
||||
|
||||
|
||||
class DummyNostr:
|
||||
def __init__(self):
|
||||
self.called = False
|
||||
|
||||
def start_background_vault_sync(self):
|
||||
self.called = True
|
||||
|
||||
def list_relays(self):
|
||||
return []
|
||||
|
||||
|
||||
class DummyController:
|
||||
def __init__(self, loop):
|
||||
self.loop = loop
|
||||
self.lock_window = types.SimpleNamespace(show=lambda: None)
|
||||
self.main_window = None
|
||||
self.vault_service = None
|
||||
self.entry_service = None
|
||||
self.nostr_service = None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def set_backend():
|
||||
os.environ["TOGA_BACKEND"] = "toga_dummy"
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
|
||||
|
||||
def test_start_vault_sync_schedules_task():
|
||||
toga.App("T", "o")
|
||||
|
||||
tasks = []
|
||||
|
||||
def create_task(coro):
|
||||
tasks.append(coro)
|
||||
|
||||
loop = types.SimpleNamespace(create_task=create_task)
|
||||
ctrl = DummyController(loop)
|
||||
nostr = DummyNostr()
|
||||
win = MainWindow(ctrl, None, DummyEntries(), nostr)
|
||||
|
||||
win.start_vault_sync()
|
||||
assert tasks
|
||||
asyncio.get_event_loop().run_until_complete(tasks[0])
|
||||
assert nostr.called
|
||||
win.cleanup()
|
||||
|
||||
|
||||
def test_status_updates_on_bus_events():
|
||||
toga.App("T2", "o2")
|
||||
loop = types.SimpleNamespace(create_task=lambda c: None)
|
||||
ctrl = DummyController(loop)
|
||||
nostr = DummyNostr()
|
||||
win = MainWindow(ctrl, None, DummyEntries(), nostr)
|
||||
|
||||
bus.publish("sync_started")
|
||||
assert win.status.text == "Syncing..."
|
||||
bus.publish("sync_finished")
|
||||
assert "Last sync:" in win.status.text
|
||||
win.cleanup()
|
@@ -91,3 +91,32 @@ def test_input_timeout_triggers_lock(monkeypatch):
|
||||
|
||||
assert locked["locked"] == 1
|
||||
assert locked["unlocked"] == 1
|
||||
|
||||
|
||||
def test_update_activity_checks_timeout(monkeypatch):
|
||||
"""AuthGuard in update_activity locks the vault after inactivity."""
|
||||
import seedpass.core.manager as manager
|
||||
|
||||
now = {"val": 0.0}
|
||||
monkeypatch.setattr(manager.time, "time", lambda: now["val"])
|
||||
|
||||
pm = manager.PasswordManager.__new__(manager.PasswordManager)
|
||||
pm.inactivity_timeout = 0.5
|
||||
pm.last_activity = 0.0
|
||||
pm.locked = False
|
||||
called = {}
|
||||
|
||||
def lock():
|
||||
called["locked"] = True
|
||||
pm.locked = True
|
||||
|
||||
pm.lock_vault = lock
|
||||
pm.auth_guard = manager.AuthGuard(pm, time_fn=lambda: now["val"])
|
||||
|
||||
now["val"] = 0.4
|
||||
pm.update_activity()
|
||||
assert not called
|
||||
|
||||
now["val"] = 1.1
|
||||
pm.update_activity()
|
||||
assert called["locked"] is True
|
||||
|
@@ -3,9 +3,9 @@ from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
def test_index_caching():
|
||||
|
@@ -7,8 +7,8 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from password_manager.vault import Vault
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
from seedpass.core.vault import Vault
|
||||
from utils.key_derivation import derive_index_key, derive_key_from_password
|
||||
|
||||
SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
@@ -8,10 +8,10 @@ from utils.key_derivation import (
|
||||
derive_key_from_password_argon2,
|
||||
derive_index_key,
|
||||
)
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from password_manager.vault import Vault
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from password_manager.manager import PasswordManager, EncryptionMode
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
from seedpass.core.vault import Vault
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.manager import PasswordManager, EncryptionMode
|
||||
|
||||
TEST_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
TEST_PASSWORD = "pw"
|
||||
@@ -59,12 +59,12 @@ def test_setup_encryption_manager_kdf_modes(monkeypatch):
|
||||
cfg = _setup_profile(path, mode)
|
||||
pm = _make_pm(path, cfg)
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.prompt_existing_password",
|
||||
"seedpass.core.manager.prompt_existing_password",
|
||||
lambda *_: TEST_PASSWORD,
|
||||
)
|
||||
if mode == "argon2":
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.derive_key_from_password_argon2",
|
||||
"seedpass.core.manager.derive_key_from_password_argon2",
|
||||
lambda pw: derive_key_from_password_argon2(pw, **argon_kwargs),
|
||||
)
|
||||
monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: 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)
|
@@ -6,9 +6,9 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
def setup_entry_mgr(tmp_path: Path) -> EntryManager:
|
||||
@@ -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,9 +37,10 @@ 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")
|
||||
assert results == [(idx, "API", None, None, False)]
|
||||
assert results == []
|
||||
|
@@ -3,9 +3,9 @@ from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import constants
|
||||
import password_manager.manager as manager_module
|
||||
import seedpass.core.manager as manager_module
|
||||
from utils.fingerprint_manager import FingerprintManager
|
||||
from password_manager.manager import EncryptionMode
|
||||
from seedpass.core.manager import EncryptionMode
|
||||
|
||||
from helpers import TEST_SEED
|
||||
|
||||
|
@@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from password_manager.entry_types import EntryType
|
||||
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:
|
||||
@@ -19,29 +19,35 @@ def setup_entry_manager(tmp_path: Path) -> EntryManager:
|
||||
return EntryManager(vault, backup_mgr)
|
||||
|
||||
|
||||
def test_sort_by_website():
|
||||
def test_sort_by_label():
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
em = setup_entry_manager(tmp_path)
|
||||
idx0 = em.add_entry("b.com", 8, "user1")
|
||||
idx1 = em.add_entry("A.com", 8, "user2")
|
||||
result = em.list_entries(sort_by="website")
|
||||
result = em.list_entries(sort_by="label")
|
||||
assert result == [
|
||||
(idx1, "A.com", "user2", "", False),
|
||||
(idx0, "b.com", "user1", "", False),
|
||||
]
|
||||
|
||||
|
||||
def test_sort_by_username():
|
||||
def test_sort_by_updated():
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
em = setup_entry_manager(tmp_path)
|
||||
idx0 = em.add_entry("alpha.com", 8, "Charlie")
|
||||
idx1 = em.add_entry("beta.com", 8, "alice")
|
||||
result = em.list_entries(sort_by="username")
|
||||
idx0 = em.add_entry("alpha.com", 8, "u0")
|
||||
idx1 = em.add_entry("beta.com", 8, "u1")
|
||||
|
||||
data = em._load_index(force_reload=True)
|
||||
data["entries"][str(idx0)]["updated"] = 1
|
||||
data["entries"][str(idx1)]["updated"] = 2
|
||||
em._save_index(data)
|
||||
|
||||
result = em.list_entries(sort_by="updated")
|
||||
assert result == [
|
||||
(idx1, "beta.com", "alice", "", False),
|
||||
(idx0, "alpha.com", "Charlie", "", False),
|
||||
(idx1, "beta.com", "u1", "", False),
|
||||
(idx0, "alpha.com", "u0", "", False),
|
||||
]
|
||||
|
||||
|
||||
|
@@ -4,14 +4,14 @@ from tempfile import TemporaryDirectory
|
||||
|
||||
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
from utils.fingerprint import generate_fingerprint
|
||||
import password_manager.manager as manager_module
|
||||
from password_manager.manager import EncryptionMode
|
||||
import seedpass.core.manager as manager_module
|
||||
from seedpass.core.manager import EncryptionMode
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
def setup_entry_manager(tmp_path: Path) -> EntryManager:
|
||||
|
@@ -4,15 +4,15 @@ from tempfile import TemporaryDirectory
|
||||
|
||||
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
from utils.fingerprint import generate_fingerprint
|
||||
import password_manager.manager as manager_module
|
||||
from password_manager.manager import EncryptionMode
|
||||
import seedpass.core.manager as manager_module
|
||||
from seedpass.core.manager import EncryptionMode
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from password_manager.password_generation import derive_seed_phrase
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.password_generation import derive_seed_phrase
|
||||
from local_bip85.bip85 import BIP85
|
||||
from bip_utils import Bip39SeedGenerator
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user