Merge pull request #681 from PR0M3TH3AN/beta

Beta
This commit is contained in:
thePR0M3TH3AN
2025-07-26 20:26:10 -04:00
committed by GitHub
162 changed files with 4584 additions and 1164 deletions

27
.github/workflows/briefcase.yml vendored Normal file
View 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/**

View File

@@ -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:

138
README.md
View File

@@ -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)
@@ -32,6 +33,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
- [Managing Multiple Seeds](#managing-multiple-seeds)
- [Additional Entry Types](#additional-entry-types)
- [Building a standalone executable](#building-a-standalone-executable)
- [Packaging with Briefcase](#packaging-with-briefcase)
- [Security Considerations](#security-considerations)
- [Contributing](#contributing)
- [License](#license)
@@ -54,7 +56,7 @@ 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.
- **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.
@@ -65,9 +67,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 +112,7 @@ 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.
**Linux and macOS:**
```bash
@@ -87,6 +122,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 +132,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:
@@ -196,6 +246,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.
@@ -326,11 +423,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
@@ -494,6 +591,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 +638,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.

View File

@@ -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 youre 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.103.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 thats 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 youll 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
View 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.

View File

@@ -116,6 +116,10 @@ Miscellaneous helper commands.
| 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.

View File

@@ -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 onetime token used for authentication:

View File

@@ -0,0 +1,29 @@
# Packaging the GUI with Briefcase
This project uses [BeeWare's Briefcase](https://beeware.org) to generate
platformnative 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.

View 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.

View File

@@ -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
@@ -191,10 +207,10 @@ 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
@@ -481,6 +497,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

View File

@@ -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

View File

@@ -1,78 +0,0 @@
---
# SeedPass Feature BackLog (v2)
> **Encryption invariant**   Everything at rest **and** in export remains ciphertext that ultimately derives from the **profile masterpassword + 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 fingerprintscoped vault: parentseed + 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    Corelevel enhancements (blockers for GUI)
|  Prio  | Feature | Notes |
| ------ | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|  🔥 | **Encrypted Search API** | • `VaultService.search(query:str, *, kinds=None) -> List[EntryMeta]` <br>• Decrypt *only* whitelisted metafields per `kind` (title, username, url, tags) for inmemory matching. |
|  🔥 | **Rich Listing / Sort / Filter** | • `list_entries(sort_by="updated", kind="note")` <br>• Sorting by `title` must decrypt that field onthefly. |
|  🔥 | **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 15min). <br>`AuthGuard` clears inmemory keys & seeds. <br>• CLI `seedpass lock` + GUI menu “Lock vault”. |
**Exitcriteria** : 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 dropdown. |
|  🔥 | **Vault Window** | • Sidebar (Entries, Search, Backups, Settings). <br>`QTableView` bound to `VaultService.list_entries()` <br>• Sort & basic filters builtin. |
|  🔥 | **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
• Hardwarewallet unlock (SLIP39 share)
• Background daemon (`seedpassd` + gRPC)
• Mobile companion (Flutter FFI)
• Federated search across multiple profiles
---
**Reminder:** *No plaintext exports, no ondisk 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.

View File

@@ -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.42.1",
"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"

View File

@@ -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 0Tooling 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 1Finalize Core Refactor (CLI still primary)
> *Most of this is already drafted heres 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 2Core 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 3Desktop 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 4Unified 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 5Future-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.**

View File

@@ -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
@@ -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

View File

@@ -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

View File

@@ -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")

View File

@@ -86,13 +86,26 @@ main() {
# 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
if command -v apt-get &> /dev/null; then
sudo apt-get update && sudo apt-get install -y \
build-essential pkg-config xclip \
libcairo2 libcairo2-dev \
libgirepository-2.0-dev gir1.2-girepository-2.0 \
gobject-introspection \
gir1.2-gtk-3.0 python3-dev
elif command -v dnf &> /dev/null; then
sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y \
pkg-config cairo cairo-devel xclip \
gobject-introspection-devel cairo-devel gtk3-devel python3-devel
elif command -v pacman &> /dev/null; then
sudo pacman -Syu --noconfirm base-devel pkg-config cairo xclip \
gobject-introspection cairo gtk3 python
else
print_warning "Could not detect package manager. Ensure build tools, cairo, and pkg-config are installed."
fi
elif [ "$OS_NAME" = "Darwin" ]; then
if ! command -v brew &> /dev/null; then print_error "Homebrew not installed. See https://brew.sh/"; fi
brew install pkg-config
brew install pkg-config cairo
fi
# 4. Clone or update the repository
@@ -120,6 +133,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 +159,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
View 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

View File

@@ -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}")

View 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
# -----------------------------------

View File

@@ -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 (

View File

@@ -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
@@ -26,12 +27,12 @@ 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 +47,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
@@ -135,6 +139,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] = []
@@ -295,8 +300,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),
@@ -310,8 +315,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:
@@ -319,10 +323,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]:
@@ -365,6 +368,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):
@@ -390,22 +394,24 @@ 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
@@ -430,11 +436,24 @@ class NostrClient:
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:
attempt = 0
chunk_bytes: bytes | None = None
while attempt < MAX_RETRIES:
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))
@@ -447,18 +466,33 @@ class NostrClient:
if hashlib.sha256(candidate).hexdigest() == meta.hash:
chunk_bytes = candidate
break
attempt += 1
if attempt < MAX_RETRIES:
await asyncio.sleep(RETRY_DELAY)
if attempt < max_retries - 1:
await asyncio.sleep(delay * (2**attempt))
if chunk_bytes is None:
return None
chunks.append(chunk_bytes)
man_id = getattr(manifest_event, "id", None)
if hasattr(man_id, "to_hex"):
man_id = man_id.to_hex()
self.current_manifest = manifest
self.current_manifest_id = man_id
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:
@@ -467,24 +501,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(3)
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
for manifest_event in events:
result = await self._fetch_chunks_with_retry(manifest_event)
if result is not None:
return result
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,
)
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")
@@ -498,24 +584,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]:
@@ -533,12 +624,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
@@ -557,6 +652,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:

View File

@@ -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

View File

@@ -27,3 +27,4 @@ requests>=2.32
python-multipart
orjson
argon2-cffi
toga-core>=0.5.2

View File

@@ -14,8 +14,8 @@ 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
app = FastAPI()
@@ -554,11 +554,13 @@ 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"}

View File

@@ -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,10 +144,20 @@ 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
@@ -109,8 +173,8 @@ 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)
@@ -124,14 +188,14 @@ def entry_get(ctx: typer.Context, query: str) -> None:
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")
@@ -147,10 +211,9 @@ def entry_add(
url: Optional[str] = typer.Option(None, "--url"),
) -> None:
"""Add a new password entry and output its index."""
pm = _get_pm(ctx)
index = pm.entry_manager.add_entry(label, length, username, url)
service = _get_entry_service(ctx)
index = service.add_entry(label, length, username, url)
typer.echo(str(index))
pm.sync_vault()
@entry_app.command("add-totp")
@@ -163,17 +226,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 +245,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 +264,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 +283,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,16 +301,14 @@ 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")
@@ -265,10 +319,9 @@ def entry_add_key_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, value, notes=notes)
typer.echo(str(idx))
pm.sync_vault()
@entry_app.command("add-managed-account")
@@ -279,15 +332,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")
@@ -305,9 +356,9 @@ def entry_modify(
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,
@@ -319,33 +370,31 @@ def entry_modify(
)
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 +402,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 +412,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 +423,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,21 +491,24 @@ 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)
result = pm.sync_vault()
if result:
_vault, _profile, sync_service = _get_services(ctx)
model = sync_service.sync()
if model:
typer.echo("Event IDs:")
typer.echo(f"- manifest: {result['manifest_id']}")
for cid in result["chunk_ids"]:
typer.echo(f"- manifest: {model.manifest_id}")
for cid in model.chunk_ids:
typer.echo(f"- chunk: {cid}")
for did in result["delta_ids"]:
for did in model.delta_ids:
typer.echo(f"- delta: {did}")
else:
typer.echo("Error: Failed to sync vault")
@@ -434,16 +517,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:
@@ -453,43 +569,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)
@@ -499,12 +590,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)
@@ -536,10 +630,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)
@@ -551,10 +642,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)
@@ -573,8 +663,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)
@@ -586,52 +675,55 @@ 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:
"""Generate a strong password."""
pm = _get_pm(ctx)
password = pm.password_generator.generate_password(length)
service = _get_util_service(ctx)
password = service.generate_password(length)
typer.echo(password)
@util_app.command("verify-checksum")
def verify_checksum(ctx: typer.Context) -> None:
"""Verify the SeedPass script checksum."""
pm = _get_pm(ctx)
pm.handle_verify_checksum()
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")
@@ -657,5 +749,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()

View File

@@ -1,10 +1,10 @@
# 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"]
def __getattr__(name: str):
@@ -16,4 +16,6 @@ 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
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")

580
src/seedpass/core/api.py Normal file
View File

@@ -0,0 +1,580 @@
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
import json
from pydantic import BaseModel
from .manager import PasswordManager
from .pubsub import bus
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 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]]:
"""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:
return self._manager.password_generator.generate_password(length, index)
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,
) -> int:
with self._lock:
idx = self._manager.entry_manager.add_entry(label, length, username, url)
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,
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, value: str, *, notes: str = "") -> int:
with self._lock:
idx = self._manager.entry_manager.add_key_value(label, 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,
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,
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),
"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) -> str:
with self._lock:
return self._manager.password_generator.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()
)

View File

@@ -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

View File

@@ -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,8 +52,8 @@ 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,
@@ -77,8 +77,8 @@ 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)
@@ -251,7 +251,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(
@@ -303,7 +303,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 +316,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)

View File

@@ -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.")

View File

@@ -1,4 +1,4 @@
# password_manager/entry_management.py
# seedpass.core/entry_management.py
"""
Entry Management Module
@@ -27,18 +27,19 @@ 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 password_manager.vault import Vault
from password_manager.backup import BackupManager
from .vault import Vault
from .backup import BackupManager
# Instantiate the logger
@@ -97,6 +98,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
@@ -176,6 +178,7 @@ 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 [],
}
@@ -236,6 +239,7 @@ class EntryManager:
"type": EntryType.TOTP.value,
"kind": EntryType.TOTP.value,
"label": label,
"modified_ts": int(time.time()),
"index": index,
"period": period,
"digits": digits,
@@ -249,6 +253,7 @@ class EntryManager:
"kind": EntryType.TOTP.value,
"label": label,
"secret": secret,
"modified_ts": int(time.time()),
"period": period,
"digits": digits,
"archived": archived,
@@ -294,6 +299,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 +318,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)
@@ -340,6 +346,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 +367,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
@@ -392,6 +399,7 @@ class EntryManager:
"kind": EntryType.NOSTR.value,
"index": index,
"label": label,
"modified_ts": int(time.time()),
"notes": notes,
"archived": archived,
"tags": tags or [],
@@ -421,6 +429,7 @@ class EntryManager:
"type": EntryType.KEY_VALUE.value,
"kind": EntryType.KEY_VALUE.value,
"label": label,
"modified_ts": int(time.time()),
"value": value,
"notes": notes,
"archived": archived,
@@ -480,6 +489,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 +511,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 +540,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
@@ -552,6 +562,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 +587,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 +693,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"))
@@ -887,6 +899,8 @@ class EntryManager:
entry["tags"] = tags
logger.debug(f"Updated tags for index {index}: {tags}")
entry["modified_ts"] = int(time.time())
data["entries"][str(index)] = entry
logger.debug(f"Modified entry at index {index}: {entry}")
@@ -922,10 +936,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 +962,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 +1069,10 @@ class EntryManager:
return []
def search_entries(
self, query: str
self, query: str, kinds: List[str] | None = None
) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]:
"""Return entries matching the query across common fields."""
"""Return entries matching ``query`` across whitelisted metadata fields."""
data = self._load_index()
entries_data = data.get("entries", {})
@@ -1059,74 +1084,33 @@ class EntryManager:
for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])):
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
if kinds is not None and etype not in kinds:
continue
label = entry.get("label", entry.get("website", ""))
notes = entry.get("notes", "")
username = (
entry.get("username", "") if etype == EntryType.PASSWORD.value else None
)
url = entry.get("url", "") if etype == EntryType.PASSWORD.value 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,
)
)
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

View File

@@ -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

View File

@@ -1,4 +1,4 @@
# password_manager/manager.py
# seedpass.core/manager.py
"""
Password Manager Module
@@ -25,14 +25,15 @@ from termcolor import colored
from utils.color_scheme import color_text
from utils.input_utils import timed_input
from password_manager.encryption import EncryptionManager
from password_manager.entry_management import EntryManager
from password_manager.password_generation import PasswordGenerator
from password_manager.backup import BackupManager
from password_manager.vault import Vault
from password_manager.portable_backup import export_backup, import_backup
from password_manager.totp import TotpManager
from password_manager.entry_types import EntryType
from .encryption import EncryptionManager
from .entry_management import EntryManager
from .password_generation import PasswordGenerator
from .backup import BackupManager
from .vault import Vault
from .portable_backup import export_backup, import_backup
from .totp import TotpManager
from .entry_types import EntryType
from .pubsub import bus
from utils.key_derivation import (
derive_key_from_parent_seed,
derive_key_from_password,
@@ -64,7 +65,7 @@ from utils.terminal_utils import (
)
from utils.fingerprint import generate_fingerprint
from constants import MIN_HEALTHY_RELAYS
from password_manager.migrations import LATEST_VERSION
from .migrations import LATEST_VERSION
from constants import (
APP_DIR,
@@ -94,7 +95,8 @@ from utils.fingerprint_manager import FingerprintManager
# Import NostrClient
from nostr.client import NostrClient, DEFAULT_RELAYS
from password_manager.config_manager import ConfigManager
from .config_manager import ConfigManager
from .state_manager import StateManager
# Instantiate the logger
logger = logging.getLogger(__name__)
@@ -108,6 +110,24 @@ class Notification:
level: str = "INFO"
class AuthGuard:
"""Helper to enforce inactivity timeouts."""
def __init__(
self, manager: "PasswordManager", time_fn: callable = time.time
) -> None:
self.manager = manager
self._time_fn = time_fn
def check_timeout(self) -> None:
"""Lock the vault if the inactivity timeout has been exceeded."""
timeout = getattr(self.manager, "inactivity_timeout", 0)
if self.manager.locked or timeout <= 0:
return
if self._time_fn() - self.manager.last_activity > timeout:
self.manager.lock_vault()
class PasswordManager:
"""
PasswordManager Class
@@ -117,7 +137,9 @@ class PasswordManager:
verification, ensuring the integrity and confidentiality of the stored password database.
"""
def __init__(self, fingerprint: Optional[str] = None) -> None:
def __init__(
self, fingerprint: Optional[str] = None, *, password: Optional[str] = None
) -> None:
"""Initialize the PasswordManager.
Parameters
@@ -138,6 +160,7 @@ class PasswordManager:
self.bip85: Optional[BIP85] = None
self.nostr_client: Optional[NostrClient] = None
self.config_manager: Optional[ConfigManager] = None
self.state_manager: Optional[StateManager] = None
self.notifications: queue.Queue[Notification] = queue.Queue()
self._current_notification: Optional[Notification] = None
self._notification_expiry: float = 0.0
@@ -155,13 +178,16 @@ class PasswordManager:
self.last_unlock_duration: float | None = None
self.verbose_timing: bool = False
self._suppress_entry_actions_menu: bool = False
self.last_bip85_idx: int = 0
self.last_sync_ts: int = 0
self.auth_guard = AuthGuard(self)
# Initialize the fingerprint manager first
self.initialize_fingerprint_manager()
if fingerprint:
# Load the specified profile without prompting
self.select_fingerprint(fingerprint)
self.select_fingerprint(fingerprint, password=password)
else:
# Ensure a parent seed is set up before accessing the fingerprint directory
self.setup_parent_seed()
@@ -187,6 +213,11 @@ class PasswordManager:
)
)
@staticmethod
def get_password_prompt() -> str:
"""Return the standard prompt for requesting a master password."""
return "Enter your master password: "
@property
def parent_seed(self) -> Optional[str]:
"""Return the decrypted parent seed if set."""
@@ -229,7 +260,12 @@ class PasswordManager:
return (None, parent_fp, self.current_fingerprint)
def update_activity(self) -> None:
"""Record the current time as the last user activity."""
"""Record activity and enforce inactivity timeout."""
guard = getattr(self, "auth_guard", None)
if guard is None:
guard = AuthGuard(self)
self.auth_guard = guard
guard.check_timeout()
self.last_activity = time.time()
def notify(self, message: str, level: str = "INFO") -> None:
@@ -268,26 +304,35 @@ class PasswordManager:
self.nostr_client = None
self.config_manager = None
self.locked = True
bus.publish("vault_locked")
def unlock_vault(self) -> None:
"""Prompt for password and reinitialize managers."""
def unlock_vault(self, password: Optional[str] = None) -> float:
"""Unlock the vault using the provided ``password``.
Parameters
----------
password:
Master password for the active profile.
Returns
-------
float
Duration of the unlock process in seconds.
"""
start = time.perf_counter()
if not self.fingerprint_dir:
raise ValueError("Fingerprint directory not set")
self.setup_encryption_manager(self.fingerprint_dir)
if password is None:
password = prompt_existing_password(self.get_password_prompt())
self.setup_encryption_manager(self.fingerprint_dir, password)
self.initialize_bip85()
self.initialize_managers()
self.locked = False
self.update_activity()
self.last_unlock_duration = time.perf_counter() - start
print(
colored(
f"Vault unlocked in {self.last_unlock_duration:.2f} seconds",
"yellow",
)
)
if getattr(self, "verbose_timing", False):
logger.info("Vault unlocked in %.2f seconds", self.last_unlock_duration)
return self.last_unlock_duration
def initialize_fingerprint_manager(self):
"""
@@ -394,7 +439,9 @@ class PasswordManager:
print(colored(f"Error: Failed to add new seed profile: {e}", "red"))
sys.exit(1)
def select_fingerprint(self, fingerprint: str) -> None:
def select_fingerprint(
self, fingerprint: str, *, password: Optional[str] = None
) -> None:
if self.fingerprint_manager.select_fingerprint(fingerprint):
self.current_fingerprint = fingerprint # Add this line
self.fingerprint_dir = (
@@ -409,7 +456,7 @@ class PasswordManager:
)
sys.exit(1)
# Setup the encryption manager and load parent seed
self.setup_encryption_manager(self.fingerprint_dir)
self.setup_encryption_manager(self.fingerprint_dir, password)
# Initialize BIP85 and other managers
self.initialize_bip85()
self.initialize_managers()
@@ -531,7 +578,7 @@ class PasswordManager:
print(colored(f"Error: Failed to load parent seed: {e}", "red"))
sys.exit(1)
def handle_switch_fingerprint(self) -> bool:
def handle_switch_fingerprint(self, *, password: Optional[str] = None) -> bool:
"""
Handles switching to a different seed profile.
@@ -572,9 +619,10 @@ class PasswordManager:
return False # Return False to indicate failure
# Prompt for master password for the selected seed profile
password = prompt_existing_password(
"Enter the master password for the selected seed profile: "
)
if password is None:
password = prompt_existing_password(
"Enter the master password for the selected seed profile: "
)
# Set up the encryption manager with the new password and seed profile directory
if not self.setup_encryption_manager(
@@ -596,6 +644,17 @@ class PasswordManager:
config_manager=getattr(self, "config_manager", None),
parent_seed=getattr(self, "parent_seed", None),
)
if getattr(self, "manifest_id", None):
from nostr.backup_models import Manifest
with self.nostr_client._state_lock:
self.nostr_client.current_manifest_id = self.manifest_id
self.nostr_client.current_manifest = Manifest(
ver=1,
algo="gzip",
chunks=[],
delta_since=self.delta_since or None,
)
logging.info(
f"NostrClient re-initialized with seed profile {self.current_fingerprint}."
)
@@ -661,14 +720,14 @@ class PasswordManager:
self.update_activity()
self.start_background_sync()
def handle_existing_seed(self) -> None:
def handle_existing_seed(self, *, password: Optional[str] = None) -> None:
"""
Handles the scenario where an existing parent seed file is found.
Prompts the user for the master password to decrypt the seed.
"""
try:
# Prompt for password using masked input
password = prompt_existing_password("Enter your login password: ")
if password is None:
password = prompt_existing_password("Enter your login password: ")
# Derive encryption key from password
iterations = (
@@ -763,7 +822,11 @@ class PasswordManager:
sys.exit(1)
def setup_existing_seed(
self, method: Literal["paste", "words"] = "paste"
self,
method: Literal["paste", "words"] = "paste",
*,
seed: Optional[str] = None,
password: Optional[str] = None,
) -> Optional[str]:
"""Prompt for an existing BIP-85 seed and set it up.
@@ -779,7 +842,9 @@ class PasswordManager:
The fingerprint if setup is successful, ``None`` otherwise.
"""
try:
if method == "words":
if seed is not None:
parent_seed = seed
elif method == "words":
parent_seed = prompt_seed_words()
else:
parent_seed = masked_input("Enter your 12-word BIP-85 seed: ").strip()
@@ -789,17 +854,21 @@ class PasswordManager:
print(colored("Error: Invalid BIP-85 seed phrase.", "red"))
sys.exit(1)
return self._finalize_existing_seed(parent_seed)
return self._finalize_existing_seed(parent_seed, password=password)
except KeyboardInterrupt:
logging.info("Operation cancelled by user.")
self.notify("Operation cancelled by user.", level="WARNING")
sys.exit(0)
def setup_existing_seed_word_by_word(self) -> Optional[str]:
def setup_existing_seed_word_by_word(
self, *, seed: Optional[str] = None, password: Optional[str] = None
) -> Optional[str]:
"""Prompt for an existing seed one word at a time and set it up."""
return self.setup_existing_seed(method="words")
return self.setup_existing_seed(method="words", seed=seed, password=password)
def _finalize_existing_seed(self, parent_seed: str) -> Optional[str]:
def _finalize_existing_seed(
self, parent_seed: str, *, password: Optional[str] = None
) -> Optional[str]:
"""Common logic for initializing an existing seed."""
if self.validate_bip85_seed(parent_seed):
fingerprint = self.fingerprint_manager.add_fingerprint(parent_seed)
@@ -827,7 +896,8 @@ class PasswordManager:
logging.info(f"Current seed profile set to {fingerprint}")
try:
password = prompt_for_password()
if password is None:
password = prompt_for_password()
index_key = derive_index_key(parent_seed)
iterations = (
self.config_manager.get_kdf_iterations()
@@ -961,7 +1031,9 @@ class PasswordManager:
print(colored(f"Error: Failed to generate BIP-85 seed: {e}", "red"))
sys.exit(1)
def save_and_encrypt_seed(self, seed: str, fingerprint_dir: Path) -> None:
def save_and_encrypt_seed(
self, seed: str, fingerprint_dir: Path, *, password: Optional[str] = None
) -> None:
"""
Saves and encrypts the parent seed.
@@ -973,8 +1045,8 @@ class PasswordManager:
# Set self.fingerprint_dir
self.fingerprint_dir = fingerprint_dir
# Prompt for password
password = prompt_for_password()
if password is None:
password = prompt_for_password()
index_key = derive_index_key(seed)
iterations = (
@@ -1042,6 +1114,7 @@ class PasswordManager:
vault=self.vault,
fingerprint_dir=self.fingerprint_dir,
)
self.state_manager = StateManager(self.fingerprint_dir)
self.backup_manager = BackupManager(
fingerprint_dir=self.fingerprint_dir,
config_manager=self.config_manager,
@@ -1060,7 +1133,19 @@ class PasswordManager:
# Load relay configuration and initialize NostrClient
config = self.config_manager.load_config()
relay_list = config.get("relays", list(DEFAULT_RELAYS))
if getattr(self, "state_manager", None) is not None:
state = self.state_manager.state
relay_list = state.get("relays", list(DEFAULT_RELAYS))
self.last_bip85_idx = state.get("last_bip85_idx", 0)
self.last_sync_ts = state.get("last_sync_ts", 0)
self.manifest_id = state.get("manifest_id")
self.delta_since = state.get("delta_since", 0)
else:
relay_list = list(DEFAULT_RELAYS)
self.last_bip85_idx = 0
self.last_sync_ts = 0
self.manifest_id = None
self.delta_since = 0
self.offline_mode = bool(config.get("offline_mode", False))
self.inactivity_timeout = config.get(
"inactivity_timeout", INACTIVITY_TIMEOUT
@@ -1079,6 +1164,18 @@ class PasswordManager:
parent_seed=getattr(self, "parent_seed", None),
)
if getattr(self, "manifest_id", None):
from nostr.backup_models import Manifest
with self.nostr_client._state_lock:
self.nostr_client.current_manifest_id = self.manifest_id
self.nostr_client.current_manifest = Manifest(
ver=1,
algo="gzip",
chunks=[],
delta_since=self.delta_since or None,
)
logger.debug("Managers re-initialized for the new fingerprint.")
except Exception as e:
@@ -1086,45 +1183,77 @@ class PasswordManager:
print(colored(f"Error: Failed to initialize managers: {e}", "red"))
sys.exit(1)
def sync_index_from_nostr(self) -> None:
async def sync_index_from_nostr_async(self) -> None:
"""Always fetch the latest vault data from Nostr and update the local index."""
start = time.perf_counter()
try:
result = asyncio.run(self.nostr_client.fetch_latest_snapshot())
result = await self.nostr_client.fetch_latest_snapshot()
if not result:
if self.nostr_client.last_error:
logger.warning(
"Unable to fetch latest snapshot from Nostr relays %s: %s",
self.nostr_client.relays,
self.nostr_client.last_error,
)
self.notify(
f"Sync failed: {self.nostr_client.last_error}",
level="WARNING",
)
return
manifest, chunks = result
encrypted = gzip.decompress(b"".join(chunks))
if manifest.delta_since:
version = int(manifest.delta_since)
deltas = asyncio.run(self.nostr_client.fetch_deltas_since(version))
if deltas:
encrypted = deltas[-1]
current = self.vault.get_encrypted_index()
updated = False
if current != encrypted:
if self.vault.decrypt_and_save_index_from_nostr(
encrypted, strict=False
encrypted, strict=False, merge=False
):
logger.info("Local database synchronized from Nostr.")
updated = True
current = encrypted
if manifest.delta_since:
version = int(manifest.delta_since)
deltas = await self.nostr_client.fetch_deltas_since(version)
for delta in deltas:
if current != delta:
if self.vault.decrypt_and_save_index_from_nostr(
delta, strict=False, merge=True
):
updated = True
current = delta
if updated:
logger.info("Local database synchronized from Nostr.")
except Exception as e:
logger.warning(f"Unable to sync index from Nostr: {e}")
logger.warning(
"Unable to sync index from Nostr relays %s: %s",
self.nostr_client.relays,
e,
)
if self.nostr_client.last_error:
logger.warning(
"NostrClient last error: %s", self.nostr_client.last_error
)
self.notify(
f"Sync failed: {self.nostr_client.last_error or e}",
level="WARNING",
)
finally:
if getattr(self, "verbose_timing", False):
duration = time.perf_counter() - start
logger.info("sync_index_from_nostr completed in %.2f seconds", duration)
def sync_index_from_nostr(self) -> None:
asyncio.run(self.sync_index_from_nostr_async())
def start_background_sync(self) -> None:
"""Launch a thread to synchronize the vault without blocking the UI."""
if getattr(self, "offline_mode", False):
return
if (
hasattr(self, "_sync_thread")
and self._sync_thread
and self._sync_thread.is_alive()
if getattr(self, "_sync_task", None) and not getattr(
self._sync_task, "done", True
):
return
def _worker() -> None:
async def _worker() -> None:
try:
if hasattr(self, "nostr_client") and hasattr(self, "vault"):
self.attempt_initial_sync()
@@ -1133,8 +1262,12 @@ class PasswordManager:
except Exception as exc:
logger.warning(f"Background sync failed: {exc}")
self._sync_thread = threading.Thread(target=_worker, daemon=True)
self._sync_thread.start()
try:
loop = asyncio.get_running_loop()
except RuntimeError:
threading.Thread(target=lambda: asyncio.run(_worker()), daemon=True).start()
else:
self._sync_task = asyncio.create_task(_worker())
def start_background_relay_check(self) -> None:
"""Check relay health in a background thread."""
@@ -1170,13 +1303,26 @@ class PasswordManager:
def _worker() -> None:
try:
self.sync_vault(alt_summary=alt_summary)
bus.publish("sync_started")
result = asyncio.run(self.sync_vault_async(alt_summary=alt_summary))
bus.publish("sync_finished", result)
except Exception as exc:
logging.error(f"Background vault sync failed: {exc}", exc_info=True)
threading.Thread(target=_worker, daemon=True).start()
try:
loop = asyncio.get_running_loop()
except RuntimeError:
threading.Thread(target=_worker, daemon=True).start()
else:
def attempt_initial_sync(self) -> bool:
async def _async_worker() -> None:
bus.publish("sync_started")
result = await self.sync_vault_async(alt_summary=alt_summary)
bus.publish("sync_finished", result)
asyncio.create_task(_async_worker())
async def attempt_initial_sync_async(self) -> bool:
"""Attempt to download the initial vault snapshot from Nostr.
Returns ``True`` if the snapshot was successfully downloaded and the
@@ -1190,21 +1336,26 @@ class PasswordManager:
have_data = False
start = time.perf_counter()
try:
result = asyncio.run(self.nostr_client.fetch_latest_snapshot())
result = await self.nostr_client.fetch_latest_snapshot()
if result:
manifest, chunks = result
encrypted = gzip.decompress(b"".join(chunks))
if manifest.delta_since:
version = int(manifest.delta_since)
deltas = asyncio.run(self.nostr_client.fetch_deltas_since(version))
if deltas:
encrypted = deltas[-1]
success = self.vault.decrypt_and_save_index_from_nostr(
encrypted, strict=False
encrypted, strict=False, merge=False
)
if success:
logger.info("Initialized local database from Nostr.")
have_data = True
current = encrypted
if manifest.delta_since:
version = int(manifest.delta_since)
deltas = await self.nostr_client.fetch_deltas_since(version)
for delta in deltas:
if current != delta:
if self.vault.decrypt_and_save_index_from_nostr(
delta, strict=False, merge=True
):
current = delta
logger.info("Initialized local database from Nostr.")
except Exception as e: # pragma: no cover - network errors
logger.warning(f"Unable to sync index from Nostr: {e}")
finally:
@@ -1214,17 +1365,23 @@ class PasswordManager:
return have_data
def attempt_initial_sync(self) -> bool:
return asyncio.run(self.attempt_initial_sync_async())
def sync_index_from_nostr_if_missing(self) -> None:
"""Retrieve the password database from Nostr if it doesn't exist locally.
If no valid data is found or decryption fails, initialize a fresh local
database and publish it to Nostr.
"""
success = self.attempt_initial_sync()
asyncio.run(self.sync_index_from_nostr_if_missing_async())
async def sync_index_from_nostr_if_missing_async(self) -> None:
success = await self.attempt_initial_sync_async()
if not success:
self.vault.save_index({"schema_version": LATEST_VERSION, "entries": {}})
try:
self.sync_vault()
await self.sync_vault_async()
except Exception as exc: # pragma: no cover - best effort
logger.warning(f"Unable to publish fresh database: {exc}")
@@ -1309,7 +1466,16 @@ class PasswordManager:
"green",
)
)
print(colored(f"Password for {website_name}: {password}\n", "yellow"))
if self.secret_mode_enabled:
copy_to_clipboard(password, self.clipboard_clear_delay)
print(
colored(
f"[+] Password copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.",
"green",
)
)
else:
print(colored(f"Password for {website_name}: {password}\n", "yellow"))
# Automatically push the updated encrypted index to Nostr so the
# latest changes are backed up remotely.
@@ -1567,7 +1733,7 @@ class PasswordManager:
print(colored("Seed Phrase:", "cyan"))
print(color_text(phrase, "deterministic"))
if confirm_action("Show Compact Seed QR? (Y/N): "):
from password_manager.seedqr import encode_seedqr
from .seedqr import encode_seedqr
TotpManager.print_qr_code(encode_seedqr(phrase))
try:
@@ -1829,7 +1995,7 @@ class PasswordManager:
else:
print(color_text(seed, "deterministic"))
if confirm_action("Show Compact Seed QR? (Y/N): "):
from password_manager.seedqr import encode_seedqr
from .seedqr import encode_seedqr
TotpManager.print_qr_code(encode_seedqr(seed))
try:
@@ -2063,7 +2229,7 @@ class PasswordManager:
)
print(color_text(seed, "deterministic"))
from password_manager.seedqr import encode_seedqr
from .seedqr import encode_seedqr
TotpManager.print_qr_code(encode_seedqr(seed))
pause()
@@ -3502,7 +3668,7 @@ class PasswordManager:
:param encrypted_data: The encrypted data retrieved from Nostr.
"""
try:
self.vault.decrypt_and_save_index_from_nostr(encrypted_data)
self.vault.decrypt_and_save_index_from_nostr(encrypted_data, merge=True)
logging.info("Index file updated from Nostr successfully.")
print(colored("Index file updated from Nostr successfully.", "green"))
except Exception as e:
@@ -3517,7 +3683,7 @@ class PasswordManager:
# Re-raise the exception to inform the calling function of the failure
raise
def sync_vault(
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."""
@@ -3532,7 +3698,7 @@ class PasswordManager:
event_id = None
if callable(pub_snap):
if asyncio.iscoroutinefunction(pub_snap):
manifest, event_id = asyncio.run(pub_snap(encrypted))
manifest, event_id = await pub_snap(encrypted)
else:
manifest, event_id = pub_snap(encrypted)
else:
@@ -3544,7 +3710,15 @@ class PasswordManager:
chunk_ids: list[str] = []
if manifest is not None:
chunk_ids = [c.event_id for c in manifest.chunks if c.event_id]
delta_ids = getattr(self.nostr_client, "_delta_events", [])
delta_ids = self.nostr_client.get_delta_events()
if manifest is not None and self.state_manager is not None:
ts = manifest.delta_since or int(time.time())
self.state_manager.update_state(
manifest_id=event_id,
delta_since=ts,
last_sync_ts=ts,
)
self.last_sync_ts = ts
return {
"manifest_id": event_id,
"chunk_ids": chunk_ids,
@@ -3554,6 +3728,11 @@ class PasswordManager:
logging.error(f"Failed to sync vault: {e}", exc_info=True)
return None
def sync_vault(
self, alt_summary: str | None = None
) -> dict[str, list[str] | str] | None:
return asyncio.run(self.sync_vault_async(alt_summary=alt_summary))
def backup_database(self) -> None:
"""
Creates a backup of the encrypted JSON index file.
@@ -3696,7 +3875,9 @@ class PasswordManager:
print(colored(f"Error: Failed to export 2FA codes: {e}", "red"))
return None
def handle_backup_reveal_parent_seed(self, file: Path | None = None) -> None:
def handle_backup_reveal_parent_seed(
self, file: Path | None = None, *, password: Optional[str] = None
) -> None:
"""Reveal the parent seed and optionally save an encrypted backup.
Parameters
@@ -3726,9 +3907,10 @@ class PasswordManager:
)
# Verify user's identity with secure password verification
password = prompt_existing_password(
"Enter your master password to continue: "
)
if password is None:
password = prompt_existing_password(
"Enter your master password to continue: "
)
if not self.verify_password(password):
print(colored("Incorrect password. Operation aborted.", "red"))
return
@@ -3872,15 +4054,11 @@ class PasswordManager:
print(colored(f"Error: Failed to store hashed password: {e}", "red"))
raise
def change_password(self) -> None:
def change_password(self, old_password: str, new_password: str) -> None:
"""Change the master password used for encryption."""
try:
current = prompt_existing_password("Enter your current master password: ")
if not self.verify_password(current):
print(colored("Incorrect password.", "red"))
return
new_password = prompt_for_password()
if not self.verify_password(old_password):
raise ValueError("Incorrect password")
# Load data with existing encryption manager
index_data = self.vault.load_index()
@@ -3906,7 +4084,11 @@ class PasswordManager:
self.password_generator.encryption_manager = new_enc_mgr
self.store_hashed_password(new_password)
relay_list = config_data.get("relays", list(DEFAULT_RELAYS))
if getattr(self, "state_manager", None) is not None:
state = self.state_manager.state
relay_list = state.get("relays", list(DEFAULT_RELAYS))
else:
relay_list = list(DEFAULT_RELAYS)
self.nostr_client = NostrClient(
encryption_manager=self.encryption_manager,
fingerprint=self.current_fingerprint,
@@ -3915,7 +4097,17 @@ class PasswordManager:
parent_seed=getattr(self, "parent_seed", None),
)
print(colored("Master password changed successfully.", "green"))
if getattr(self, "manifest_id", None):
from nostr.backup_models import Manifest
with self.nostr_client._state_lock:
self.nostr_client.current_manifest_id = self.manifest_id
self.nostr_client.current_manifest = Manifest(
ver=1,
algo="gzip",
chunks=[],
delta_since=self.delta_since or None,
)
# Push a fresh backup to Nostr so the newly encrypted index is
# stored remotely. Include a tag to mark the password change.
@@ -3928,7 +4120,7 @@ class PasswordManager:
)
except Exception as e:
logging.error(f"Failed to change password: {e}", exc_info=True)
print(colored(f"Error: Failed to change password: {e}", "red"))
raise
def get_profile_stats(self) -> dict:
"""Return various statistics about the current seed profile."""
@@ -3990,13 +4182,11 @@ class PasswordManager:
)
# Nostr sync info
manifest = getattr(self.nostr_client, "current_manifest", None)
manifest = self.nostr_client.get_current_manifest()
if manifest is not None:
stats["chunk_count"] = len(manifest.chunks)
stats["delta_since"] = manifest.delta_since
stats["pending_deltas"] = len(
getattr(self.nostr_client, "_delta_events", [])
)
stats["pending_deltas"] = len(self.nostr_client.get_delta_events())
else:
stats["chunk_count"] = 0
stats["delta_since"] = None

View File

@@ -1,4 +1,4 @@
# password_manager/password_generation.py
# seedpass.core/password_generation.py
"""
Password Generation Module
@@ -43,7 +43,7 @@ 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 .encryption import EncryptionManager
# Instantiate the logger
logger = logging.getLogger(__name__)

View File

@@ -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__)

View 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()

View 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)

View File

@@ -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 -----

View 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"]

View File

@@ -0,0 +1,4 @@
from .app import main
if __name__ == "__main__":
main()

476
src/seedpass_gui/app.py Normal file
View File

@@ -0,0 +1,476 @@
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)
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.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("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.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
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, 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({"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 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()

View File

@@ -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,

View File

@@ -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:

View File

@@ -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):

View File

@@ -179,12 +179,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"

View File

@@ -291,8 +291,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

View File

@@ -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:

View File

@@ -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:

View File

@@ -10,10 +10,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.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 setup_entry_mgr(tmp_path: Path) -> EntryManager:

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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):

View File

@@ -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,
)

View File

@@ -0,0 +1,72 @@
from types import SimpleNamespace
import typer
from typer.testing import CliRunner
from seedpass import cli
from seedpass.cli import app
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)]
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

View File

@@ -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,7 +17,7 @@ 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)],
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,
@@ -40,10 +40,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
@@ -58,6 +58,7 @@ class DummyPM:
"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,
@@ -76,7 +77,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]:
@@ -84,7 +85,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)

View File

@@ -115,14 +115,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)

View File

@@ -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

View 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)

View 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"]

View File

@@ -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"):

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,71 @@
import types
from types import SimpleNamespace
from seedpass.core.api import VaultService, EntryService, SyncService, UnlockRequest
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)]
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)]
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"]

View File

@@ -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):

View File

@@ -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

View 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"

View File

@@ -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:

View File

@@ -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

View File

@@ -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():

View File

@@ -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():

View File

@@ -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(

View File

@@ -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():

View File

@@ -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()

View File

@@ -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():

View File

@@ -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,7 +44,7 @@ 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
result = pm_b.attempt_initial_sync()

View File

@@ -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,7 +44,7 @@ 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
result = pm_b.attempt_initial_sync()

View File

@@ -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(

View 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"

View File

@@ -0,0 +1,164 @@
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):
self.added.append(("nostr", label))
return 1
def add_key_value(self, label, value):
self.added.append(("key_value", label, value))
return 1
def add_managed_account(self, label):
self.added.append(("managed_account", label))
return 1
def modify_entry(self, entry_id, username=None, url=None, label=None, value=None):
self.modified.append((entry_id, username, url, label, value))
def 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", "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.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)),
(EntryType.KEY_VALUE.value, (1, None, None, "New", "val2")),
(EntryType.TOTP.value, (1, None, None, "New", 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.value_input.value = "val2"
dlg.save(None)
assert entries.modified[-1] == expected
assert source[0].label == "New"

View 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()

View File

@@ -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

View File

@@ -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():

View File

@@ -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"

View File

@@ -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)

View File

@@ -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:
@@ -41,4 +41,4 @@ def test_add_and_modify_key_value():
assert updated["value"] == "def456"
results = em.search_entries("def456")
assert results == [(idx, "API", None, None, False)]
assert results == []

View File

@@ -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

View File

@@ -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),
]

View File

@@ -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:

View File

@@ -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

View File

@@ -0,0 +1,133 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
import pytest
from helpers import create_vault, TEST_SEED, TEST_PASSWORD, dummy_nostr_client
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 constants import DEFAULT_PASSWORD_LENGTH
class FakePasswordGenerator:
def generate_password(self, length: int, index: int) -> str: # noqa: D401
return f"pw-{index}-{length}"
def test_handle_add_password(monkeypatch, dummy_nostr_client, capsys):
client, _relay = dummy_nostr_client
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = vault
pm.entry_manager = entry_mgr
pm.backup_manager = backup_mgr
pm.password_generator = FakePasswordGenerator()
pm.parent_seed = TEST_SEED
pm.nostr_client = client
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
pm.is_dirty = False
inputs = iter(
[
"Example", # label
"", # username
"", # url
"", # notes
"", # tags
"n", # add custom field
"", # length (default)
]
)
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None)
monkeypatch.setattr(pm, "start_background_vault_sync", lambda *a, **k: None)
pm.handle_add_password()
out = capsys.readouterr().out
entries = entry_mgr.list_entries(verbose=False)
assert entries == [(0, "Example", "", "", False)]
entry = entry_mgr.retrieve_entry(0)
assert entry == {
"label": "Example",
"length": DEFAULT_PASSWORD_LENGTH,
"username": "",
"url": "",
"archived": False,
"type": "password",
"kind": "password",
"notes": "",
"custom_fields": [],
"tags": [],
}
assert f"pw-0-{DEFAULT_PASSWORD_LENGTH}" in out
def test_handle_add_password_secret_mode(monkeypatch, dummy_nostr_client, capsys):
client, _relay = dummy_nostr_client
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = vault
pm.entry_manager = entry_mgr
pm.backup_manager = backup_mgr
pm.password_generator = FakePasswordGenerator()
pm.parent_seed = TEST_SEED
pm.nostr_client = client
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = True
pm.clipboard_clear_delay = 5
pm.is_dirty = False
inputs = iter(
[
"Example", # label
"", # username
"", # url
"", # notes
"", # tags
"n", # add custom field
"", # length (default)
]
)
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None)
monkeypatch.setattr(pm, "start_background_vault_sync", lambda *a, **k: None)
called = []
monkeypatch.setattr(
"seedpass.core.manager.copy_to_clipboard",
lambda text, delay: called.append((text, delay)),
)
pm.handle_add_password()
out = capsys.readouterr().out
assert f"pw-0-{DEFAULT_PASSWORD_LENGTH}" not in out
assert "copied to clipboard" in out
assert called == [(f"pw-0-{DEFAULT_PASSWORD_LENGTH}", 5)]

View File

@@ -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 FakeNostrClient:

View File

@@ -3,7 +3,7 @@ from pathlib import Path
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.manager import PasswordManager, EncryptionMode
from seedpass.core.manager import PasswordManager, EncryptionMode
import queue
@@ -29,8 +29,8 @@ def test_handle_verify_checksum_success(monkeypatch, tmp_path, capsys):
pm = _make_pm()
chk_file = tmp_path / "chk.txt"
chk_file.write_text("abc")
monkeypatch.setattr("password_manager.manager.SCRIPT_CHECKSUM_FILE", chk_file)
monkeypatch.setattr("password_manager.manager.calculate_checksum", lambda _: "abc")
monkeypatch.setattr("seedpass.core.manager.SCRIPT_CHECKSUM_FILE", chk_file)
monkeypatch.setattr("seedpass.core.manager.calculate_checksum", lambda _: "abc")
pm.handle_verify_checksum()
out = capsys.readouterr().out
assert "Checksum verification passed." in out
@@ -40,8 +40,8 @@ def test_handle_verify_checksum_failure(monkeypatch, tmp_path, capsys):
pm = _make_pm()
chk_file = tmp_path / "chk.txt"
chk_file.write_text("xyz")
monkeypatch.setattr("password_manager.manager.SCRIPT_CHECKSUM_FILE", chk_file)
monkeypatch.setattr("password_manager.manager.calculate_checksum", lambda _: "abc")
monkeypatch.setattr("seedpass.core.manager.SCRIPT_CHECKSUM_FILE", chk_file)
monkeypatch.setattr("seedpass.core.manager.calculate_checksum", lambda _: "abc")
pm.handle_verify_checksum()
out = capsys.readouterr().out
assert "Checksum verification failed" in out
@@ -50,13 +50,13 @@ def test_handle_verify_checksum_failure(monkeypatch, tmp_path, capsys):
def test_handle_verify_checksum_missing(monkeypatch, tmp_path, capsys):
pm = _make_pm()
chk_file = tmp_path / "chk.txt"
monkeypatch.setattr("password_manager.manager.SCRIPT_CHECKSUM_FILE", chk_file)
monkeypatch.setattr("password_manager.manager.calculate_checksum", lambda _: "abc")
monkeypatch.setattr("seedpass.core.manager.SCRIPT_CHECKSUM_FILE", chk_file)
monkeypatch.setattr("seedpass.core.manager.calculate_checksum", lambda _: "abc")
def raise_missing(*_args, **_kwargs):
raise FileNotFoundError
monkeypatch.setattr("password_manager.manager.verify_checksum", raise_missing)
monkeypatch.setattr("seedpass.core.manager.verify_checksum", raise_missing)
pm.handle_verify_checksum()
note = pm.notifications.get_nowait()
assert note.level == "WARNING"

View File

@@ -5,7 +5,7 @@ import sys
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.manager import PasswordManager, Notification
from seedpass.core.manager import PasswordManager, Notification
from constants import NOTIFICATION_DURATION
@@ -20,7 +20,7 @@ def _make_pm():
def test_notify_sets_current(monkeypatch):
pm = _make_pm()
current = {"val": 100.0}
monkeypatch.setattr("password_manager.manager.time.time", lambda: current["val"])
monkeypatch.setattr("seedpass.core.manager.time.time", lambda: current["val"])
pm.notify("hello")
note = pm._current_notification
assert hasattr(note, "message")
@@ -32,7 +32,7 @@ def test_notify_sets_current(monkeypatch):
def test_get_current_notification_ttl(monkeypatch):
pm = _make_pm()
now = {"val": 0.0}
monkeypatch.setattr("password_manager.manager.time.time", lambda: now["val"])
monkeypatch.setattr("seedpass.core.manager.time.time", lambda: now["val"])
pm.notify("note1")
assert pm.get_current_notification().message == "note1"

View File

@@ -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.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 FakeNostrClient:
@@ -50,7 +50,7 @@ def test_handle_display_totp_codes(monkeypatch, capsys):
# interrupt the loop after first iteration
monkeypatch.setattr(
"password_manager.manager.timed_input",
"seedpass.core.manager.timed_input",
lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()),
)
@@ -91,7 +91,7 @@ def test_display_totp_codes_excludes_archived(monkeypatch, capsys):
)
monkeypatch.setattr(
"password_manager.manager.timed_input",
"seedpass.core.manager.timed_input",
lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()),
)

Some files were not shown because too many files have changed in this diff Show More