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 timeout-minutes: 16
shell: bash shell: bash
run: scripts/run_ci_tests.sh 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 - name: Upload pytest log
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: pytest-log-${{ matrix.os }} name: pytest-log-${{ matrix.os }}
path: pytest.log 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 - name: Upload coverage report
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: 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 ## Table of Contents
- [Features](#features) - [Features](#features)
- [Architecture Overview](#architecture-overview)
- [Prerequisites](#prerequisites) - [Prerequisites](#prerequisites)
- [Installation](#installation) - [Installation](#installation)
- [1. Clone the Repository](#1-clone-the-repository) - [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) - [Managing Multiple Seeds](#managing-multiple-seeds)
- [Additional Entry Types](#additional-entry-types) - [Additional Entry Types](#additional-entry-types)
- [Building a standalone executable](#building-a-standalone-executable) - [Building a standalone executable](#building-a-standalone-executable)
- [Packaging with Briefcase](#packaging-with-briefcase)
- [Security Considerations](#security-considerations) - [Security Considerations](#security-considerations)
- [Contributing](#contributing) - [Contributing](#contributing)
- [License](#license) - [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. - **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. - **Auto-Lock on Inactivity:** Vault locks after a configurable timeout for additional security.
- **Quick Unlock:** Optionally skip the password prompt after verifying once. - **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. - **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. - **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. - **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. - **Relay Management:** List, add, remove or reset configured Nostr relays.
- **Offline Mode:** Disable all Nostr communication for local-only operation. - **Offline Mode:** Disable all Nostr communication for local-only operation.
A small on-screen notification area now shows queued messages for 10 seconds A small on-screen notification area now shows queued messages for 10 seconds
before fading. 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 ## 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. - **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 ### Quick Installer
Use the automated installer to download SeedPass and its dependencies in one step. 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:** **Linux and macOS:**
```bash ```bash
@@ -87,6 +122,7 @@ bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/
```bash ```bash
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)" _ -b beta 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):** **Windows (PowerShell):**
```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. 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. **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 ### Uninstall
Run the matching uninstaller if you need to remove a previous installation or clean up an old `seedpass` command: 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). 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 ### 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. 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 ### 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**. 1. From the main menu open **Settings** and select **Toggle Secret Mode**.
2. Choose how many seconds to keep passwords on the clipboard. 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 ### Viewing Entry Details
@@ -494,6 +591,10 @@ If the checksum file is missing, generate it manually:
python scripts/update_checksum.py 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`: To run mutation tests locally, generate coverage data first and then execute `mutmut`:
```bash ```bash
@@ -537,8 +638,39 @@ scripts/vendor_dependencies.sh
pyinstaller SeedPass.spec 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. 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 ## 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. **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` | | Verify script checksum | `util verify-checksum` | `seedpass util verify-checksum` |
| Update script checksum | `util update-checksum` | `seedpass util update-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 ### API Commands
Run or stop the local HTTP API. 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. 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 ## Starting the API
Run `seedpass api start` from your terminal. The command prints a onetime token used for authentication: 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 ✔ Windows 10/11 • macOS 12+ • Any modern Linux
SeedPass now uses the `portalocker` library for cross-platform file locking. No WSL or Cygwin required. 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 ## Table of Contents
@@ -191,10 +207,10 @@ create a backup:
seedpass seedpass
# Export your index # Export your index
seedpass export --file "~/seedpass_backup.json" seedpass vault export --file "~/seedpass_backup.json"
# Later you can restore it # 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 # Import also performs a Nostr sync to pull any changes
# Quickly find or retrieve entries # Quickly find or retrieve entries
@@ -481,6 +497,10 @@ If the checksum file is missing, generate it manually:
python scripts/update_checksum.py 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`: To run mutation tests locally, generate coverage data first and then execute `mutmut`:
```bash ```bash

View File

@@ -84,6 +84,26 @@ flowchart TB
<h2 class="section-title" id="architecture-heading">Architecture Overview</h2> <h2 class="section-title" id="architecture-heading">Architecture Overview</h2>
<pre class="mermaid"> <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: config:
layout: fixed layout: fixed
theme: base 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" name = "seedpass"
version = "0.1.0" version = "0.1.0"
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"
[project.scripts] [project.scripts]
seedpass = "seedpass.cli:app" seedpass = "seedpass.cli:app"
seedpass-gui = "seedpass_gui.app:main"
[tool.mypy] [tool.mypy]
python_version = "3.11" python_version = "3.11"
strict = true strict = true
mypy_path = "src" 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 ecdsa==0.19.1
ed25519-blake2b==1.4.1 ed25519-blake2b==1.4.1
execnet==2.1.1 execnet==2.1.1
fastapi==0.116.0 fastapi==0.116.1
frozenlist==1.7.0 frozenlist==1.7.0
glob2==0.7 glob2==0.7
hypothesis==6.135.20 hypothesis==6.135.20
@@ -61,6 +61,7 @@ toml==0.10.2
tomli==2.2.1 tomli==2.2.1
urllib3==2.5.0 urllib3==2.5.0
uvicorn==0.35.0 uvicorn==0.35.0
starlette==0.47.2
httpx==0.28.1 httpx==0.28.1
varint==1.0.2 varint==1.0.2
websocket-client==1.7.0 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 constants import APP_DIR, initialize_app
from utils.key_derivation import derive_key_from_password, derive_index_key from utils.key_derivation import derive_key_from_password, derive_index_key
from password_manager.encryption import EncryptionManager from seedpass.core.encryption import EncryptionManager
from password_manager.vault import Vault from seedpass.core.vault import Vault
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.entry_management import EntryManager from seedpass.core.entry_management import EntryManager
from nostr.client import NostrClient from nostr.client import NostrClient
from utils.fingerprint import generate_fingerprint from utils.fingerprint import generate_fingerprint
from utils.fingerprint_manager import FingerprintManager from utils.fingerprint_manager import FingerprintManager

View File

@@ -260,6 +260,10 @@ if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to install SeedPass package" 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 # 5. Create launcher script
Write-Info "Creating launcher script..." Write-Info "Creating launcher script..."
if (-not (Test-Path $LauncherDir)) { New-Item -ItemType Directory -Path $LauncherDir | Out-Null } 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." 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 # 6. Add launcher directory to User's PATH if needed
Write-Info "Checking if '$LauncherDir' is in your PATH..." Write-Info "Checking if '$LauncherDir' is in your PATH..."
$UserPath = [System.Environment]::GetEnvironmentVariable("Path", "User") $UserPath = [System.Environment]::GetEnvironmentVariable("Path", "User")

View File

@@ -86,13 +86,26 @@ main() {
# 3. Install OS-specific dependencies # 3. Install OS-specific dependencies
print_info "Checking for build dependencies..." print_info "Checking for build dependencies..."
if [ "$OS_NAME" = "Linux" ]; then 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; if command -v apt-get &> /dev/null; then
elif command -v dnf &> /dev/null; then sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y pkg-config xclip; sudo apt-get update && sudo apt-get install -y \
elif command -v pacman &> /dev/null; then sudo pacman -Syu --noconfirm base-devel pkg-config xclip; build-essential pkg-config xclip \
else print_warning "Could not detect package manager. Ensure build tools and pkg-config are installed."; fi 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 elif [ "$OS_NAME" = "Darwin" ]; then
if ! command -v brew &> /dev/null; then print_error "Homebrew not installed. See https://brew.sh/"; fi 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 fi
# 4. Clone or update the repository # 4. Clone or update the repository
@@ -120,6 +133,14 @@ main() {
pip install --upgrade pip pip install --upgrade pip
pip install -r src/requirements.txt pip install -r src/requirements.txt
pip install -e . 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 deactivate
# 7. Create launcher script # 7. Create launcher script
@@ -138,6 +159,23 @@ EOF2
print_warning "Ensure '$LAUNCHER_DIR' comes first in your PATH or remove the old installation." print_warning "Ensure '$LAUNCHER_DIR' comes first in your PATH or remove the old installation."
fi 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 # 8. Final instructions
print_success "Installation/update complete!" print_success "Installation/update complete!"
print_info "You can now launch the interactive TUI by typing: seedpass" 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: def main() -> None:
"""Calculate checksum for the main script and write it to SCRIPT_CHECKSUM_FILE.""" """Calculate checksum for the main script and write it to SCRIPT_CHECKSUM_FILE."""
initialize_app() 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)): if not update_checksum_file(str(script_path), str(SCRIPT_CHECKSUM_FILE)):
raise SystemExit(f"Failed to update checksum for {script_path}") raise SystemExit(f"Failed to update checksum for {script_path}")
print(f"Updated checksum written to {SCRIPT_CHECKSUM_FILE}") print(f"Updated checksum written to {SCRIPT_CHECKSUM_FILE}")

View File

@@ -9,9 +9,11 @@ logger = logging.getLogger(__name__)
# ----------------------------------- # -----------------------------------
# Nostr Relay Connection Settings # Nostr Relay Connection Settings
# ----------------------------------- # -----------------------------------
# Retry fewer times with a shorter wait by default # Retry fewer times with a shorter wait by default. These values
MAX_RETRIES = 2 # Maximum number of retries for relay connections # act as defaults that can be overridden via ``ConfigManager``
RETRY_DELAY = 1 # Seconds to wait before retrying a failed connection # 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 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 from utils.color_scheme import color_text
import traceback import traceback
from password_manager.manager import PasswordManager from seedpass.core.manager import PasswordManager
from nostr.client import NostrClient 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 constants import INACTIVITY_TIMEOUT, initialize_app
from utils.password_prompt import PasswordPromptError from utils.password_prompt import PasswordPromptError
from utils import ( from utils import (

View File

@@ -8,6 +8,7 @@ from typing import List, Optional, Tuple, TYPE_CHECKING
import hashlib import hashlib
import asyncio import asyncio
import gzip import gzip
import threading
import websockets import websockets
# Imports from the nostr-sdk library # Imports from the nostr-sdk library
@@ -26,12 +27,12 @@ from nostr_sdk import EventId, Timestamp
from .key_manager import KeyManager as SeedPassKeyManager from .key_manager import KeyManager as SeedPassKeyManager
from .backup_models import Manifest, ChunkMeta, KIND_MANIFEST, KIND_SNAPSHOT_CHUNK 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 constants import MAX_RETRIES, RETRY_DELAY
from utils.file_lock import exclusive_lock from utils.file_lock import exclusive_lock
if TYPE_CHECKING: # pragma: no cover - imported for type hints 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 # Backwards compatibility for tests that patch these symbols
KeyManager = SeedPassKeyManager KeyManager = SeedPassKeyManager
@@ -46,6 +47,9 @@ DEFAULT_RELAYS = [
"wss://relay.primal.net", "wss://relay.primal.net",
] ]
# Identifier prefix for replaceable manifest events
MANIFEST_ID_PREFIX = "seedpass-manifest-"
def prepare_snapshot( def prepare_snapshot(
encrypted_bytes: bytes, limit: int encrypted_bytes: bytes, limit: int
@@ -135,6 +139,7 @@ class NostrClient:
self.last_error: Optional[str] = None self.last_error: Optional[str] = None
self.delta_threshold = 100 self.delta_threshold = 100
self._state_lock = threading.Lock()
self.current_manifest: Manifest | None = None self.current_manifest: Manifest | None = None
self.current_manifest_id: str | None = None self.current_manifest_id: str | None = None
self._delta_events: list[str] = [] self._delta_events: list[str] = []
@@ -295,8 +300,8 @@ class NostrClient:
if retries is None or delay is None: if retries is None or delay is None:
if self.config_manager is None: if self.config_manager is None:
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
from password_manager.vault import Vault from seedpass.core.vault import Vault
cfg_mgr = ConfigManager( cfg_mgr = ConfigManager(
Vault(self.encryption_manager, self.fingerprint_dir), Vault(self.encryption_manager, self.fingerprint_dir),
@@ -310,8 +315,7 @@ class NostrClient:
self.connect() self.connect()
self.last_error = None self.last_error = None
attempt = 0 for attempt in range(retries):
while True:
try: try:
result = asyncio.run(self._retrieve_json_from_nostr()) result = asyncio.run(self._retrieve_json_from_nostr())
if result is not None: if result is not None:
@@ -319,10 +323,9 @@ class NostrClient:
except Exception as e: except Exception as e:
self.last_error = str(e) self.last_error = str(e)
logger.error("Failed to retrieve events from Nostr: %s", e) logger.error("Failed to retrieve events from Nostr: %s", e)
if attempt >= retries: if attempt < retries - 1:
break sleep_time = delay * (2**attempt)
attempt += 1 time.sleep(sleep_time)
time.sleep(delay)
return None return None
async def _retrieve_json_from_nostr(self) -> Optional[bytes]: async def _retrieve_json_from_nostr(self) -> Optional[bytes]:
@@ -365,6 +368,7 @@ class NostrClient:
start = time.perf_counter() start = time.perf_counter()
if self.offline_mode or not self.relays: if self.offline_mode or not self.relays:
return Manifest(ver=1, algo="gzip", chunks=[]), "" return Manifest(ver=1, algo="gzip", chunks=[]), ""
await self.ensure_manifest_is_current()
await self._connect_async() await self._connect_async()
manifest, chunks = prepare_snapshot(encrypted_bytes, limit) manifest, chunks = prepare_snapshot(encrypted_bytes, limit)
for meta, chunk in zip(manifest.chunks, chunks): for meta, chunk in zip(manifest.chunks, chunks):
@@ -390,22 +394,24 @@ class NostrClient:
} }
) )
manifest_identifier = f"{MANIFEST_ID_PREFIX}{self.fingerprint}"
manifest_event = ( manifest_event = (
EventBuilder(Kind(KIND_MANIFEST), manifest_json) EventBuilder(Kind(KIND_MANIFEST), manifest_json)
.tags([Tag.identifier(manifest_identifier)])
.build(self.keys.public_key()) .build(self.keys.public_key())
.sign_with_keys(self.keys) .sign_with_keys(self.keys)
) )
result = await self.client.send_event(manifest_event) await self.client.send_event(manifest_event)
manifest_id = result.id.to_hex() if hasattr(result, "id") else str(result) with self._state_lock:
self.current_manifest = manifest self.current_manifest = manifest
self.current_manifest_id = manifest_id self.current_manifest_id = manifest_identifier
# Record when this snapshot was published for future delta events # Record when this snapshot was published for future delta events
self.current_manifest.delta_since = int(time.time()) self.current_manifest.delta_since = int(time.time())
self._delta_events = [] self._delta_events = []
if getattr(self, "verbose_timing", False): if getattr(self, "verbose_timing", False):
duration = time.perf_counter() - start duration = time.perf_counter() - start
logger.info("publish_snapshot completed in %.2f seconds", duration) logger.info("publish_snapshot completed in %.2f seconds", duration)
return manifest, manifest_id return manifest, manifest_identifier
async def _fetch_chunks_with_retry( async def _fetch_chunks_with_retry(
self, manifest_event self, manifest_event
@@ -430,11 +436,24 @@ class NostrClient:
except Exception: except Exception:
return None 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] = [] chunks: list[bytes] = []
for meta in manifest.chunks: for meta in manifest.chunks:
attempt = 0
chunk_bytes: bytes | None = None chunk_bytes: bytes | None = None
while attempt < MAX_RETRIES: for attempt in range(max_retries):
cf = Filter().author(pubkey).kind(Kind(KIND_SNAPSHOT_CHUNK)) cf = Filter().author(pubkey).kind(Kind(KIND_SNAPSHOT_CHUNK))
if meta.event_id: if meta.event_id:
cf = cf.id(EventId.parse(meta.event_id)) cf = cf.id(EventId.parse(meta.event_id))
@@ -447,18 +466,33 @@ class NostrClient:
if hashlib.sha256(candidate).hexdigest() == meta.hash: if hashlib.sha256(candidate).hexdigest() == meta.hash:
chunk_bytes = candidate chunk_bytes = candidate
break break
attempt += 1 if attempt < max_retries - 1:
if attempt < MAX_RETRIES: await asyncio.sleep(delay * (2**attempt))
await asyncio.sleep(RETRY_DELAY)
if chunk_bytes is None: if chunk_bytes is None:
return None return None
chunks.append(chunk_bytes) chunks.append(chunk_bytes)
man_id = getattr(manifest_event, "id", None) ident = None
if hasattr(man_id, "to_hex"): try:
man_id = man_id.to_hex() 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 = manifest
self.current_manifest_id = man_id self.current_manifest_id = ident
return manifest, chunks return manifest, chunks
async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None: async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None:
@@ -467,24 +501,76 @@ class NostrClient:
return None return None
await self._connect_async() await self._connect_async()
self.last_error = None
pubkey = self.keys.public_key() 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) timeout = timedelta(seconds=10)
try:
events = (await self.client.fetch_events(f, timeout)).to_vec() 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: if not events:
return None return None
for manifest_event in events: for manifest_event in events:
try:
result = await self._fetch_chunks_with_retry(manifest_event) result = await self._fetch_chunks_with_retry(manifest_event)
if result is not None: if result is not None:
return result 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 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: async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str:
"""Publish a delta event referencing a manifest.""" """Publish a delta event referencing a manifest."""
if self.offline_mode or not self.relays: if self.offline_mode or not self.relays:
return "" return ""
await self.ensure_manifest_is_current()
await self._connect_async() await self._connect_async()
content = base64.b64encode(delta_bytes).decode("utf-8") content = base64.b64encode(delta_bytes).decode("utf-8")
@@ -498,13 +584,17 @@ class NostrClient:
) )
if hasattr(created_at, "secs"): if hasattr(created_at, "secs"):
created_at = created_at.secs created_at = created_at.secs
manifest_event = None
with self._state_lock:
if self.current_manifest is not None: if self.current_manifest is not None:
self.current_manifest.delta_since = int(created_at) self.current_manifest.delta_since = int(created_at)
manifest_json = json.dumps( manifest_json = json.dumps(
{ {
"ver": self.current_manifest.ver, "ver": self.current_manifest.ver,
"algo": self.current_manifest.algo, "algo": self.current_manifest.algo,
"chunks": [meta.__dict__ for meta in self.current_manifest.chunks], "chunks": [
meta.__dict__ for meta in self.current_manifest.chunks
],
"delta_since": self.current_manifest.delta_since, "delta_since": self.current_manifest.delta_since,
} }
) )
@@ -514,8 +604,9 @@ class NostrClient:
.build(self.keys.public_key()) .build(self.keys.public_key())
.sign_with_keys(self.keys) .sign_with_keys(self.keys)
) )
await self.client.send_event(manifest_event)
self._delta_events.append(delta_id) self._delta_events.append(delta_id)
if manifest_event is not None:
await self.client.send_event(manifest_event)
return delta_id return delta_id
async def fetch_deltas_since(self, version: int) -> list[bytes]: async def fetch_deltas_since(self, version: int) -> list[bytes]:
@@ -533,12 +624,16 @@ class NostrClient:
) )
timeout = timedelta(seconds=10) timeout = timedelta(seconds=10)
events = (await self.client.fetch_events(f, timeout)).to_vec() 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] = [] deltas: list[bytes] = []
for ev in events: for ev in events:
deltas.append(base64.b64decode(ev.content().encode("utf-8"))) deltas.append(base64.b64decode(ev.content().encode("utf-8")))
if self.current_manifest is not None: manifest = self.get_current_manifest()
snap_size = sum(c.size for c in self.current_manifest.chunks) if manifest is not None:
snap_size = sum(c.size for c in manifest.chunks)
if ( if (
len(deltas) >= self.delta_threshold len(deltas) >= self.delta_threshold
or sum(len(d) for d in deltas) > snap_size or sum(len(d) for d in deltas) > snap_size
@@ -557,6 +652,21 @@ class NostrClient:
await self.client.send_event(exp_event) await self.client.send_event(exp_event)
return deltas 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: def close_client_pool(self) -> None:
"""Disconnects the client from all relays.""" """Disconnects the client from all relays."""
try: try:

View File

@@ -25,10 +25,14 @@ freezegun
pyperclip pyperclip
qrcode>=8.2 qrcode>=8.2
typer>=0.12.3 typer>=0.12.3
fastapi>=0.116.0 fastapi>=0.116.1
uvicorn>=0.35.0 uvicorn>=0.35.0
starlette>=0.47.2
httpx>=0.28.1 httpx>=0.28.1
requests>=2.32 requests>=2.32
python-multipart python-multipart
orjson orjson
argon2-cffi 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 python-multipart
orjson orjson
argon2-cffi argon2-cffi
toga-core>=0.5.2

View File

@@ -14,8 +14,8 @@ import asyncio
import sys import sys
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from password_manager.manager import PasswordManager from seedpass.core.manager import PasswordManager
from password_manager.entry_types import EntryType from seedpass.core.entry_types import EntryType
app = FastAPI() app = FastAPI()
@@ -554,11 +554,13 @@ def backup_parent_seed(
@app.post("/api/v1/change-password") @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.""" """Change the master password for the active profile."""
_check_token(authorization) _check_token(authorization)
assert _pm is not None assert _pm is not None
_pm.change_password() _pm.change_password(data.get("old", ""), data.get("new", ""))
return {"status": "ok"} return {"status": "ok"}

View File

@@ -1,15 +1,32 @@
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional, List
import json import json
import typer import typer
import sys
from password_manager.manager import PasswordManager from seedpass.core.manager import PasswordManager
from password_manager.entry_types import EntryType 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 import uvicorn
from . import api as api_module from . import api as api_module
import importlib import importlib
import importlib.util
import subprocess
app = typer.Typer( app = typer.Typer(
help="SeedPass command line interface", help="SeedPass command line interface",
@@ -52,6 +69,43 @@ def _get_pm(ctx: typer.Context) -> PasswordManager:
return pm 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) @app.callback(invoke_without_command=True)
def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None: def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None:
"""SeedPass CLI entry point. """SeedPass CLI entry point.
@@ -68,14 +122,14 @@ def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) ->
def entry_list( def entry_list(
ctx: typer.Context, ctx: typer.Context,
sort: str = typer.Option( 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"), kind: Optional[str] = typer.Option(None, "--kind", help="Filter by entry type"),
archived: bool = typer.Option(False, "--archived", help="Include archived"), archived: bool = typer.Option(False, "--archived", help="Include archived"),
) -> None: ) -> None:
"""List entries in the vault.""" """List entries in the vault."""
pm = _get_pm(ctx) service = _get_entry_service(ctx)
entries = pm.entry_manager.list_entries( entries = service.list_entries(
sort_by=sort, filter_kind=kind, include_archived=archived sort_by=sort, filter_kind=kind, include_archived=archived
) )
for idx, label, username, url, is_archived in entries: for idx, label, username, url, is_archived in entries:
@@ -90,10 +144,20 @@ def entry_list(
@entry_app.command("search") @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.""" """Search entries."""
pm = _get_pm(ctx) service = _get_entry_service(ctx)
results = pm.entry_manager.search_entries(query) kinds = list(kind) if kind else None
results = service.search_entries(query, kinds=kinds)
if not results: if not results:
typer.echo("No matching entries found") typer.echo("No matching entries found")
return return
@@ -109,8 +173,8 @@ def entry_search(ctx: typer.Context, query: str) -> None:
@entry_app.command("get") @entry_app.command("get")
def entry_get(ctx: typer.Context, query: str) -> None: def entry_get(ctx: typer.Context, query: str) -> None:
"""Retrieve a single entry's secret.""" """Retrieve a single entry's secret."""
pm = _get_pm(ctx) service = _get_entry_service(ctx)
matches = pm.entry_manager.search_entries(query) matches = service.search_entries(query)
if len(matches) == 0: if len(matches) == 0:
typer.echo("No matching entries found") typer.echo("No matching entries found")
raise typer.Exit(code=1) raise typer.Exit(code=1)
@@ -124,14 +188,14 @@ def entry_get(ctx: typer.Context, query: str) -> None:
raise typer.Exit(code=1) raise typer.Exit(code=1)
index = matches[0][0] index = matches[0][0]
entry = pm.entry_manager.retrieve_entry(index) entry = service.retrieve_entry(index)
etype = entry.get("type", entry.get("kind")) etype = entry.get("type", entry.get("kind"))
if etype == EntryType.PASSWORD.value: if etype == EntryType.PASSWORD.value:
length = int(entry.get("length", 12)) length = int(entry.get("length", 12))
password = pm.password_generator.generate_password(length, index) password = service.generate_password(length, index)
typer.echo(password) typer.echo(password)
elif etype == EntryType.TOTP.value: 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) typer.echo(code)
else: else:
typer.echo("Unsupported entry type") typer.echo("Unsupported entry type")
@@ -147,10 +211,9 @@ def entry_add(
url: Optional[str] = typer.Option(None, "--url"), url: Optional[str] = typer.Option(None, "--url"),
) -> None: ) -> None:
"""Add a new password entry and output its index.""" """Add a new password entry and output its index."""
pm = _get_pm(ctx) service = _get_entry_service(ctx)
index = pm.entry_manager.add_entry(label, length, username, url) index = service.add_entry(label, length, username, url)
typer.echo(str(index)) typer.echo(str(index))
pm.sync_vault()
@entry_app.command("add-totp") @entry_app.command("add-totp")
@@ -163,17 +226,15 @@ def entry_add_totp(
digits: int = typer.Option(6, "--digits", help="Number of TOTP digits"), digits: int = typer.Option(6, "--digits", help="Number of TOTP digits"),
) -> None: ) -> None:
"""Add a TOTP entry and output the otpauth URI.""" """Add a TOTP entry and output the otpauth URI."""
pm = _get_pm(ctx) service = _get_entry_service(ctx)
uri = pm.entry_manager.add_totp( uri = service.add_totp(
label, label,
pm.parent_seed,
index=index, index=index,
secret=secret, secret=secret,
period=period, period=period,
digits=digits, digits=digits,
) )
typer.echo(uri) typer.echo(uri)
pm.sync_vault()
@entry_app.command("add-ssh") @entry_app.command("add-ssh")
@@ -184,15 +245,13 @@ def entry_add_ssh(
notes: str = typer.Option("", "--notes", help="Entry notes"), notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None: ) -> None:
"""Add an SSH key entry and output its index.""" """Add an SSH key entry and output its index."""
pm = _get_pm(ctx) service = _get_entry_service(ctx)
idx = pm.entry_manager.add_ssh_key( idx = service.add_ssh_key(
label, label,
pm.parent_seed,
index=index, index=index,
notes=notes, notes=notes,
) )
typer.echo(str(idx)) typer.echo(str(idx))
pm.sync_vault()
@entry_app.command("add-pgp") @entry_app.command("add-pgp")
@@ -205,17 +264,15 @@ def entry_add_pgp(
notes: str = typer.Option("", "--notes", help="Entry notes"), notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None: ) -> None:
"""Add a PGP key entry and output its index.""" """Add a PGP key entry and output its index."""
pm = _get_pm(ctx) service = _get_entry_service(ctx)
idx = pm.entry_manager.add_pgp_key( idx = service.add_pgp_key(
label, label,
pm.parent_seed,
index=index, index=index,
key_type=key_type, key_type=key_type,
user_id=user_id, user_id=user_id,
notes=notes, notes=notes,
) )
typer.echo(str(idx)) typer.echo(str(idx))
pm.sync_vault()
@entry_app.command("add-nostr") @entry_app.command("add-nostr")
@@ -226,14 +283,13 @@ def entry_add_nostr(
notes: str = typer.Option("", "--notes", help="Entry notes"), notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None: ) -> None:
"""Add a Nostr key entry and output its index.""" """Add a Nostr key entry and output its index."""
pm = _get_pm(ctx) service = _get_entry_service(ctx)
idx = pm.entry_manager.add_nostr_key( idx = service.add_nostr_key(
label, label,
index=index, index=index,
notes=notes, notes=notes,
) )
typer.echo(str(idx)) typer.echo(str(idx))
pm.sync_vault()
@entry_app.command("add-seed") @entry_app.command("add-seed")
@@ -245,16 +301,14 @@ def entry_add_seed(
notes: str = typer.Option("", "--notes", help="Entry notes"), notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None: ) -> None:
"""Add a derived seed phrase entry and output its index.""" """Add a derived seed phrase entry and output its index."""
pm = _get_pm(ctx) service = _get_entry_service(ctx)
idx = pm.entry_manager.add_seed( idx = service.add_seed(
label, label,
pm.parent_seed,
index=index, index=index,
words_num=words, words=words,
notes=notes, notes=notes,
) )
typer.echo(str(idx)) typer.echo(str(idx))
pm.sync_vault()
@entry_app.command("add-key-value") @entry_app.command("add-key-value")
@@ -265,10 +319,9 @@ def entry_add_key_value(
notes: str = typer.Option("", "--notes", help="Entry notes"), notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None: ) -> None:
"""Add a key/value entry and output its index.""" """Add a key/value entry and output its index."""
pm = _get_pm(ctx) service = _get_entry_service(ctx)
idx = pm.entry_manager.add_key_value(label, value, notes=notes) idx = service.add_key_value(label, value, notes=notes)
typer.echo(str(idx)) typer.echo(str(idx))
pm.sync_vault()
@entry_app.command("add-managed-account") @entry_app.command("add-managed-account")
@@ -279,15 +332,13 @@ def entry_add_managed_account(
notes: str = typer.Option("", "--notes", help="Entry notes"), notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None: ) -> None:
"""Add a managed account seed entry and output its index.""" """Add a managed account seed entry and output its index."""
pm = _get_pm(ctx) service = _get_entry_service(ctx)
idx = pm.entry_manager.add_managed_account( idx = service.add_managed_account(
label, label,
pm.parent_seed,
index=index, index=index,
notes=notes, notes=notes,
) )
typer.echo(str(idx)) typer.echo(str(idx))
pm.sync_vault()
@entry_app.command("modify") @entry_app.command("modify")
@@ -305,9 +356,9 @@ def entry_modify(
value: Optional[str] = typer.Option(None, "--value", help="New value"), value: Optional[str] = typer.Option(None, "--value", help="New value"),
) -> None: ) -> None:
"""Modify an existing entry.""" """Modify an existing entry."""
pm = _get_pm(ctx) service = _get_entry_service(ctx)
try: try:
pm.entry_manager.modify_entry( service.modify_entry(
entry_id, entry_id,
username=username, username=username,
url=url, url=url,
@@ -319,33 +370,31 @@ def entry_modify(
) )
except ValueError as e: except ValueError as e:
typer.echo(str(e)) typer.echo(str(e))
sys.stdout.flush()
raise typer.Exit(code=1) raise typer.Exit(code=1)
pm.sync_vault()
@entry_app.command("archive") @entry_app.command("archive")
def entry_archive(ctx: typer.Context, entry_id: int) -> None: def entry_archive(ctx: typer.Context, entry_id: int) -> None:
"""Archive an entry.""" """Archive an entry."""
pm = _get_pm(ctx) service = _get_entry_service(ctx)
pm.entry_manager.archive_entry(entry_id) service.archive_entry(entry_id)
typer.echo(str(entry_id)) typer.echo(str(entry_id))
pm.sync_vault()
@entry_app.command("unarchive") @entry_app.command("unarchive")
def entry_unarchive(ctx: typer.Context, entry_id: int) -> None: def entry_unarchive(ctx: typer.Context, entry_id: int) -> None:
"""Restore an archived entry.""" """Restore an archived entry."""
pm = _get_pm(ctx) service = _get_entry_service(ctx)
pm.entry_manager.restore_entry(entry_id) service.restore_entry(entry_id)
typer.echo(str(entry_id)) typer.echo(str(entry_id))
pm.sync_vault()
@entry_app.command("totp-codes") @entry_app.command("totp-codes")
def entry_totp_codes(ctx: typer.Context) -> None: def entry_totp_codes(ctx: typer.Context) -> None:
"""Display all current TOTP codes.""" """Display all current TOTP codes."""
pm = _get_pm(ctx) service = _get_entry_service(ctx)
pm.handle_display_totp_codes() service.display_totp_codes()
@entry_app.command("export-totp") @entry_app.command("export-totp")
@@ -353,8 +402,8 @@ def entry_export_totp(
ctx: typer.Context, file: str = typer.Option(..., help="Output file") ctx: typer.Context, file: str = typer.Option(..., help="Output file")
) -> None: ) -> None:
"""Export all TOTP secrets to a JSON file.""" """Export all TOTP secrets to a JSON file."""
pm = _get_pm(ctx) service = _get_entry_service(ctx)
data = pm.entry_manager.export_totp_entries(pm.parent_seed) data = service.export_totp_entries()
Path(file).write_text(json.dumps(data, indent=2)) Path(file).write_text(json.dumps(data, indent=2))
typer.echo(str(file)) typer.echo(str(file))
@@ -363,9 +412,10 @@ def entry_export_totp(
def vault_export( def vault_export(
ctx: typer.Context, file: str = typer.Option(..., help="Output file") ctx: typer.Context, file: str = typer.Option(..., help="Output file")
) -> None: ) -> None:
"""Export the vault.""" """Export the vault profile to an encrypted file."""
pm = _get_pm(ctx) vault_service, _profile, _sync = _get_services(ctx)
pm.handle_export_database(Path(file)) data = vault_service.export_profile()
Path(file).write_bytes(data)
typer.echo(str(file)) typer.echo(str(file))
@@ -373,33 +423,63 @@ def vault_export(
def vault_import( def vault_import(
ctx: typer.Context, file: str = typer.Option(..., help="Input file") ctx: typer.Context, file: str = typer.Option(..., help="Input file")
) -> None: ) -> None:
"""Import a vault from an encrypted JSON file.""" """Import a vault profile from an encrypted file."""
pm = _get_pm(ctx) vault_service, _profile, _sync = _get_services(ctx)
pm.handle_import_database(Path(file)) data = Path(file).read_bytes()
pm.sync_vault() vault_service.import_profile(data)
typer.echo(str(file)) typer.echo(str(file))
@vault_app.command("change-password") @vault_app.command("change-password")
def vault_change_password(ctx: typer.Context) -> None: def vault_change_password(ctx: typer.Context) -> None:
"""Change the master password used for encryption.""" """Change the master password used for encryption."""
pm = _get_pm(ctx) vault_service, _profile, _sync = _get_services(ctx)
pm.change_password() 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") @vault_app.command("lock")
def vault_lock(ctx: typer.Context) -> None: def vault_lock(ctx: typer.Context) -> None:
"""Lock the vault and clear sensitive data from memory.""" """Lock the vault and clear sensitive data from memory."""
pm = _get_pm(ctx) vault_service, _profile, _sync = _get_services(ctx)
pm.lock_vault() 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") typer.echo("locked")
@vault_app.command("stats") @vault_app.command("stats")
def vault_stats(ctx: typer.Context) -> None: def vault_stats(ctx: typer.Context) -> None:
"""Display statistics about the current seed profile.""" """Display statistics about the current seed profile."""
pm = _get_pm(ctx) vault_service, _profile, _sync = _get_services(ctx)
stats = pm.get_profile_stats() stats = vault_service.stats()
typer.echo(json.dumps(stats, indent=2)) typer.echo(json.dumps(stats, indent=2))
@@ -411,21 +491,24 @@ def vault_reveal_parent_seed(
), ),
) -> None: ) -> None:
"""Display the parent seed and optionally write an encrypted backup file.""" """Display the parent seed and optionally write an encrypted backup file."""
pm = _get_pm(ctx) vault_service, _profile, _sync = _get_services(ctx)
pm.handle_backup_reveal_parent_seed(Path(file) if file else None) 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") @nostr_app.command("sync")
def nostr_sync(ctx: typer.Context) -> None: def nostr_sync(ctx: typer.Context) -> None:
"""Sync with configured Nostr relays.""" """Sync with configured Nostr relays."""
pm = _get_pm(ctx) _vault, _profile, sync_service = _get_services(ctx)
result = pm.sync_vault() model = sync_service.sync()
if result: if model:
typer.echo("Event IDs:") typer.echo("Event IDs:")
typer.echo(f"- manifest: {result['manifest_id']}") typer.echo(f"- manifest: {model.manifest_id}")
for cid in result["chunk_ids"]: for cid in model.chunk_ids:
typer.echo(f"- chunk: {cid}") typer.echo(f"- chunk: {cid}")
for did in result["delta_ids"]: for did in model.delta_ids:
typer.echo(f"- delta: {did}") typer.echo(f"- delta: {did}")
else: else:
typer.echo("Error: Failed to sync vault") typer.echo("Error: Failed to sync vault")
@@ -434,16 +517,49 @@ def nostr_sync(ctx: typer.Context) -> None:
@nostr_app.command("get-pubkey") @nostr_app.command("get-pubkey")
def nostr_get_pubkey(ctx: typer.Context) -> None: def nostr_get_pubkey(ctx: typer.Context) -> None:
"""Display the active profile's npub.""" """Display the active profile's npub."""
pm = _get_pm(ctx) service = _get_nostr_service(ctx)
npub = pm.nostr_client.key_manager.get_npub() npub = service.get_pubkey()
typer.echo(npub) 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") @config_app.command("get")
def config_get(ctx: typer.Context, key: str) -> None: def config_get(ctx: typer.Context, key: str) -> None:
"""Get a configuration value.""" """Get a configuration value."""
pm = _get_pm(ctx) service = _get_config_service(ctx)
value = pm.config_manager.load_config(require_pin=False).get(key) value = service.get(key)
if value is None: if value is None:
typer.echo("Key not found") typer.echo("Key not found")
else: else:
@@ -453,43 +569,18 @@ def config_get(ctx: typer.Context, key: str) -> None:
@config_app.command("set") @config_app.command("set")
def config_set(ctx: typer.Context, key: str, value: str) -> None: def config_set(ctx: typer.Context, key: str, value: str) -> None:
"""Set a configuration value.""" """Set a configuration value."""
pm = _get_pm(ctx) service = _get_config_service(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)
try: 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 except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}") typer.echo(f"Error: {exc}")
raise typer.Exit(code=1) 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") @config_app.command("toggle-secret-mode")
def config_toggle_secret_mode(ctx: typer.Context) -> None: def config_toggle_secret_mode(ctx: typer.Context) -> None:
"""Interactively enable or disable secret mode.""" """Interactively enable or disable secret mode.
pm = _get_pm(ctx)
cfg = pm.config_manager When enabled, newly generated and retrieved passwords are copied to the
clipboard instead of printed to the screen.
"""
service = _get_config_service(ctx)
try: try:
enabled = cfg.get_secret_mode_enabled() enabled = service.get_secret_mode_enabled()
delay = cfg.get_clipboard_clear_delay() delay = service.get_clipboard_clear_delay()
except Exception as exc: # pragma: no cover - pass through errors except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error loading settings: {exc}") typer.echo(f"Error loading settings: {exc}")
raise typer.Exit(code=1) raise typer.Exit(code=1)
@@ -536,10 +630,7 @@ def config_toggle_secret_mode(ctx: typer.Context) -> None:
raise typer.Exit(code=1) raise typer.Exit(code=1)
try: try:
cfg.set_secret_mode_enabled(enabled) service.set_secret_mode(enabled, delay)
cfg.set_clipboard_clear_delay(delay)
pm.secret_mode_enabled = enabled
pm.clipboard_clear_delay = delay
except Exception as exc: # pragma: no cover - pass through errors except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}") typer.echo(f"Error: {exc}")
raise typer.Exit(code=1) raise typer.Exit(code=1)
@@ -551,10 +642,9 @@ def config_toggle_secret_mode(ctx: typer.Context) -> None:
@config_app.command("toggle-offline") @config_app.command("toggle-offline")
def config_toggle_offline(ctx: typer.Context) -> None: def config_toggle_offline(ctx: typer.Context) -> None:
"""Enable or disable offline mode.""" """Enable or disable offline mode."""
pm = _get_pm(ctx) service = _get_config_service(ctx)
cfg = pm.config_manager
try: try:
enabled = cfg.get_offline_mode() enabled = service.get_offline_mode()
except Exception as exc: # pragma: no cover - pass through errors except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error loading settings: {exc}") typer.echo(f"Error loading settings: {exc}")
raise typer.Exit(code=1) raise typer.Exit(code=1)
@@ -573,8 +663,7 @@ def config_toggle_offline(ctx: typer.Context) -> None:
enabled = False enabled = False
try: try:
cfg.set_offline_mode(enabled) service.set_offline_mode(enabled)
pm.offline_mode = enabled
except Exception as exc: # pragma: no cover - pass through errors except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}") typer.echo(f"Error: {exc}")
raise typer.Exit(code=1) raise typer.Exit(code=1)
@@ -586,52 +675,55 @@ def config_toggle_offline(ctx: typer.Context) -> None:
@fingerprint_app.command("list") @fingerprint_app.command("list")
def fingerprint_list(ctx: typer.Context) -> None: def fingerprint_list(ctx: typer.Context) -> None:
"""List available seed profiles.""" """List available seed profiles."""
pm = _get_pm(ctx) _vault, profile_service, _sync = _get_services(ctx)
for fp in pm.fingerprint_manager.list_fingerprints(): for fp in profile_service.list_profiles():
typer.echo(fp) typer.echo(fp)
@fingerprint_app.command("add") @fingerprint_app.command("add")
def fingerprint_add(ctx: typer.Context) -> None: def fingerprint_add(ctx: typer.Context) -> None:
"""Create a new seed profile.""" """Create a new seed profile."""
pm = _get_pm(ctx) _vault, profile_service, _sync = _get_services(ctx)
pm.add_new_fingerprint() profile_service.add_profile()
@fingerprint_app.command("remove") @fingerprint_app.command("remove")
def fingerprint_remove(ctx: typer.Context, fingerprint: str) -> None: def fingerprint_remove(ctx: typer.Context, fingerprint: str) -> None:
"""Remove a seed profile.""" """Remove a seed profile."""
pm = _get_pm(ctx) _vault, profile_service, _sync = _get_services(ctx)
pm.fingerprint_manager.remove_fingerprint(fingerprint) profile_service.remove_profile(ProfileRemoveRequest(fingerprint=fingerprint))
@fingerprint_app.command("switch") @fingerprint_app.command("switch")
def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None: def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None:
"""Switch to another seed profile.""" """Switch to another seed profile."""
pm = _get_pm(ctx) _vault, profile_service, _sync = _get_services(ctx)
pm.select_fingerprint(fingerprint) password = typer.prompt("Master password", hide_input=True)
profile_service.switch_profile(
ProfileSwitchRequest(fingerprint=fingerprint, password=password)
)
@util_app.command("generate-password") @util_app.command("generate-password")
def generate_password(ctx: typer.Context, length: int = 24) -> None: def generate_password(ctx: typer.Context, length: int = 24) -> None:
"""Generate a strong password.""" """Generate a strong password."""
pm = _get_pm(ctx) service = _get_util_service(ctx)
password = pm.password_generator.generate_password(length) password = service.generate_password(length)
typer.echo(password) typer.echo(password)
@util_app.command("verify-checksum") @util_app.command("verify-checksum")
def verify_checksum(ctx: typer.Context) -> None: def verify_checksum(ctx: typer.Context) -> None:
"""Verify the SeedPass script checksum.""" """Verify the SeedPass script checksum."""
pm = _get_pm(ctx) service = _get_util_service(ctx)
pm.handle_verify_checksum() service.verify_checksum()
@util_app.command("update-checksum") @util_app.command("update-checksum")
def update_checksum(ctx: typer.Context) -> None: def update_checksum(ctx: typer.Context) -> None:
"""Regenerate the script checksum file.""" """Regenerate the script checksum file."""
pm = _get_pm(ctx) service = _get_util_service(ctx)
pm.handle_update_script_checksum() service.update_checksum()
@api_app.command("start") @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}") 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__": if __name__ == "__main__":
app() app()

View File

@@ -1,10 +1,10 @@
# password_manager/__init__.py # seedpass.core/__init__.py
"""Expose password manager components with lazy imports.""" """Expose password manager components with lazy imports."""
from importlib import import_module from importlib import import_module
__all__ = ["PasswordManager", "ConfigManager", "Vault", "EntryType"] __all__ = ["PasswordManager", "ConfigManager", "Vault", "EntryType", "StateManager"]
def __getattr__(name: str): def __getattr__(name: str):
@@ -16,4 +16,6 @@ def __getattr__(name: str):
return import_module(".vault", __name__).Vault return import_module(".vault", __name__).Vault
if name == "EntryType": if name == "EntryType":
return import_module(".entry_types", __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}'") 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 Backup Manager Module
@@ -19,7 +19,7 @@ import traceback
from pathlib import Path from pathlib import Path
from termcolor import colored from termcolor import colored
from password_manager.config_manager import ConfigManager from .config_manager import ConfigManager
from utils.file_lock import exclusive_lock from utils.file_lock import exclusive_lock
from constants import APP_DIR from constants import APP_DIR

View File

@@ -10,10 +10,10 @@ from utils.seed_prompt import masked_input
import bcrypt import bcrypt
from password_manager.vault import Vault from .vault import Vault
from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS 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__) logger = logging.getLogger(__name__)
@@ -52,8 +52,8 @@ class ConfigManager:
"secret_mode_enabled": False, "secret_mode_enabled": False,
"clipboard_clear_delay": 45, "clipboard_clear_delay": 45,
"quick_unlock": False, "quick_unlock": False,
"nostr_max_retries": 2, "nostr_max_retries": MAX_RETRIES,
"nostr_retry_delay": 1.0, "nostr_retry_delay": float(RETRY_DELAY),
"min_uppercase": 2, "min_uppercase": 2,
"min_lowercase": 2, "min_lowercase": 2,
"min_digits": 2, "min_digits": 2,
@@ -77,8 +77,8 @@ class ConfigManager:
data.setdefault("secret_mode_enabled", False) data.setdefault("secret_mode_enabled", False)
data.setdefault("clipboard_clear_delay", 45) data.setdefault("clipboard_clear_delay", 45)
data.setdefault("quick_unlock", False) data.setdefault("quick_unlock", False)
data.setdefault("nostr_max_retries", 2) data.setdefault("nostr_max_retries", MAX_RETRIES)
data.setdefault("nostr_retry_delay", 1.0) data.setdefault("nostr_retry_delay", float(RETRY_DELAY))
data.setdefault("min_uppercase", 2) data.setdefault("min_uppercase", 2)
data.setdefault("min_lowercase", 2) data.setdefault("min_lowercase", 2)
data.setdefault("min_digits", 2) data.setdefault("min_digits", 2)
@@ -251,7 +251,7 @@ class ConfigManager:
# Password policy settings # Password policy settings
def get_password_policy(self) -> "PasswordPolicy": def get_password_policy(self) -> "PasswordPolicy":
"""Return the password complexity policy.""" """Return the password complexity policy."""
from password_manager.password_generation import PasswordPolicy from .password_generation import PasswordPolicy
cfg = self.load_config(require_pin=False) cfg = self.load_config(require_pin=False)
return PasswordPolicy( return PasswordPolicy(
@@ -303,7 +303,7 @@ class ConfigManager:
def get_nostr_max_retries(self) -> int: def get_nostr_max_retries(self) -> int:
"""Retrieve the configured Nostr retry count.""" """Retrieve the configured Nostr retry count."""
cfg = self.load_config(require_pin=False) 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: def set_nostr_retry_delay(self, delay: float) -> None:
"""Persist the delay between Nostr retry attempts.""" """Persist the delay between Nostr retry attempts."""
@@ -316,7 +316,7 @@ class ConfigManager:
def get_nostr_retry_delay(self) -> float: def get_nostr_retry_delay(self) -> float:
"""Retrieve the delay in seconds between Nostr retries.""" """Retrieve the delay in seconds between Nostr retries."""
cfg = self.load_config(require_pin=False) 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: def set_verbose_timing(self, enabled: bool) -> None:
cfg = self.load_config(require_pin=False) 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 logging
import traceback import traceback
@@ -228,6 +228,7 @@ class EncryptionManager:
relative_path: Optional[Path] = None, relative_path: Optional[Path] = None,
*, *,
strict: bool = True, strict: bool = True,
merge: bool = False,
) -> bool: ) -> bool:
"""Decrypts data from Nostr and saves it. """Decrypts data from Nostr and saves it.
@@ -249,6 +250,20 @@ class EncryptionManager:
data = json_lib.loads(decrypted_data) data = json_lib.loads(decrypted_data)
else: else:
data = json_lib.loads(decrypted_data.decode("utf-8")) 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.save_json_data(data, relative_path) # This always saves in V2 format
self.update_checksum(relative_path) self.update_checksum(relative_path)
logger.info("Index file from Nostr was processed and saved successfully.") 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 Entry Management Module
@@ -27,18 +27,19 @@ import logging
import hashlib import hashlib
import sys import sys
import shutil import shutil
import time
from typing import Optional, Tuple, Dict, Any, List from typing import Optional, Tuple, Dict, Any, List
from pathlib import Path from pathlib import Path
from termcolor import colored from termcolor import colored
from password_manager.migrations import LATEST_VERSION from .migrations import LATEST_VERSION
from password_manager.entry_types import EntryType from .entry_types import EntryType
from password_manager.totp import TotpManager from .totp import TotpManager
from utils.fingerprint import generate_fingerprint from utils.fingerprint import generate_fingerprint
from utils.checksum import canonical_json_dumps from utils.checksum import canonical_json_dumps
from password_manager.vault import Vault from .vault import Vault
from password_manager.backup import BackupManager from .backup import BackupManager
# Instantiate the logger # Instantiate the logger
@@ -97,6 +98,7 @@ class EntryManager:
entry["word_count"] = entry["words"] entry["word_count"] = entry["words"]
entry.pop("words", None) entry.pop("words", None)
entry.setdefault("tags", []) entry.setdefault("tags", [])
entry.setdefault("modified_ts", entry.get("updated", 0))
logger.debug("Index loaded successfully.") logger.debug("Index loaded successfully.")
self._index_cache = data self._index_cache = data
return data return data
@@ -176,6 +178,7 @@ class EntryManager:
"type": EntryType.PASSWORD.value, "type": EntryType.PASSWORD.value,
"kind": EntryType.PASSWORD.value, "kind": EntryType.PASSWORD.value,
"notes": notes, "notes": notes,
"modified_ts": int(time.time()),
"custom_fields": custom_fields or [], "custom_fields": custom_fields or [],
"tags": tags or [], "tags": tags or [],
} }
@@ -236,6 +239,7 @@ class EntryManager:
"type": EntryType.TOTP.value, "type": EntryType.TOTP.value,
"kind": EntryType.TOTP.value, "kind": EntryType.TOTP.value,
"label": label, "label": label,
"modified_ts": int(time.time()),
"index": index, "index": index,
"period": period, "period": period,
"digits": digits, "digits": digits,
@@ -249,6 +253,7 @@ class EntryManager:
"kind": EntryType.TOTP.value, "kind": EntryType.TOTP.value,
"label": label, "label": label,
"secret": secret, "secret": secret,
"modified_ts": int(time.time()),
"period": period, "period": period,
"digits": digits, "digits": digits,
"archived": archived, "archived": archived,
@@ -294,6 +299,7 @@ class EntryManager:
"kind": EntryType.SSH.value, "kind": EntryType.SSH.value,
"index": index, "index": index,
"label": label, "label": label,
"modified_ts": int(time.time()),
"notes": notes, "notes": notes,
"archived": archived, "archived": archived,
"tags": tags or [], "tags": tags or [],
@@ -312,7 +318,7 @@ class EntryManager:
if not entry or (etype != EntryType.SSH.value and kind != EntryType.SSH.value): if not entry or (etype != EntryType.SSH.value and kind != EntryType.SSH.value):
raise ValueError("Entry is not an SSH key entry") 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)) key_index = int(entry.get("index", index))
return derive_ssh_key_pair(parent_seed, key_index) return derive_ssh_key_pair(parent_seed, key_index)
@@ -340,6 +346,7 @@ class EntryManager:
"kind": EntryType.PGP.value, "kind": EntryType.PGP.value,
"index": index, "index": index,
"label": label, "label": label,
"modified_ts": int(time.time()),
"key_type": key_type, "key_type": key_type,
"user_id": user_id, "user_id": user_id,
"notes": notes, "notes": notes,
@@ -360,7 +367,7 @@ class EntryManager:
if not entry or (etype != EntryType.PGP.value and kind != EntryType.PGP.value): if not entry or (etype != EntryType.PGP.value and kind != EntryType.PGP.value):
raise ValueError("Entry is not a PGP key entry") 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 local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator from bip_utils import Bip39SeedGenerator
@@ -392,6 +399,7 @@ class EntryManager:
"kind": EntryType.NOSTR.value, "kind": EntryType.NOSTR.value,
"index": index, "index": index,
"label": label, "label": label,
"modified_ts": int(time.time()),
"notes": notes, "notes": notes,
"archived": archived, "archived": archived,
"tags": tags or [], "tags": tags or [],
@@ -421,6 +429,7 @@ class EntryManager:
"type": EntryType.KEY_VALUE.value, "type": EntryType.KEY_VALUE.value,
"kind": EntryType.KEY_VALUE.value, "kind": EntryType.KEY_VALUE.value,
"label": label, "label": label,
"modified_ts": int(time.time()),
"value": value, "value": value,
"notes": notes, "notes": notes,
"archived": archived, "archived": archived,
@@ -480,6 +489,7 @@ class EntryManager:
"kind": EntryType.SEED.value, "kind": EntryType.SEED.value,
"index": index, "index": index,
"label": label, "label": label,
"modified_ts": int(time.time()),
"word_count": words_num, "word_count": words_num,
"notes": notes, "notes": notes,
"archived": archived, "archived": archived,
@@ -501,7 +511,7 @@ class EntryManager:
): ):
raise ValueError("Entry is not a seed entry") 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 local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator from bip_utils import Bip39SeedGenerator
@@ -530,7 +540,7 @@ class EntryManager:
if index is None: if index is None:
index = self.get_next_index() 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 local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator from bip_utils import Bip39SeedGenerator
@@ -552,6 +562,7 @@ class EntryManager:
"kind": EntryType.MANAGED_ACCOUNT.value, "kind": EntryType.MANAGED_ACCOUNT.value,
"index": index, "index": index,
"label": label, "label": label,
"modified_ts": int(time.time()),
"word_count": word_count, "word_count": word_count,
"notes": notes, "notes": notes,
"fingerprint": fingerprint, "fingerprint": fingerprint,
@@ -576,7 +587,7 @@ class EntryManager:
): ):
raise ValueError("Entry is not a managed account entry") 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 local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator from bip_utils import Bip39SeedGenerator
@@ -682,7 +693,8 @@ class EntryManager:
): ):
entry.setdefault("custom_fields", []) entry.setdefault("custom_fields", [])
logger.debug(f"Retrieved entry at index {index}: {entry}") 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: else:
logger.warning(f"No entry found at index {index}.") logger.warning(f"No entry found at index {index}.")
print(colored(f"Warning: No entry found at index {index}.", "yellow")) print(colored(f"Warning: No entry found at index {index}.", "yellow"))
@@ -887,6 +899,8 @@ class EntryManager:
entry["tags"] = tags entry["tags"] = tags
logger.debug(f"Updated tags for index {index}: {tags}") logger.debug(f"Updated tags for index {index}: {tags}")
entry["modified_ts"] = int(time.time())
data["entries"][str(index)] = entry data["entries"][str(index)] = entry
logger.debug(f"Modified entry at index {index}: {entry}") logger.debug(f"Modified entry at index {index}: {entry}")
@@ -922,10 +936,17 @@ class EntryManager:
include_archived: bool = False, include_archived: bool = False,
verbose: bool = True, verbose: bool = True,
) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]: ) -> 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 Parameters
``True``. ----------
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: try:
data = self._load_index() data = self._load_index()
@@ -941,11 +962,14 @@ class EntryManager:
idx_str, entry = item idx_str, entry = item
if sort_by == "index": if sort_by == "index":
return int(idx_str) 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() return entry.get("label", entry.get("website", "")).lower()
if sort_by == "username": if sort_by == "updated":
return entry.get("username", "").lower() # sort newest first
raise ValueError("sort_by must be 'index', 'label', or 'username'") 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) sorted_items = sorted(entries_data.items(), key=sort_key)
@@ -1045,9 +1069,10 @@ class EntryManager:
return [] return []
def search_entries( def search_entries(
self, query: str self, query: str, kinds: List[str] | None = None
) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]: ) -> 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() data = self._load_index()
entries_data = data.get("entries", {}) entries_data = data.get("entries", {})
@@ -1059,72 +1084,31 @@ class EntryManager:
for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])): for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])):
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) etype = 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", "")) 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", []) tags = entry.get("tags", [])
archived = entry.get("archived", entry.get("blacklisted", False))
label_match = query_lower in label.lower() 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) tags_match = any(query_lower in str(t).lower() for t in tags)
if etype == EntryType.PASSWORD.value: if label_match or username_match or url_match or tags_match:
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 query_lower in username.lower()
or query_lower in url.lower()
or notes_match
or custom_match
or tags_match
):
results.append( results.append(
( (
int(idx), int(idx),
label, label,
username, username if username is not None else None,
url, url if url is not None else None,
entry.get("archived", entry.get("blacklisted", False)), archived,
)
)
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)),
) )
) )

View File

@@ -1,4 +1,4 @@
# password_manager/entry_types.py # seedpass.core/entry_types.py
"""Enumerations for entry types used by SeedPass.""" """Enumerations for entry types used by SeedPass."""
from enum import Enum from enum import Enum

View File

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

View File

@@ -1,4 +1,4 @@
# password_manager/password_generation.py # seedpass.core/password_generation.py
""" """
Password Generation Module Password Generation Module
@@ -43,7 +43,7 @@ except ModuleNotFoundError: # pragma: no cover - fallback for removed module
from local_bip85.bip85 import BIP85 from local_bip85.bip85 import BIP85
from constants import DEFAULT_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH from constants import DEFAULT_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH
from password_manager.encryption import EncryptionManager from .encryption import EncryptionManager
# Instantiate the logger # Instantiate the logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -12,14 +12,14 @@ import asyncio
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from password_manager.vault import Vault from .vault import Vault
from password_manager.backup import BackupManager from .backup import BackupManager
from nostr.client import NostrClient from nostr.client import NostrClient
from utils.key_derivation import ( from utils.key_derivation import (
derive_index_key, derive_index_key,
EncryptionMode, EncryptionMode,
) )
from password_manager.encryption import EncryptionManager from .encryption import EncryptionManager
from utils.checksum import json_checksum, canonical_json_dumps from utils.checksum import json_checksum, canonical_json_dumps
logger = logging.getLogger(__name__) 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() return self.encryption_manager.get_encrypted_index()
def decrypt_and_save_index_from_nostr( 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: ) -> 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( return self.encryption_manager.decrypt_and_save_index_from_nostr(
encrypted_data, strict=strict encrypted_data, strict=strict, merge=merge
) )
# ----- Config helpers ----- # ----- 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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.vault import Vault from seedpass.core.vault import Vault
from password_manager.encryption import EncryptionManager from seedpass.core.encryption import EncryptionManager
from utils.key_derivation import ( from utils.key_derivation import (
derive_index_key, derive_index_key,
derive_key_from_password, 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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager from seedpass.core.entry_management import EntryManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.manager import PasswordManager, EncryptionMode from seedpass.core.manager import PasswordManager, EncryptionMode
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
class FakePasswordGenerator: class FakePasswordGenerator:

View File

@@ -4,9 +4,9 @@ from tempfile import TemporaryDirectory
from helpers import create_vault, TEST_SEED, TEST_PASSWORD from helpers import create_vault, TEST_SEED, TEST_PASSWORD
from password_manager.entry_management import EntryManager from seedpass.core.entry_management import EntryManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
def test_entry_manager_additional_backup(monkeypatch): def test_entry_manager_additional_backup(monkeypatch):

View File

@@ -179,12 +179,16 @@ def test_change_password_route(client):
cl, token = client cl, token = client
called = {} 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"} 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.status_code == 200
assert res.json() == {"status": "ok"} 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" 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 res.json() == {"status": "locked"}
assert called.get("locked") is True assert called.get("locked") is True
assert api._pm.locked is True assert api._pm.locked is True
api._pm.unlock_vault = lambda: setattr(api._pm, "locked", False) api._pm.unlock_vault = lambda pw: setattr(api._pm, "locked", False)
api._pm.unlock_vault() api._pm.unlock_vault("pw")
assert api._pm.locked is False 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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager from seedpass.core.entry_management import EntryManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.manager import PasswordManager, EncryptionMode from seedpass.core.manager import PasswordManager, EncryptionMode
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
class FakePasswordGenerator: 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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager from seedpass.core.entry_management import EntryManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
def setup_entry_mgr(tmp_path: Path) -> EntryManager: 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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager from seedpass.core.entry_management import EntryManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
from password_manager.manager import PasswordManager, EncryptionMode from seedpass.core.manager import PasswordManager, EncryptionMode
def setup_entry_mgr(tmp_path: Path) -> EntryManager: 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])) 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 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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.manager import PasswordManager from seedpass.core.manager import PasswordManager
import password_manager.manager as manager_module import seedpass.core.manager as manager_module
def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path): 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) pm.config_manager = SimpleNamespace(get_quick_unlock=lambda: False)
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "1") monkeypatch.setattr("builtins.input", lambda *_a, **_k: "1")
monkeypatch.setattr(
"password_manager.manager.prompt_existing_password", lambda *_a, **_k: "pw"
)
monkeypatch.setattr( monkeypatch.setattr(
PasswordManager, "setup_encryption_manager", lambda *a, **k: True PasswordManager, "setup_encryption_manager", lambda *a, **k: True
) )
monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda *a, **k: None) monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda *a, **k: None)
monkeypatch.setattr(PasswordManager, "initialize_managers", lambda *a, **k: None) monkeypatch.setattr(PasswordManager, "initialize_managers", lambda *a, **k: None)
monkeypatch.setattr( monkeypatch.setattr("seedpass.core.manager.NostrClient", lambda *a, **kw: object())
"password_manager.manager.NostrClient", lambda *a, **kw: object()
)
calls = {"count": 0} 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) monkeypatch.setattr(PasswordManager, "start_background_sync", fake_bg)
assert pm.handle_switch_fingerprint() assert pm.handle_switch_fingerprint(password="pw")
assert calls["count"] == 1 assert calls["count"] == 1

View File

@@ -4,8 +4,8 @@ from tempfile import TemporaryDirectory
from helpers import create_vault, TEST_SEED, TEST_PASSWORD from helpers import create_vault, TEST_SEED, TEST_PASSWORD
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
def test_backup_interval(monkeypatch): 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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
def test_backup_restore_workflow(monkeypatch): def test_backup_restore_workflow(monkeypatch):

View File

@@ -5,7 +5,7 @@ import pytest
sys.path.append(str(Path(__file__).resolve().parents[1])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from local_bip85.bip85 import BIP85, Bip85Error from local_bip85.bip85 import BIP85, Bip85Error
from password_manager.password_generation import ( from seedpass.core.password_generation import (
derive_ssh_key, derive_ssh_key,
derive_seed_phrase, 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 typer.testing import CliRunner
from seedpass import cli from seedpass import cli
from password_manager.entry_types import EntryType from seedpass.core.entry_types import EntryType
class DummyPM: class DummyPM:
@@ -17,7 +17,7 @@ class DummyPM:
list_entries=lambda sort_by="index", filter_kind=None, include_archived=False: [ list_entries=lambda sort_by="index", filter_kind=None, include_archived=False: [
(1, "Label", "user", "url", 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}, retrieve_entry=lambda idx: {"type": EntryType.PASSWORD.value, "length": 8},
get_totp_code=lambda idx, seed: "123456", get_totp_code=lambda idx, seed: "123456",
add_entry=lambda label, length, username, url: 1, add_entry=lambda label, length, username, url: 1,
@@ -40,10 +40,10 @@ class DummyPM:
self.handle_display_totp_codes = lambda: None self.handle_display_totp_codes = lambda: None
self.handle_export_database = lambda path: None self.handle_export_database = lambda path: None
self.handle_import_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.lock_vault = lambda: None
self.get_profile_stats = lambda: {"n": 1} 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_verify_checksum = lambda: None
self.handle_update_script_checksum = lambda: None self.handle_update_script_checksum = lambda: None
self.add_new_fingerprint = lambda: None self.add_new_fingerprint = lambda: None
@@ -58,6 +58,7 @@ class DummyPM:
"chunk_ids": ["c1"], "chunk_ids": ["c1"],
"delta_ids": [], "delta_ids": [],
} }
self.start_background_vault_sync = lambda *a, **k: self.sync_vault()
self.config_manager = SimpleNamespace( self.config_manager = SimpleNamespace(
load_config=lambda require_pin=False: {"inactivity_timeout": 30}, load_config=lambda require_pin=False: {"inactivity_timeout": 30},
set_inactivity_timeout=lambda v: None, set_inactivity_timeout=lambda v: None,
@@ -76,7 +77,7 @@ class DummyPM:
) )
self.secret_mode_enabled = True self.secret_mode_enabled = True
self.clipboard_clear_delay = 30 self.clipboard_clear_delay = 30
self.select_fingerprint = lambda fp: None self.select_fingerprint = lambda fp, **_: None
def load_doc_commands() -> list[str]: def load_doc_commands() -> list[str]:
@@ -84,7 +85,9 @@ def load_doc_commands() -> list[str]:
cmds = set(re.findall(r"`seedpass ([^`<>]+)`", text)) cmds = set(re.findall(r"`seedpass ([^`<>]+)`", text))
cmds = {c for c in cmds if "<" not in c and ">" not in c} cmds = {c for c in cmds if "<" not in c and ">" not in c}
cmds.discard("vault export") cmds.discard("vault export")
cmds.discard("vault export --file backup.json")
cmds.discard("vault import") cmds.discard("vault import")
cmds.discard("vault import --file backup.json")
return sorted(cmds) return sorted(cmds)

View File

@@ -115,14 +115,14 @@ def test_entry_add_commands(
called["kwargs"] = kwargs called["kwargs"] = kwargs
return stdout return stdout
def sync_vault(): def start_background_vault_sync():
called["sync"] = True called["sync"] = True
pm = SimpleNamespace( pm = SimpleNamespace(
entry_manager=SimpleNamespace(**{method: func}), entry_manager=SimpleNamespace(**{method: func}),
parent_seed="seed", parent_seed="seed",
select_fingerprint=lambda fp: None, select_fingerprint=lambda fp: None,
sync_vault=sync_vault, start_background_vault_sync=start_background_vault_sync,
) )
monkeypatch.setattr(cli, "PasswordManager", lambda: pm) monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["entry", command] + cli_args) 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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
import main import main
from password_manager.portable_backup import export_backup, import_backup from seedpass.core.portable_backup import export_backup, import_backup
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from helpers import create_vault, TEST_SEED 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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
import main 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"): 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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.encryption import EncryptionManager from seedpass.core.encryption import EncryptionManager
from password_manager.vault import Vault from seedpass.core.vault import Vault
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
from utils.key_derivation import derive_index_key, derive_key_from_password 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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
from password_manager.vault import Vault from seedpass.core.vault import Vault
from nostr.client import DEFAULT_RELAYS from nostr.client import DEFAULT_RELAYS
from constants import INACTIVITY_TIMEOUT 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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager from seedpass.core.entry_management import EntryManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.manager import PasswordManager, EncryptionMode from seedpass.core.manager import PasswordManager, EncryptionMode
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
def test_retrieve_entry_shows_custom_fields(monkeypatch, capsys): 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 types import SimpleNamespace
from pathlib import Path from pathlib import Path
from password_manager.manager import PasswordManager from seedpass.core.manager import PasswordManager
from utils.key_derivation import EncryptionMode 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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager from seedpass.core.entry_management import EntryManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.manager import PasswordManager, EncryptionMode from seedpass.core.manager import PasswordManager, EncryptionMode
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
class FakePasswordGenerator: class FakePasswordGenerator:

View File

@@ -8,7 +8,7 @@ import base64
sys.path.append(str(Path(__file__).resolve().parents[1])) 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 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])) 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(): 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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager from seedpass.core.entry_management import EntryManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.vault import Vault from seedpass.core.vault import Vault
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
def test_list_entries_empty(): 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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager from seedpass.core.entry_management import EntryManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.vault import Vault from seedpass.core.vault import Vault
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
def test_add_and_retrieve_entry(): 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) data = enc_mgr.load_json_data(entry_mgr.index_file)
assert str(index) in data.get("entries", {}) 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( @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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager from seedpass.core.entry_management import EntryManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.vault import Vault from seedpass.core.vault import Vault
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
def test_update_checksum_writes_to_expected_path(): 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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager from seedpass.core.entry_management import EntryManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.manager import PasswordManager, EncryptionMode from seedpass.core.manager import PasswordManager, EncryptionMode
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
from password_manager.totp import TotpManager from seedpass.core.totp import TotpManager
class FakeNostrClient: class FakeNostrClient:
@@ -42,9 +42,7 @@ def test_handle_export_totp_codes(monkeypatch, tmp_path):
export_path = tmp_path / "out.json" export_path = tmp_path / "out.json"
monkeypatch.setattr("builtins.input", lambda *a, **k: str(export_path)) monkeypatch.setattr("builtins.input", lambda *a, **k: str(export_path))
monkeypatch.setattr( monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: False)
"password_manager.manager.confirm_action", lambda *_a, **_k: False
)
pm.handle_export_totp_codes() pm.handle_export_totp_codes()

View File

@@ -9,7 +9,7 @@ import base64
sys.path.append(str(Path(__file__).resolve().parents[1])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from utils.fingerprint import generate_fingerprint from utils.fingerprint import generate_fingerprint
from password_manager.encryption import EncryptionManager from seedpass.core.encryption import EncryptionManager
def test_generate_fingerprint_deterministic(): def test_generate_fingerprint_deterministic():

View File

@@ -4,10 +4,10 @@ from tempfile import TemporaryDirectory
from helpers import create_vault, dummy_nostr_client from helpers import create_vault, dummy_nostr_client
from password_manager.entry_management import EntryManager from seedpass.core.entry_management import EntryManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
from password_manager.manager import PasswordManager, EncryptionMode from seedpass.core.manager import PasswordManager, EncryptionMode
def _init_pm(dir_path: Path, client) -> PasswordManager: 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 # Manager A publishes initial snapshot
pm_a.entry_manager.add_entry("site1", 12) pm_a.entry_manager.add_entry("site1", 12)
pm_a.sync_vault() pm_a.sync_vault()
manifest_id = relay.manifests[-1].id manifest_id = relay.manifests[-1].tags[0]
# Manager B retrieves snapshot # Manager B retrieves snapshot
result = pm_b.attempt_initial_sync() 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 helpers import create_vault, dummy_nostr_client
from password_manager.entry_management import EntryManager from seedpass.core.entry_management import EntryManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
from password_manager.manager import PasswordManager, EncryptionMode from seedpass.core.manager import PasswordManager, EncryptionMode
def _init_pm(dir_path: Path, client) -> PasswordManager: 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 # Manager A publishes initial snapshot
pm_a.entry_manager.add_entry("site1", 12) pm_a.entry_manager.add_entry("site1", 12)
pm_a.sync_vault() pm_a.sync_vault()
manifest_id = relay.manifests[-1].id manifest_id = relay.manifests[-1].tags[0]
# Manager B retrieves snapshot # Manager B retrieves snapshot
result = pm_b.attempt_initial_sync() result = pm_b.attempt_initial_sync()

View File

@@ -9,7 +9,7 @@ from utils.key_derivation import (
derive_key_from_password_argon2, derive_key_from_password_argon2,
derive_index_key, derive_index_key,
) )
from password_manager.encryption import EncryptionManager from seedpass.core.encryption import EncryptionManager
cfg_values = st.one_of( 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["locked"] == 1
assert locked["unlocked"] == 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 unittest.mock import patch
from helpers import create_vault, TEST_SEED, TEST_PASSWORD from helpers import create_vault, TEST_SEED, TEST_PASSWORD
from password_manager.entry_management import EntryManager from seedpass.core.entry_management import EntryManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
def test_index_caching(): 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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.encryption import EncryptionManager from seedpass.core.encryption import EncryptionManager
from password_manager.vault import Vault from seedpass.core.vault import Vault
from utils.key_derivation import derive_index_key, derive_key_from_password 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" 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_key_from_password_argon2,
derive_index_key, derive_index_key,
) )
from password_manager.encryption import EncryptionManager from seedpass.core.encryption import EncryptionManager
from password_manager.vault import Vault from seedpass.core.vault import Vault
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
from password_manager.manager import PasswordManager, EncryptionMode from seedpass.core.manager import PasswordManager, EncryptionMode
TEST_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" TEST_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
TEST_PASSWORD = "pw" TEST_PASSWORD = "pw"
@@ -59,12 +59,12 @@ def test_setup_encryption_manager_kdf_modes(monkeypatch):
cfg = _setup_profile(path, mode) cfg = _setup_profile(path, mode)
pm = _make_pm(path, cfg) pm = _make_pm(path, cfg)
monkeypatch.setattr( monkeypatch.setattr(
"password_manager.manager.prompt_existing_password", "seedpass.core.manager.prompt_existing_password",
lambda *_: TEST_PASSWORD, lambda *_: TEST_PASSWORD,
) )
if mode == "argon2": if mode == "argon2":
monkeypatch.setattr( 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), lambda pw: derive_key_from_password_argon2(pw, **argon_kwargs),
) )
monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None) 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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager from seedpass.core.entry_management import EntryManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
def setup_entry_mgr(tmp_path: Path) -> EntryManager: def setup_entry_mgr(tmp_path: Path) -> EntryManager:
@@ -41,4 +41,4 @@ def test_add_and_modify_key_value():
assert updated["value"] == "def456" assert updated["value"] == "def456"
results = em.search_entries("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 from tempfile import TemporaryDirectory
import constants import constants
import password_manager.manager as manager_module import seedpass.core.manager as manager_module
from utils.fingerprint_manager import FingerprintManager from utils.fingerprint_manager import FingerprintManager
from password_manager.manager import EncryptionMode from seedpass.core.manager import EncryptionMode
from helpers import TEST_SEED 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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager from seedpass.core.entry_management import EntryManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
from password_manager.entry_types import EntryType from seedpass.core.entry_types import EntryType
def setup_entry_manager(tmp_path: Path) -> EntryManager: 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) return EntryManager(vault, backup_mgr)
def test_sort_by_website(): def test_sort_by_label():
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir) tmp_path = Path(tmpdir)
em = setup_entry_manager(tmp_path) em = setup_entry_manager(tmp_path)
idx0 = em.add_entry("b.com", 8, "user1") idx0 = em.add_entry("b.com", 8, "user1")
idx1 = em.add_entry("A.com", 8, "user2") idx1 = em.add_entry("A.com", 8, "user2")
result = em.list_entries(sort_by="website") result = em.list_entries(sort_by="label")
assert result == [ assert result == [
(idx1, "A.com", "user2", "", False), (idx1, "A.com", "user2", "", False),
(idx0, "b.com", "user1", "", False), (idx0, "b.com", "user1", "", False),
] ]
def test_sort_by_username(): def test_sort_by_updated():
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir) tmp_path = Path(tmpdir)
em = setup_entry_manager(tmp_path) em = setup_entry_manager(tmp_path)
idx0 = em.add_entry("alpha.com", 8, "Charlie") idx0 = em.add_entry("alpha.com", 8, "u0")
idx1 = em.add_entry("beta.com", 8, "alice") idx1 = em.add_entry("beta.com", 8, "u1")
result = em.list_entries(sort_by="username")
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 == [ assert result == [
(idx1, "beta.com", "alice", "", False), (idx1, "beta.com", "u1", "", False),
(idx0, "alpha.com", "Charlie", "", 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 helpers import create_vault, TEST_SEED, TEST_PASSWORD
from utils.fingerprint import generate_fingerprint from utils.fingerprint import generate_fingerprint
import password_manager.manager as manager_module import seedpass.core.manager as manager_module
from password_manager.manager import EncryptionMode from seedpass.core.manager import EncryptionMode
sys.path.append(str(Path(__file__).resolve().parents[1])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager from seedpass.core.entry_management import EntryManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
def setup_entry_manager(tmp_path: Path) -> EntryManager: 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 helpers import create_vault, TEST_SEED, TEST_PASSWORD
from utils.fingerprint import generate_fingerprint from utils.fingerprint import generate_fingerprint
import password_manager.manager as manager_module import seedpass.core.manager as manager_module
from password_manager.manager import EncryptionMode from seedpass.core.manager import EncryptionMode
sys.path.append(str(Path(__file__).resolve().parents[1])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager from seedpass.core.entry_management import EntryManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
from password_manager.password_generation import derive_seed_phrase from seedpass.core.password_generation import derive_seed_phrase
from local_bip85.bip85 import BIP85 from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator 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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager from seedpass.core.entry_management import EntryManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.manager import PasswordManager, EncryptionMode from seedpass.core.manager import PasswordManager, EncryptionMode
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
class FakeNostrClient: class FakeNostrClient:

View File

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

View File

@@ -5,7 +5,7 @@ import sys
sys.path.append(str(Path(__file__).resolve().parents[1])) 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 from constants import NOTIFICATION_DURATION
@@ -20,7 +20,7 @@ def _make_pm():
def test_notify_sets_current(monkeypatch): def test_notify_sets_current(monkeypatch):
pm = _make_pm() pm = _make_pm()
current = {"val": 100.0} 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") pm.notify("hello")
note = pm._current_notification note = pm._current_notification
assert hasattr(note, "message") assert hasattr(note, "message")
@@ -32,7 +32,7 @@ def test_notify_sets_current(monkeypatch):
def test_get_current_notification_ttl(monkeypatch): def test_get_current_notification_ttl(monkeypatch):
pm = _make_pm() pm = _make_pm()
now = {"val": 0.0} 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") pm.notify("note1")
assert pm.get_current_notification().message == "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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager from seedpass.core.entry_management import EntryManager
from password_manager.backup import BackupManager from seedpass.core.backup import BackupManager
from password_manager.manager import PasswordManager, EncryptionMode from seedpass.core.manager import PasswordManager, EncryptionMode
from password_manager.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
class FakeNostrClient: class FakeNostrClient:
@@ -50,7 +50,7 @@ def test_handle_display_totp_codes(monkeypatch, capsys):
# interrupt the loop after first iteration # interrupt the loop after first iteration
monkeypatch.setattr( monkeypatch.setattr(
"password_manager.manager.timed_input", "seedpass.core.manager.timed_input",
lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()), lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()),
) )
@@ -91,7 +91,7 @@ def test_display_totp_codes_excludes_archived(monkeypatch, capsys):
) )
monkeypatch.setattr( monkeypatch.setattr(
"password_manager.manager.timed_input", "seedpass.core.manager.timed_input",
lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()), lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()),
) )

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