diff --git a/.github/workflows/briefcase.yml b/.github/workflows/briefcase.yml
new file mode 100644
index 0000000..99d6a6f
--- /dev/null
+++ b/.github/workflows/briefcase.yml
@@ -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/**
diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml
index 0087961..edd768f 100644
--- a/.github/workflows/python-ci.yml
+++ b/.github/workflows/python-ci.yml
@@ -84,12 +84,24 @@ jobs:
timeout-minutes: 16
shell: bash
run: scripts/run_ci_tests.sh
+ - name: Run desktop tests
+ timeout-minutes: 10
+ shell: bash
+ env:
+ TOGA_BACKEND: toga_dummy
+ run: scripts/run_gui_tests.sh
- name: Upload pytest log
if: always()
uses: actions/upload-artifact@v4
with:
name: pytest-log-${{ matrix.os }}
path: pytest.log
+ - name: Upload GUI pytest log
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: gui-pytest-log-${{ matrix.os }}
+ path: pytest_gui.log
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
diff --git a/README.md b/README.md
index 7b39a4d..902914e 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
## Table of Contents
- [Features](#features)
+- [Architecture Overview](#architecture-overview)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [1. Clone the Repository](#1-clone-the-repository)
@@ -32,6 +33,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
- [Managing Multiple Seeds](#managing-multiple-seeds)
- [Additional Entry Types](#additional-entry-types)
- [Building a standalone executable](#building-a-standalone-executable)
+- [Packaging with Briefcase](#packaging-with-briefcase)
- [Security Considerations](#security-considerations)
- [Contributing](#contributing)
- [License](#license)
@@ -54,7 +56,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
- **Optional External Backup Location:** Configure a second directory where backups are automatically copied.
- **Auto-Lock on Inactivity:** Vault locks after a configurable timeout for additional security.
- **Quick Unlock:** Optionally skip the password prompt after verifying once.
-- **Secret Mode:** Copy retrieved passwords directly to your clipboard and automatically clear it after a delay.
+- **Secret Mode:** When enabled, newly generated and retrieved passwords are copied to your clipboard and automatically cleared after a delay.
- **Tagging Support:** Organize entries with optional tags and find them quickly via search.
- **Manual Vault Export/Import:** Create encrypted backups or restore them using the CLI or API.
- **Parent Seed Backup:** Securely save an encrypted copy of the master seed.
@@ -65,9 +67,41 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
- **Relay Management:** List, add, remove or reset configured Nostr relays.
- **Offline Mode:** Disable all Nostr communication for local-only operation.
+
A small on-screen notification area now shows queued messages for 10 seconds
before fading.
+## Architecture Overview
+
+SeedPass follows a layered design. The **`seedpass.core`** package exposes the
+`PasswordManager` along with service classes (e.g. `VaultService` and
+`EntryService`) that implement the main API used across interfaces.
+The command line tool in **`seedpass.cli`** is a thin adapter built with Typer
+that delegates operations to this API layer.
+
+The BeeWare desktop interface lives in **`seedpass_gui.app`** and can be
+started with either `seedpass-gui` or `python -m seedpass_gui`. It reuses the
+same service objects to unlock the vault, list entries and search through them.
+
+An optional browser extension can communicate with the FastAPI server exposed by
+`seedpass.api` to manage entries from within the browser.
+
+```mermaid
+graph TD
+ core["seedpass.core"]
+ cli["CLI"]
+ api["FastAPI server"]
+ gui["BeeWare GUI"]
+ ext["Browser Extension"]
+
+ cli --> core
+ gui --> core
+ api --> core
+ ext --> api
+```
+
+See `docs/ARCHITECTURE.md` for details.
+
## Prerequisites
- **Python 3.8+** (3.11 or 3.12 recommended): Install Python from [python.org](https://www.python.org/downloads/) and be sure to check **"Add Python to PATH"** during setup. Using Python 3.13 is currently discouraged because some dependencies do not ship wheels for it yet, which can cause build failures on Windows unless you install the Visual C++ Build Tools.
@@ -78,6 +112,7 @@ before fading.
### Quick Installer
Use the automated installer to download SeedPass and its dependencies in one step.
+The scripts also install the correct BeeWare backend for your platform automatically.
**Linux and macOS:**
```bash
@@ -87,6 +122,7 @@ bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/
```bash
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)" _ -b beta
```
+Make sure the command ends right after `-b beta` with **no trailing parenthesis**.
**Windows (PowerShell):**
```powershell
@@ -96,6 +132,20 @@ Before running the script, install **Python 3.11** or **3.12** from [python.org]
The Windows installer will attempt to install Git automatically if it is not already available. It also tries to install Python 3 using `winget`, `choco`, or `scoop` when Python is missing and recognizes the `py` launcher if `python` isn't on your PATH. If these tools are unavailable you'll see a link to download Python directly from
• 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.
• Restructure to `seedpass/` (or keep `src/` but list `packages = ["seedpass"]`).
• Entry-point: `seedpass = "seedpass.main:cli"`.
• Dev extras: `pytest-cov`, `ruff`, `mypy`, `pre-commit`.
• Split pure business logic from I/O (e.g., encryption, BIP-85, vault ops) so GUI can reuse. | **Weeks 0-2** |
-| **2 – Local Quality Net** | Fail fast before CI runs. | • `make test` / `tox` quick matrix (3.10–3.12).
• 90 % line coverage gate.
• 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).
• Steps: install → unit tests → build wheels (`python -m build`) → PyInstaller one-file artefacts → upload to Release.
• 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.
**Flatpak** • YAML manifest + GitHub Action to build & push to Flathub beta repo.
**Windows** • PyInstaller `--onefile` → NSIS installer.
**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).
• Create `seedpass.gui` package calling existing APIs; flag with `--gui`.
• Feature flag via env var `SEEDPASS_GUI=1` or CLI switch.
• 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": …}`.
• Document simple example plugin (e.g., custom password rule).
• 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).
• Threat-model doc: key-storage, BIP-85 determinism, Nostr backup flow.
• Repro-build check for PyInstaller artefacts.
• Signed releases (Sigstore, minisign). | **Weeks 12-16** |
-| **8 – 1.0 Launch Prep** | Final polish + docs. | • User manual (MkDocs, `docs.seedpass.org`).
• In-app `--check-update` hitting GitHub API.
• Blog post & template release notes. | **Weeks 16-18** |
-
----
-
-### Ongoing Practices to Keep Development Nimble
-
-| Practice | What to do |
-| ----------------------- | ------------------------------------------------------------------------------------------- |
-| **Dynamic versioning** | Keep `version` dynamic via `setuptools-scm` / `hatch-vcs`; tag and push – nothing else. |
-| **Trunk-based dev** | Short-lived branches, PRs < 300 LOC; merge when tests pass. |
-| **Feature flags** | `seedpass.config.is_enabled("X")` so unfinished work can ship dark. |
-| **Fast feedback loops** | Local editable install; `invoke run --watch` (or `uvicorn --reload` for GUI) to hot-reload. |
-| **Weekly beta release** | Even during heavy GUI work, cut “beta” tags weekly; real users shake out regressions early. |
-
----
-
-### First 2-Week Sprint (Concrete To-Dos)
-
-1. **Bootstrap packaging**
-
- ```bash
- pip install --upgrade pip build setuptools_scm
- poetry init # if you prefer Poetry, else stick with setuptools
- ```
-
- Add `pyproject.toml`, move code to `seedpass/`.
-
-2. **Console entry-point**
- In `seedpass/__main__.py` add `from .main import cli; cli()`.
-
-3. **Editable dev install**
- `pip install -e .[dev]` → run `seedpass --help`.
-
-4. **Set up pre-commit**
- `pre-commit install` with ruff + black + mypy hooks.
-
-5. **GitHub Action skeleton** (`.github/workflows/ci.yml`)
-
- ```yaml
- jobs:
- test:
- strategy:
- matrix: os: [ubuntu-latest, windows-latest, macos-latest]
- python-version: ['3.12', '3.11']
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-python@v5
- with: {python-version: ${{ matrix.python-version }}}
- - run: pip install --upgrade pip
- - run: pip install -e .[dev]
- - run: pytest -n auto
- ```
-
-6. **Smoke PyInstaller locally**
- `pyinstaller --onefile seedpass/main.py` – fix missing data/hooks; check binary runs.
-
-When that’s green, cut tag `v0.1.0-beta` and let CI build artefacts automatically.
-
----
-
-### Choosing the GUI Path (decision by Week 6)
-
-| If you value… | Choose |
-| ---------------------------------- | ---------------------------- |
-| Terminal-first UX, live coding | **Textual (Rich-TUI)** |
-| Native look, single code base | **Toga / Briefcase** |
-| Advanced widgets, designer tooling | **PySide-6 / Qt for Python** |
-
-Prototype one screen (vault list + “Add” dialog) and benchmark bundle size + startup time with PyInstaller before committing.
-
----
-
-## Recap
-
-* **Packaging & CI first** – lets every future feature ride an established release train.
-* **GUI lives in its own layer** – CLI stays stable; dev cycles remain quick.
-* **Security & signing** land after functionality is stable, before v1.0 marketing push.
-
-Follow the phase table, keep weekly betas flowing, and you’ll reach a polished, installer-ready, GUI-enhanced 1.0 in roughly four months without sacrificing day-to-day agility.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..f8416b8
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -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.
diff --git a/docs/docs/content/01-getting-started/01-advanced_cli.md b/docs/docs/content/01-getting-started/01-advanced_cli.md
index 1c0bed6..3c86dcc 100644
--- a/docs/docs/content/01-getting-started/01-advanced_cli.md
+++ b/docs/docs/content/01-getting-started/01-advanced_cli.md
@@ -116,6 +116,10 @@ Miscellaneous helper commands.
| Verify script checksum | `util verify-checksum` | `seedpass util verify-checksum` |
| Update script checksum | `util update-checksum` | `seedpass util update-checksum` |
+If you see a startup warning about a script checksum mismatch,
+run `seedpass util update-checksum` or choose "Generate Script Checksum"
+from the Settings menu to update the stored value.
+
### API Commands
Run or stop the local HTTP API.
diff --git a/docs/docs/content/01-getting-started/02-api_reference.md b/docs/docs/content/01-getting-started/02-api_reference.md
index 5c23bf6..38ac3b6 100644
--- a/docs/docs/content/01-getting-started/02-api_reference.md
+++ b/docs/docs/content/01-getting-started/02-api_reference.md
@@ -2,6 +2,9 @@
This guide covers how to start the SeedPass API, authenticate requests, and interact with the available endpoints.
+**Note:** All UI layers, including the CLI, BeeWare GUI, and future adapters, consume this REST API through service classes in `seedpass.core`. See [docs/gui_adapter.md](docs/gui_adapter.md) for more details on the GUI integration.
+
+
## Starting the API
Run `seedpass api start` from your terminal. The command prints a one‑time token used for authentication:
diff --git a/docs/docs/content/01-getting-started/05-briefcase.md b/docs/docs/content/01-getting-started/05-briefcase.md
new file mode 100644
index 0000000..e7b82aa
--- /dev/null
+++ b/docs/docs/content/01-getting-started/05-briefcase.md
@@ -0,0 +1,29 @@
+# Packaging the GUI with Briefcase
+
+This project uses [BeeWare's Briefcase](https://beeware.org) to generate
+platform‑native installers. Once your development environment is set up,
+package the GUI by running the following commands from the repository root:
+
+```bash
+# Create the application scaffold for your platform
+briefcase create
+
+# Compile dependencies and produce a distributable bundle
+briefcase build
+
+# Run the packaged application
+briefcase run
+```
+
+## Command Overview
+
+- **`briefcase create`** — generates the project scaffold for your
+ operating system. Run this once per platform.
+- **`briefcase build`** — compiles dependencies and produces the
+ distributable bundle.
+- **`briefcase run`** — launches the packaged application so you can test
+ it locally.
+
+After the initial creation step you can repeatedly run `briefcase build`
+followed by `briefcase run` to test your packaged application on Windows,
+macOS or Linux.
diff --git a/docs/docs/content/01-getting-started/06-gui_adapter.md b/docs/docs/content/01-getting-started/06-gui_adapter.md
new file mode 100644
index 0000000..dcb2dac
--- /dev/null
+++ b/docs/docs/content/01-getting-started/06-gui_adapter.md
@@ -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.
diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md
index d417824..d272206 100644
--- a/docs/docs/content/index.md
+++ b/docs/docs/content/index.md
@@ -16,6 +16,22 @@ This software was not developed by an experienced security expert and should be
✔ Windows 10/11 • macOS 12+ • Any modern Linux
SeedPass now uses the `portalocker` library for cross-platform file locking. No WSL or Cygwin required.
+```mermaid
+graph TD
+ core(seedpass.core)
+ cli(CLI/TUI)
+ gui(BeeWare GUI)
+ ext(Browser extension)
+ cli --> core
+ gui --> core
+ ext --> core
+```
+
+SeedPass uses a modular design with a single core library that handles all
+security-critical logic. The current CLI/TUI adapter communicates with
+`seedpass.core`, and future interfaces like a BeeWare GUI and a browser
+extension can hook into the same layer. This architecture keeps the codebase
+maintainable while enabling a consistent experience on multiple platforms.
## Table of Contents
@@ -191,10 +207,10 @@ create a backup:
seedpass
# Export your index
-seedpass export --file "~/seedpass_backup.json"
+seedpass vault export --file "~/seedpass_backup.json"
# Later you can restore it
-seedpass import --file "~/seedpass_backup.json"
+seedpass vault import --file "~/seedpass_backup.json"
# Import also performs a Nostr sync to pull any changes
# Quickly find or retrieve entries
@@ -481,6 +497,10 @@ If the checksum file is missing, generate it manually:
python scripts/update_checksum.py
```
+If SeedPass reports a "script checksum mismatch" warning on startup,
+regenerate the checksum with `seedpass util update-checksum` or select
+"Generate Script Checksum" from the Settings menu.
+
To run mutation tests locally, generate coverage data first and then execute `mutmut`:
```bash
diff --git a/landing/index.html b/landing/index.html
index 2814e2b..678c5b9 100644
--- a/landing/index.html
+++ b/landing/index.html
@@ -84,6 +84,26 @@ flowchart TB
--- +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 ++
+--- config: layout: fixed theme: base diff --git a/post-refactor-to-do.md b/post-refactor-to-do.md deleted file mode 100644 index 62a038a..0000000 --- a/post-refactor-to-do.md +++ /dev/null @@ -1,78 +0,0 @@ ---- - -# SeedPass Feature Back‑Log (v2) - -> **Encryption invariant** Everything at rest **and** in export remains cipher‑text that ultimately derives from the **profile master‑password + parent seed**. No unencrypted payload leaves the vault. -> -> **Surface rule** UI layers (CLI, GUI, future mobile) may *display* decrypted data **after** user unlock, but must never write plaintext to disk or network. - ---- - -## Track vocabulary - -| Label | Meaning | -| ------------ | ------------------------------------------------------------------------------ | -| **Core API** | `seedpass.api` – headless services consumed by CLI / GUI | -| **Profile** | A fingerprint‑scoped vault: parent‑seed + hashed pw + entries | -| **Entry** | One encrypted JSON blob on disk plus Nostr snapshot chunks and delta events | -| **GUI MVP** | Desktop app built with PySide 6 announced in the v2 roadmap | - ---- - -## Phase A • Core‑level enhancements (blockers for GUI) - -| Prio | Feature | Notes | -| ------ | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 🔥 | **Encrypted Search API** | • `VaultService.search(query:str, *, kinds=None) -> List[EntryMeta]`
• Decrypt *only* whitelisted meta‑fields per `kind` (title, username, url, tags) for in‑memory matching. | -| 🔥 | **Rich Listing / Sort / Filter** | • `list_entries(sort_by="updated", kind="note")`
• Sorting by `title` must decrypt that field on‑the‑fly. | -| 🔥 | **Custom Relay Set (per profile)** | • `StateManager.state["relays"]: List[str]`
• CRUD CLI commands & GUI dialog.
• `NostrClient` reads from state at instantiation. | -| ⚡ | **Session Lock & Idle Timeout** | • Config `SESSION_TIMEOUT` (default 15 min).
• `AuthGuard` clears in‑memory keys & seeds.
• CLI `seedpass lock` + GUI menu “Lock vault”. | - -**Exit‑criteria** : All functions green in CI, consumed by both CLI (Typer) *and* a minimal Qt test harness. - ---- - -## Phase B • Data Portability (encrypted only) - -| Prio | Feature | Notes | | -| ------ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| ⭐ | **Encrypted Profile Export** | • CLI `seedpass export --out myprofile.enc`
• Serialise *encrypted* entry files → single JSON wrapper → `EncryptionManager.encrypt_data()`
• Always require active profile unlock. | | -| ⭐ | **Encrypted Profile Import / Merge** | • CLI \`seedpass import myprofile.enc \[--strategy skip | overwrite-newer]`
• Verify fingerprint match before ingest.
• 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
• `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.
• Profile switcher drop‑down. | -| 🔥 | **Vault Window** | • Sidebar (Entries, Search, Backups, Settings).
• `QTableView` bound to `VaultService.list_entries()`
• Sort & basic filters built‑in. | -| 🔥 | **Entry Editor Dialog** | • Dynamic form driven by `kinds.py`.
• Add / Edit. | -| ⭐ | **Sync Status Bar** | • Pulsing icon + last sync timestamp; hooks into `SyncService` bus. | -| ◇ | **Relay Manager Dialog** | • CRUD & ping test per relay. | - -*Binary packaging (PyInstaller matrix build) is already tracked in the roadmap and is not duplicated here.* - ---- - -## Phase E • Later / Research - -• Hardware‑wallet unlock (SLIP‑39 share) -• Background daemon (`seedpassd` + gRPC) -• Mobile companion (Flutter FFI) -• Federated search across multiple profiles - ---- - -**Reminder:** *No plaintext exports, no on‑disk temp files, and no writing decrypted data to Nostr.* Everything funnels through the encryption stack or stays in memory for the current unlocked session only. diff --git a/pyproject.toml b/pyproject.toml index 4165dd2..880a5c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,10 +2,50 @@ name = "seedpass" version = "0.1.0" +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" + [project.scripts] seedpass = "seedpass.cli:app" +seedpass-gui = "seedpass_gui.app:main" [tool.mypy] python_version = "3.11" strict = true mypy_path = "src" + +[tool.briefcase.app.seedpass-gui] +formal-name = "SeedPass" +description = "Deterministic password manager with a BeeWare GUI" +sources = ["src"] +requires = [ + "toga-core>=0.5.2", + "colorama>=0.4.6", + "termcolor>=1.1.0", + "cryptography>=40.0.2", + "bip-utils>=2.5.0", + "bech32==1.2.0", + "coincurve>=18.0.0", + "mnemonic", + "aiohttp>=3.12.14", + "bcrypt", + "portalocker>=2.8", + "nostr-sdk>=0.42.1", + "websocket-client==1.7.0", + "websockets>=15.0.0", + "tomli", + "pgpy==0.6.0", + "pyotp>=2.8.0", + "pyperclip", + "qrcode>=8.2", + "typer>=0.12.3", + "fastapi>=0.116.0", + "uvicorn>=0.35.0", + "httpx>=0.28.1", + "requests>=2.32", + "python-multipart", + "orjson", + "argon2-cffi", +] +icon = "logo/png/SeedPass-Logo-24.png" diff --git a/refactor.md b/refactor.md deleted file mode 100644 index fdbf457..0000000 --- a/refactor.md +++ /dev/null @@ -1,113 +0,0 @@ -# SeedPass v2 Roadmap — CLI → Desktop GUI - -> **Guiding principles** -> -> 1. **Core-first** – a headless, testable Python package (`seedpass.core`) that is 100 % GUI-agnostic. -> 2. **Thin adapters** – CLI, GUI, and future mobile layers merely call the core API. -> 3. **Stateless UI** – all persistence lives in core services; UI never touches vault files directly. -> 4. **Parity at every step** – CLI must keep working while GUI evolves. - ---- - -## Phase 0 • Tooling Baseline - -| # | Task | Rationale | -| --- | ---------------------------------------------------------------------------------------------- | --------------------------------- | -| 0.1 | ✅ **Adopt `poetry`** (or `hatch`) for builds & dependency pins. | Single-source version + lockfile. | -| 0.2 | ✅ **GitHub Actions**: lint (ruff), type-check (mypy), tests (pytest -q), coverage gate ≥ 85 %. | Prevent regressions. | -| 0.3 | ✅ Pre-commit hooks: ruff –fix, black, isort. | Uniform style. | - ---- - -## Phase 1 • Finalize Core Refactor (CLI still primary) - -> *Most of this is already drafted – here’s what must ship before GUI work starts.* - -| # | Component | Must-have work | -| --- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| 1.1 | **`kinds.py` registry + per-kind handler modules** | import-safe; handler signature `(data,fingerprint,**svc)` | -| 1.2 | **`StateManager`** | JSON file w/ fcntl lock
keys: `last_bip85_idx`, `last_sync_ts` | -| 1.3 | **Checksum inside entry metadata** | `sha256(json.dumps(data,sort_keys=True))` | -| 1.4 | **Replaceable Nostr events** (kind 31111, `d` tag = `"{kindtag}{entry_num}"`) | publish/update/delete tombstone | -| 1.5 | **Per-entry `EntryManager` / `BackupManager`** | Save / load / backup / restore individual encrypted files | -| 1.6 | **CLI rewritten with Typer** | Typer commands map 1-to-1 with core service methods; preserves colours. | -| 1.7 | **Legacy index migration command** | `seedpass migrate-legacy` – idempotent, uses `add_entry()` under the hood. | -| 1.8 | **bcrypt + NFKD master password hash** | Stored per fingerprint. | - -> **Exit-criteria:** end-to-end flow (`add → list → sync → restore`) green in CI and covered by tests. - ---- - -## Phase 2 • Core API Hardening (prep for GUI) - -| # | Task | Deliverable | -| --- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| 2.1 | **Public Service Layer** (`seedpass.api`) | Facade classes:
`VaultService`, `ProfileService`, `SyncService` – *no* CLI / UI imports. | -| 2.2 | **Thread-safe gate** | Re-entrancy locks so GUI threads can call core safely. | -| 2.3 | **Fast in-process event bus** | Simple `pubsub.py` (observer pattern) for GUI to receive progress callbacks (e.g. sync progress, long ops). | -| 2.4 | **Docstrings + pydantic models** | Typed request/response objects → eases RPC later (e.g. REST, gRPC). | -| 2.5 | **Library packaging** | `python -m pip install .` gives importable `seedpass`. | - ---- - -## Phase 3 • Desktop GUI MVP - -| # | Decision | Notes | -| --- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| 3.0 | **Framework: PySide 6 (Qt 6)** | ✓ LGPL, ✓ native look, ✓ Python-first, ✓ WebEngine if needed. | -| 3.1 | **Process model** | *Same* process; GUI thread ↔ core API via signals/slots.
(If we outgrow this, swap to a local gRPC server later.) | -| 3.2 | **UI Skeleton (milestone “Hello Vault”)** | | -| – | `LoginWindow` | master-password prompt → opens default profile | -| – | `VaultWindow` | sidebar (Profiles, Entries, Backups) + stacked views | -| – | `EntryTableView` | QTableView bound to `VaultService.list_entries()` | -| – | `EntryEditorDialog` | Add / Edit forms – field set driven by `kinds.py` | -| – | `SyncStatusBar` | pulse animation + last-sync timestamp | -| 3.3 | **Icons / theming** | Start with Qt-built-in icons; later swap to SVG set. | -| 3.4 | **Packaging** | `PyInstaller --onefile` for Win / macOS / Linux AppImage; GitHub Actions matrix build. | -| 3.5 | **GUI E2E tests** | PyTest + pytest-qt (QtBot) smoke flows; run headless in CI (Xvfb). | - -> **Stretch option:** wrap the same UI in **Tauri** later for a lighter binary (\~5 MB), reusing the core API through a local websocket RPC. - ---- - -## Phase 4 • Unified Workflows & Coverage - -| # | Task | -| --- | --------------------------------------------------------------------------------------- | -| 4.1 | Extend GitHub Actions to build GUI artifacts on every tag. | -| 4.2 | Add synthetic coverage for GUI code paths (QtBot). | -| 4.3 | Nightly job: spin up headless GUI, run `sync` against test relay, assert no exceptions. | - ---- - -## Phase 5 • Future-Proofing (post-GUI v1) - -| Idea | Sketch | -| -------------------------- | ----------------------------------------------------------------------------------------- | -| **Background daemon** | Optional `seedpassd` exposing Unix socket + JSON-RPC; both CLI & GUI become thin clients. | -| **Hardware-wallet unlock** | Replace master password with HWW + SLIP-39 share; requires PyUSB bridge. | -| **Mobile companion app** | Reuse core via BeeWare or Flutter FFI; sync over Nostr only (no local vault). | -| **End-to-end test farm** | dedicated relay docker-compose + pytest-subprocess to fake flaky relays. | - ---- - -## Deliverables Checklist - -* [ ] Core refactor merged, tests ≥ 85 % coverage -* [ ] `seedpass` installs and passes `python -m seedpass.cli --help` -* [ ] `seedpass-gui` binary opens vault, lists entries, adds & edits, syncs -* [ ] GitHub Actions builds binaries for Win/macOS/Linux on tag -* [ ] `docs/ARCHITECTURE.md` diagrams core ↔ CLI ↔ GUI layers - -When the above are ✅ we can ship `v2.0.0-beta.1` and invite early desktop testers. - ---- - -### 🔑 Key Takeaways - -1. **Keep all state & crypto in the core package.** -2. **Expose a clean Python API first – GUI is “just another client.”** -3. **Checksum + replaceable Nostr events give rock-solid sync & conflict handling.** -4. **Lock files and StateManager prevent index reuse and vault corruption.** -5. **The GUI sprint starts only after Phase 1 + 2 are fully green in CI.** - diff --git a/requirements.lock b/requirements.lock index a0f0be4..dd2ae0f 100644 --- a/requirements.lock +++ b/requirements.lock @@ -20,7 +20,7 @@ cryptography==45.0.4 ecdsa==0.19.1 ed25519-blake2b==1.4.1 execnet==2.1.1 -fastapi==0.116.0 +fastapi==0.116.1 frozenlist==1.7.0 glob2==0.7 hypothesis==6.135.20 @@ -61,6 +61,7 @@ toml==0.10.2 tomli==2.2.1 urllib3==2.5.0 uvicorn==0.35.0 +starlette==0.47.2 httpx==0.28.1 varint==1.0.2 websocket-client==1.7.0 diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py index 2632e9a..00e7aa0 100644 --- a/scripts/generate_test_profile.py +++ b/scripts/generate_test_profile.py @@ -38,11 +38,11 @@ consts.SCRIPT_CHECKSUM_FILE = consts.APP_DIR / "seedpass_script_checksum.txt" from constants import APP_DIR, initialize_app from utils.key_derivation import derive_key_from_password, derive_index_key -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager -from password_manager.backup import BackupManager -from password_manager.entry_management import EntryManager +from seedpass.core.encryption import EncryptionManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager +from seedpass.core.backup import BackupManager +from seedpass.core.entry_management import EntryManager from nostr.client import NostrClient from utils.fingerprint import generate_fingerprint from utils.fingerprint_manager import FingerprintManager diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d843b8b..0883899 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -260,6 +260,10 @@ if ($LASTEXITCODE -ne 0) { Write-Error "Failed to install SeedPass package" } +Write-Info "Installing BeeWare GUI backend..." +& "$VenvDir\Scripts\python.exe" -m pip install toga-winforms +if ($LASTEXITCODE -ne 0) { Write-Warning "Failed to install GUI backend" } + # 5. Create launcher script Write-Info "Creating launcher script..." if (-not (Test-Path $LauncherDir)) { New-Item -ItemType Directory -Path $LauncherDir | Out-Null } @@ -279,6 +283,18 @@ if ($existingSeedpass -and $existingSeedpass.Source -ne $LauncherPath) { Write-Warning "Ensure '$LauncherDir' comes first in your PATH or remove the old installation." } +# Detect additional seedpass executables on PATH that are not our launcher +$allSeedpass = Get-Command seedpass -All -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source +$stale = @() +foreach ($cmd in $allSeedpass) { + if ($cmd -ne $LauncherPath) { $stale += $cmd } +} +if ($stale.Count -gt 0) { + Write-Warning "Stale 'seedpass' executables detected:" + foreach ($cmd in $stale) { Write-Warning " - $cmd" } + Write-Warning "Remove or rename these to avoid launching outdated code." +} + # 6. Add launcher directory to User's PATH if needed Write-Info "Checking if '$LauncherDir' is in your PATH..." $UserPath = [System.Environment]::GetEnvironmentVariable("Path", "User") diff --git a/scripts/install.sh b/scripts/install.sh index cb07099..f6df808 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -86,13 +86,26 @@ main() { # 3. Install OS-specific dependencies print_info "Checking for build dependencies..." if [ "$OS_NAME" = "Linux" ]; then - if command -v apt-get &> /dev/null; then sudo apt-get update && sudo apt-get install -y build-essential pkg-config xclip; - elif command -v dnf &> /dev/null; then sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y pkg-config xclip; - elif command -v pacman &> /dev/null; then sudo pacman -Syu --noconfirm base-devel pkg-config xclip; - else print_warning "Could not detect package manager. Ensure build tools and pkg-config are installed."; fi + if command -v apt-get &> /dev/null; then + sudo apt-get update && sudo apt-get install -y \ + build-essential pkg-config xclip \ + libcairo2 libcairo2-dev \ + libgirepository-2.0-dev gir1.2-girepository-2.0 \ + gobject-introspection \ + gir1.2-gtk-3.0 python3-dev + elif command -v dnf &> /dev/null; then + sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y \ + pkg-config cairo cairo-devel xclip \ + gobject-introspection-devel cairo-devel gtk3-devel python3-devel + elif command -v pacman &> /dev/null; then + sudo pacman -Syu --noconfirm base-devel pkg-config cairo xclip \ + gobject-introspection cairo gtk3 python + else + print_warning "Could not detect package manager. Ensure build tools, cairo, and pkg-config are installed." + fi elif [ "$OS_NAME" = "Darwin" ]; then if ! command -v brew &> /dev/null; then print_error "Homebrew not installed. See https://brew.sh/"; fi - brew install pkg-config + brew install pkg-config cairo fi # 4. Clone or update the repository @@ -120,6 +133,14 @@ main() { pip install --upgrade pip pip install -r src/requirements.txt pip install -e . + print_info "Installing platform-specific Toga backend..." + if [ "$OS_NAME" = "Linux" ]; then + print_info "Installing toga-gtk for Linux..." + pip install toga-gtk + elif [ "$OS_NAME" = "Darwin" ]; then + print_info "Installing toga-cocoa for macOS..." + pip install toga-cocoa + fi deactivate # 7. Create launcher script @@ -138,6 +159,23 @@ EOF2 print_warning "Ensure '$LAUNCHER_DIR' comes first in your PATH or remove the old installation." fi + # Detect any additional seedpass executables on PATH that are not our launcher + IFS=':' read -ra _sp_paths <<< "$PATH" + stale_cmds=() + for _dir in "${_sp_paths[@]}"; do + _candidate="$_dir/seedpass" + if [ -x "$_candidate" ] && [ "$_candidate" != "$LAUNCHER_PATH" ]; then + stale_cmds+=("$_candidate") + fi + done + if [ ${#stale_cmds[@]} -gt 0 ]; then + print_warning "Stale 'seedpass' executables detected:" + for cmd in "${stale_cmds[@]}"; do + print_warning " - $cmd" + done + print_warning "Remove or rename these to avoid launching outdated code." + fi + # 8. Final instructions print_success "Installation/update complete!" print_info "You can now launch the interactive TUI by typing: seedpass" diff --git a/scripts/run_gui_tests.sh b/scripts/run_gui_tests.sh new file mode 100755 index 0000000..3962394 --- /dev/null +++ b/scripts/run_gui_tests.sh @@ -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 diff --git a/scripts/update_checksum.py b/scripts/update_checksum.py index 537c415..eb52041 100644 --- a/scripts/update_checksum.py +++ b/scripts/update_checksum.py @@ -14,7 +14,7 @@ from constants import SCRIPT_CHECKSUM_FILE, initialize_app def main() -> None: """Calculate checksum for the main script and write it to SCRIPT_CHECKSUM_FILE.""" initialize_app() - script_path = SRC_DIR / "password_manager" / "manager.py" + script_path = SRC_DIR / "seedpass/core" / "manager.py" if not update_checksum_file(str(script_path), str(SCRIPT_CHECKSUM_FILE)): raise SystemExit(f"Failed to update checksum for {script_path}") print(f"Updated checksum written to {SCRIPT_CHECKSUM_FILE}") diff --git a/src/constants.py b/src/constants.py index 7d99552..e221288 100644 --- a/src/constants.py +++ b/src/constants.py @@ -9,9 +9,11 @@ logger = logging.getLogger(__name__) # ----------------------------------- # Nostr Relay Connection Settings # ----------------------------------- -# Retry fewer times with a shorter wait by default -MAX_RETRIES = 2 # Maximum number of retries for relay connections -RETRY_DELAY = 1 # Seconds to wait before retrying a failed connection +# Retry fewer times with a shorter wait by default. These values +# act as defaults that can be overridden via ``ConfigManager`` +# entries ``nostr_max_retries`` and ``nostr_retry_delay``. +MAX_RETRIES = 2 # Default maximum number of retry attempts +RETRY_DELAY = 1 # Default seconds to wait before retrying MIN_HEALTHY_RELAYS = 2 # Minimum relays that should return data on startup # ----------------------------------- diff --git a/src/main.py b/src/main.py index 61da7f2..3dea1f7 100644 --- a/src/main.py +++ b/src/main.py @@ -20,9 +20,9 @@ from termcolor import colored from utils.color_scheme import color_text import traceback -from password_manager.manager import PasswordManager +from seedpass.core.manager import PasswordManager from nostr.client import NostrClient -from password_manager.entry_types import EntryType +from seedpass.core.entry_types import EntryType from constants import INACTIVITY_TIMEOUT, initialize_app from utils.password_prompt import PasswordPromptError from utils import ( diff --git a/src/nostr/client.py b/src/nostr/client.py index ea2d817..0155617 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -8,6 +8,7 @@ from typing import List, Optional, Tuple, TYPE_CHECKING import hashlib import asyncio import gzip +import threading import websockets # Imports from the nostr-sdk library @@ -26,12 +27,12 @@ from nostr_sdk import EventId, Timestamp from .key_manager import KeyManager as SeedPassKeyManager from .backup_models import Manifest, ChunkMeta, KIND_MANIFEST, KIND_SNAPSHOT_CHUNK -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager from constants import MAX_RETRIES, RETRY_DELAY from utils.file_lock import exclusive_lock if TYPE_CHECKING: # pragma: no cover - imported for type hints - from password_manager.config_manager import ConfigManager + from seedpass.core.config_manager import ConfigManager # Backwards compatibility for tests that patch these symbols KeyManager = SeedPassKeyManager @@ -46,6 +47,9 @@ DEFAULT_RELAYS = [ "wss://relay.primal.net", ] +# Identifier prefix for replaceable manifest events +MANIFEST_ID_PREFIX = "seedpass-manifest-" + def prepare_snapshot( encrypted_bytes: bytes, limit: int @@ -135,6 +139,7 @@ class NostrClient: self.last_error: Optional[str] = None self.delta_threshold = 100 + self._state_lock = threading.Lock() self.current_manifest: Manifest | None = None self.current_manifest_id: str | None = None self._delta_events: list[str] = [] @@ -295,8 +300,8 @@ class NostrClient: if retries is None or delay is None: if self.config_manager is None: - from password_manager.config_manager import ConfigManager - from password_manager.vault import Vault + from seedpass.core.config_manager import ConfigManager + from seedpass.core.vault import Vault cfg_mgr = ConfigManager( Vault(self.encryption_manager, self.fingerprint_dir), @@ -310,8 +315,7 @@ class NostrClient: self.connect() self.last_error = None - attempt = 0 - while True: + for attempt in range(retries): try: result = asyncio.run(self._retrieve_json_from_nostr()) if result is not None: @@ -319,10 +323,9 @@ class NostrClient: except Exception as e: self.last_error = str(e) logger.error("Failed to retrieve events from Nostr: %s", e) - if attempt >= retries: - break - attempt += 1 - time.sleep(delay) + if attempt < retries - 1: + sleep_time = delay * (2**attempt) + time.sleep(sleep_time) return None async def _retrieve_json_from_nostr(self) -> Optional[bytes]: @@ -365,6 +368,7 @@ class NostrClient: start = time.perf_counter() if self.offline_mode or not self.relays: return Manifest(ver=1, algo="gzip", chunks=[]), "" + await self.ensure_manifest_is_current() await self._connect_async() manifest, chunks = prepare_snapshot(encrypted_bytes, limit) for meta, chunk in zip(manifest.chunks, chunks): @@ -390,22 +394,24 @@ class NostrClient: } ) + manifest_identifier = f"{MANIFEST_ID_PREFIX}{self.fingerprint}" manifest_event = ( EventBuilder(Kind(KIND_MANIFEST), manifest_json) + .tags([Tag.identifier(manifest_identifier)]) .build(self.keys.public_key()) .sign_with_keys(self.keys) ) - result = await self.client.send_event(manifest_event) - manifest_id = result.id.to_hex() if hasattr(result, "id") else str(result) - self.current_manifest = manifest - self.current_manifest_id = manifest_id - # Record when this snapshot was published for future delta events - self.current_manifest.delta_since = int(time.time()) - self._delta_events = [] + await self.client.send_event(manifest_event) + with self._state_lock: + self.current_manifest = manifest + self.current_manifest_id = manifest_identifier + # Record when this snapshot was published for future delta events + self.current_manifest.delta_since = int(time.time()) + self._delta_events = [] if getattr(self, "verbose_timing", False): duration = time.perf_counter() - start logger.info("publish_snapshot completed in %.2f seconds", duration) - return manifest, manifest_id + return manifest, manifest_identifier async def _fetch_chunks_with_retry( self, manifest_event @@ -430,11 +436,24 @@ class NostrClient: except Exception: return None + if self.config_manager is None: + from seedpass.core.config_manager import ConfigManager + from seedpass.core.vault import Vault + + cfg_mgr = ConfigManager( + Vault(self.encryption_manager, self.fingerprint_dir), + self.fingerprint_dir, + ) + else: + cfg_mgr = self.config_manager + cfg = cfg_mgr.load_config(require_pin=False) + max_retries = int(cfg.get("nostr_max_retries", MAX_RETRIES)) + delay = float(cfg.get("nostr_retry_delay", RETRY_DELAY)) + chunks: list[bytes] = [] for meta in manifest.chunks: - attempt = 0 chunk_bytes: bytes | None = None - while attempt < MAX_RETRIES: + for attempt in range(max_retries): cf = Filter().author(pubkey).kind(Kind(KIND_SNAPSHOT_CHUNK)) if meta.event_id: cf = cf.id(EventId.parse(meta.event_id)) @@ -447,18 +466,33 @@ class NostrClient: if hashlib.sha256(candidate).hexdigest() == meta.hash: chunk_bytes = candidate break - attempt += 1 - if attempt < MAX_RETRIES: - await asyncio.sleep(RETRY_DELAY) + if attempt < max_retries - 1: + await asyncio.sleep(delay * (2**attempt)) if chunk_bytes is None: return None chunks.append(chunk_bytes) - man_id = getattr(manifest_event, "id", None) - if hasattr(man_id, "to_hex"): - man_id = man_id.to_hex() - self.current_manifest = manifest - self.current_manifest_id = man_id + ident = None + try: + tags_obj = manifest_event.tags() + ident = tags_obj.identifier() + except Exception: + tags = getattr(manifest_event, "tags", None) + if callable(tags): + tags = tags() + if tags: + tag = tags[0] + if hasattr(tag, "as_vec"): + vec = tag.as_vec() + if vec and len(vec) >= 2: + ident = vec[1] + elif isinstance(tag, (list, tuple)) and len(tag) >= 2: + ident = tag[1] + elif isinstance(tag, str): + ident = tag + with self._state_lock: + self.current_manifest = manifest + self.current_manifest_id = ident return manifest, chunks async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None: @@ -467,24 +501,76 @@ class NostrClient: return None await self._connect_async() + self.last_error = None pubkey = self.keys.public_key() - f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).limit(3) + ident = f"{MANIFEST_ID_PREFIX}{self.fingerprint}" + f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).identifier(ident).limit(1) timeout = timedelta(seconds=10) - events = (await self.client.fetch_events(f, timeout)).to_vec() + try: + events = (await self.client.fetch_events(f, timeout)).to_vec() + except Exception as e: # pragma: no cover - network errors + self.last_error = str(e) + logger.error( + "Failed to fetch manifest from relays %s: %s", + self.relays, + e, + ) + return None + if not events: return None for manifest_event in events: - result = await self._fetch_chunks_with_retry(manifest_event) - if result is not None: - return result + try: + result = await self._fetch_chunks_with_retry(manifest_event) + if result is not None: + return result + except Exception as e: # pragma: no cover - network errors + self.last_error = str(e) + logger.error( + "Error retrieving snapshot from relays %s: %s", + self.relays, + e, + ) + + if self.last_error is None: + self.last_error = "Snapshot not found on relays" return None + async def ensure_manifest_is_current(self) -> None: + """Verify the local manifest is up to date before publishing.""" + if self.offline_mode or not self.relays: + return + await self._connect_async() + pubkey = self.keys.public_key() + ident = f"{MANIFEST_ID_PREFIX}{self.fingerprint}" + f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).identifier(ident).limit(1) + timeout = timedelta(seconds=10) + try: + events = (await self.client.fetch_events(f, timeout)).to_vec() + except Exception: + return + if not events: + return + try: + data = json.loads(events[0].content()) + remote = data.get("delta_since") + if remote is not None: + remote = int(remote) + except Exception: + return + with self._state_lock: + local = self.current_manifest.delta_since if self.current_manifest else None + if remote is not None and (local is None or remote > local): + self.last_error = "Manifest out of date" + raise RuntimeError("Manifest out of date") + async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str: """Publish a delta event referencing a manifest.""" if self.offline_mode or not self.relays: return "" + await self.ensure_manifest_is_current() await self._connect_async() content = base64.b64encode(delta_bytes).decode("utf-8") @@ -498,24 +584,29 @@ class NostrClient: ) if hasattr(created_at, "secs"): created_at = created_at.secs - if self.current_manifest is not None: - self.current_manifest.delta_since = int(created_at) - manifest_json = json.dumps( - { - "ver": self.current_manifest.ver, - "algo": self.current_manifest.algo, - "chunks": [meta.__dict__ for meta in self.current_manifest.chunks], - "delta_since": self.current_manifest.delta_since, - } - ) - manifest_event = ( - EventBuilder(Kind(KIND_MANIFEST), manifest_json) - .tags([Tag.identifier(self.current_manifest_id)]) - .build(self.keys.public_key()) - .sign_with_keys(self.keys) - ) + manifest_event = None + with self._state_lock: + if self.current_manifest is not None: + self.current_manifest.delta_since = int(created_at) + manifest_json = json.dumps( + { + "ver": self.current_manifest.ver, + "algo": self.current_manifest.algo, + "chunks": [ + meta.__dict__ for meta in self.current_manifest.chunks + ], + "delta_since": self.current_manifest.delta_since, + } + ) + manifest_event = ( + EventBuilder(Kind(KIND_MANIFEST), manifest_json) + .tags([Tag.identifier(self.current_manifest_id)]) + .build(self.keys.public_key()) + .sign_with_keys(self.keys) + ) + self._delta_events.append(delta_id) + if manifest_event is not None: await self.client.send_event(manifest_event) - self._delta_events.append(delta_id) return delta_id async def fetch_deltas_since(self, version: int) -> list[bytes]: @@ -533,12 +624,16 @@ class NostrClient: ) timeout = timedelta(seconds=10) events = (await self.client.fetch_events(f, timeout)).to_vec() + events.sort( + key=lambda ev: getattr(ev, "created_at", getattr(ev, "timestamp", 0)) + ) deltas: list[bytes] = [] for ev in events: deltas.append(base64.b64decode(ev.content().encode("utf-8"))) - if self.current_manifest is not None: - snap_size = sum(c.size for c in self.current_manifest.chunks) + manifest = self.get_current_manifest() + if manifest is not None: + snap_size = sum(c.size for c in manifest.chunks) if ( len(deltas) >= self.delta_threshold or sum(len(d) for d in deltas) > snap_size @@ -557,6 +652,21 @@ class NostrClient: await self.client.send_event(exp_event) return deltas + def get_current_manifest(self) -> Manifest | None: + """Thread-safe access to ``current_manifest``.""" + with self._state_lock: + return self.current_manifest + + def get_current_manifest_id(self) -> str | None: + """Thread-safe access to ``current_manifest_id``.""" + with self._state_lock: + return self.current_manifest_id + + def get_delta_events(self) -> list[str]: + """Thread-safe snapshot of pending delta event IDs.""" + with self._state_lock: + return list(self._delta_events) + def close_client_pool(self) -> None: """Disconnects the client from all relays.""" try: diff --git a/src/requirements.txt b/src/requirements.txt index f3951b0..4008154 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -25,10 +25,14 @@ freezegun pyperclip qrcode>=8.2 typer>=0.12.3 -fastapi>=0.116.0 +fastapi>=0.116.1 uvicorn>=0.35.0 +starlette>=0.47.2 httpx>=0.28.1 requests>=2.32 python-multipart orjson argon2-cffi +toga-core>=0.5.2 +pillow +toga-dummy>=0.5.2 # for headless GUI tests diff --git a/src/runtime_requirements.txt b/src/runtime_requirements.txt index 38cf46e..1bde15f 100644 --- a/src/runtime_requirements.txt +++ b/src/runtime_requirements.txt @@ -27,3 +27,4 @@ requests>=2.32 python-multipart orjson argon2-cffi +toga-core>=0.5.2 diff --git a/src/seedpass/api.py b/src/seedpass/api.py index fdc748c..134575a 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -14,8 +14,8 @@ import asyncio import sys from fastapi.middleware.cors import CORSMiddleware -from password_manager.manager import PasswordManager -from password_manager.entry_types import EntryType +from seedpass.core.manager import PasswordManager +from seedpass.core.entry_types import EntryType app = FastAPI() @@ -554,11 +554,13 @@ def backup_parent_seed( @app.post("/api/v1/change-password") -def change_password(authorization: str | None = Header(None)) -> dict[str, str]: +def change_password( + data: dict, authorization: str | None = Header(None) +) -> dict[str, str]: """Change the master password for the active profile.""" _check_token(authorization) assert _pm is not None - _pm.change_password() + _pm.change_password(data.get("old", ""), data.get("new", "")) return {"status": "ok"} diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 2ffb0e6..7cc3b20 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -1,15 +1,32 @@ from pathlib import Path -from typing import Optional +from typing import Optional, List import json import typer +import sys -from password_manager.manager import PasswordManager -from password_manager.entry_types import EntryType +from seedpass.core.manager import PasswordManager +from seedpass.core.entry_types import EntryType +from seedpass.core.api import ( + VaultService, + ProfileService, + SyncService, + EntryService, + ConfigService, + UtilityService, + NostrService, + ChangePasswordRequest, + UnlockRequest, + BackupParentSeedRequest, + ProfileSwitchRequest, + ProfileRemoveRequest, +) import uvicorn from . import api as api_module import importlib +import importlib.util +import subprocess app = typer.Typer( help="SeedPass command line interface", @@ -52,6 +69,43 @@ def _get_pm(ctx: typer.Context) -> PasswordManager: return pm +def _get_services( + ctx: typer.Context, +) -> tuple[VaultService, ProfileService, SyncService]: + """Return service layer instances for the current context.""" + + pm = _get_pm(ctx) + return VaultService(pm), ProfileService(pm), SyncService(pm) + + +def _get_entry_service(ctx: typer.Context) -> EntryService: + pm = _get_pm(ctx) + return EntryService(pm) + + +def _get_config_service(ctx: typer.Context) -> ConfigService: + pm = _get_pm(ctx) + return ConfigService(pm) + + +def _get_util_service(ctx: typer.Context) -> UtilityService: + pm = _get_pm(ctx) + return UtilityService(pm) + + +def _get_nostr_service(ctx: typer.Context) -> NostrService: + pm = _get_pm(ctx) + return NostrService(pm) + + +def _gui_backend_available() -> bool: + """Return True if a platform-specific BeeWare backend is installed.""" + for pkg in ("toga_gtk", "toga_winforms", "toga_cocoa"): + if importlib.util.find_spec(pkg) is not None: + return True + return False + + @app.callback(invoke_without_command=True) def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None: """SeedPass CLI entry point. @@ -68,14 +122,14 @@ def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> def entry_list( ctx: typer.Context, sort: str = typer.Option( - "index", "--sort", help="Sort by 'index', 'label', or 'username'" + "index", "--sort", help="Sort by 'index', 'label', or 'updated'" ), kind: Optional[str] = typer.Option(None, "--kind", help="Filter by entry type"), archived: bool = typer.Option(False, "--archived", help="Include archived"), ) -> None: """List entries in the vault.""" - pm = _get_pm(ctx) - entries = pm.entry_manager.list_entries( + service = _get_entry_service(ctx) + entries = service.list_entries( sort_by=sort, filter_kind=kind, include_archived=archived ) for idx, label, username, url, is_archived in entries: @@ -90,10 +144,20 @@ def entry_list( @entry_app.command("search") -def entry_search(ctx: typer.Context, query: str) -> None: +def entry_search( + ctx: typer.Context, + query: str, + kind: List[str] = typer.Option( + None, + "--kind", + "-k", + help="Filter by entry kinds (can be repeated)", + ), +) -> None: """Search entries.""" - pm = _get_pm(ctx) - results = pm.entry_manager.search_entries(query) + service = _get_entry_service(ctx) + kinds = list(kind) if kind else None + results = service.search_entries(query, kinds=kinds) if not results: typer.echo("No matching entries found") return @@ -109,8 +173,8 @@ def entry_search(ctx: typer.Context, query: str) -> None: @entry_app.command("get") def entry_get(ctx: typer.Context, query: str) -> None: """Retrieve a single entry's secret.""" - pm = _get_pm(ctx) - matches = pm.entry_manager.search_entries(query) + service = _get_entry_service(ctx) + matches = service.search_entries(query) if len(matches) == 0: typer.echo("No matching entries found") raise typer.Exit(code=1) @@ -124,14 +188,14 @@ def entry_get(ctx: typer.Context, query: str) -> None: raise typer.Exit(code=1) index = matches[0][0] - entry = pm.entry_manager.retrieve_entry(index) + entry = service.retrieve_entry(index) etype = entry.get("type", entry.get("kind")) if etype == EntryType.PASSWORD.value: length = int(entry.get("length", 12)) - password = pm.password_generator.generate_password(length, index) + password = service.generate_password(length, index) typer.echo(password) elif etype == EntryType.TOTP.value: - code = pm.entry_manager.get_totp_code(index, pm.parent_seed) + code = service.get_totp_code(index) typer.echo(code) else: typer.echo("Unsupported entry type") @@ -147,10 +211,9 @@ def entry_add( url: Optional[str] = typer.Option(None, "--url"), ) -> None: """Add a new password entry and output its index.""" - pm = _get_pm(ctx) - index = pm.entry_manager.add_entry(label, length, username, url) + service = _get_entry_service(ctx) + index = service.add_entry(label, length, username, url) typer.echo(str(index)) - pm.sync_vault() @entry_app.command("add-totp") @@ -163,17 +226,15 @@ def entry_add_totp( digits: int = typer.Option(6, "--digits", help="Number of TOTP digits"), ) -> None: """Add a TOTP entry and output the otpauth URI.""" - pm = _get_pm(ctx) - uri = pm.entry_manager.add_totp( + service = _get_entry_service(ctx) + uri = service.add_totp( label, - pm.parent_seed, index=index, secret=secret, period=period, digits=digits, ) typer.echo(uri) - pm.sync_vault() @entry_app.command("add-ssh") @@ -184,15 +245,13 @@ def entry_add_ssh( notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add an SSH key entry and output its index.""" - pm = _get_pm(ctx) - idx = pm.entry_manager.add_ssh_key( + service = _get_entry_service(ctx) + idx = service.add_ssh_key( label, - pm.parent_seed, index=index, notes=notes, ) typer.echo(str(idx)) - pm.sync_vault() @entry_app.command("add-pgp") @@ -205,17 +264,15 @@ def entry_add_pgp( notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add a PGP key entry and output its index.""" - pm = _get_pm(ctx) - idx = pm.entry_manager.add_pgp_key( + service = _get_entry_service(ctx) + idx = service.add_pgp_key( label, - pm.parent_seed, index=index, key_type=key_type, user_id=user_id, notes=notes, ) typer.echo(str(idx)) - pm.sync_vault() @entry_app.command("add-nostr") @@ -226,14 +283,13 @@ def entry_add_nostr( notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add a Nostr key entry and output its index.""" - pm = _get_pm(ctx) - idx = pm.entry_manager.add_nostr_key( + service = _get_entry_service(ctx) + idx = service.add_nostr_key( label, index=index, notes=notes, ) typer.echo(str(idx)) - pm.sync_vault() @entry_app.command("add-seed") @@ -245,16 +301,14 @@ def entry_add_seed( notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add a derived seed phrase entry and output its index.""" - pm = _get_pm(ctx) - idx = pm.entry_manager.add_seed( + service = _get_entry_service(ctx) + idx = service.add_seed( label, - pm.parent_seed, index=index, - words_num=words, + words=words, notes=notes, ) typer.echo(str(idx)) - pm.sync_vault() @entry_app.command("add-key-value") @@ -265,10 +319,9 @@ def entry_add_key_value( notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add a key/value entry and output its index.""" - pm = _get_pm(ctx) - idx = pm.entry_manager.add_key_value(label, value, notes=notes) + service = _get_entry_service(ctx) + idx = service.add_key_value(label, value, notes=notes) typer.echo(str(idx)) - pm.sync_vault() @entry_app.command("add-managed-account") @@ -279,15 +332,13 @@ def entry_add_managed_account( notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add a managed account seed entry and output its index.""" - pm = _get_pm(ctx) - idx = pm.entry_manager.add_managed_account( + service = _get_entry_service(ctx) + idx = service.add_managed_account( label, - pm.parent_seed, index=index, notes=notes, ) typer.echo(str(idx)) - pm.sync_vault() @entry_app.command("modify") @@ -305,9 +356,9 @@ def entry_modify( value: Optional[str] = typer.Option(None, "--value", help="New value"), ) -> None: """Modify an existing entry.""" - pm = _get_pm(ctx) + service = _get_entry_service(ctx) try: - pm.entry_manager.modify_entry( + service.modify_entry( entry_id, username=username, url=url, @@ -319,33 +370,31 @@ def entry_modify( ) except ValueError as e: typer.echo(str(e)) + sys.stdout.flush() raise typer.Exit(code=1) - pm.sync_vault() @entry_app.command("archive") def entry_archive(ctx: typer.Context, entry_id: int) -> None: """Archive an entry.""" - pm = _get_pm(ctx) - pm.entry_manager.archive_entry(entry_id) + service = _get_entry_service(ctx) + service.archive_entry(entry_id) typer.echo(str(entry_id)) - pm.sync_vault() @entry_app.command("unarchive") def entry_unarchive(ctx: typer.Context, entry_id: int) -> None: """Restore an archived entry.""" - pm = _get_pm(ctx) - pm.entry_manager.restore_entry(entry_id) + service = _get_entry_service(ctx) + service.restore_entry(entry_id) typer.echo(str(entry_id)) - pm.sync_vault() @entry_app.command("totp-codes") def entry_totp_codes(ctx: typer.Context) -> None: """Display all current TOTP codes.""" - pm = _get_pm(ctx) - pm.handle_display_totp_codes() + service = _get_entry_service(ctx) + service.display_totp_codes() @entry_app.command("export-totp") @@ -353,8 +402,8 @@ def entry_export_totp( ctx: typer.Context, file: str = typer.Option(..., help="Output file") ) -> None: """Export all TOTP secrets to a JSON file.""" - pm = _get_pm(ctx) - data = pm.entry_manager.export_totp_entries(pm.parent_seed) + service = _get_entry_service(ctx) + data = service.export_totp_entries() Path(file).write_text(json.dumps(data, indent=2)) typer.echo(str(file)) @@ -363,9 +412,10 @@ def entry_export_totp( def vault_export( ctx: typer.Context, file: str = typer.Option(..., help="Output file") ) -> None: - """Export the vault.""" - pm = _get_pm(ctx) - pm.handle_export_database(Path(file)) + """Export the vault profile to an encrypted file.""" + vault_service, _profile, _sync = _get_services(ctx) + data = vault_service.export_profile() + Path(file).write_bytes(data) typer.echo(str(file)) @@ -373,33 +423,63 @@ def vault_export( def vault_import( ctx: typer.Context, file: str = typer.Option(..., help="Input file") ) -> None: - """Import a vault from an encrypted JSON file.""" - pm = _get_pm(ctx) - pm.handle_import_database(Path(file)) - pm.sync_vault() + """Import a vault profile from an encrypted file.""" + vault_service, _profile, _sync = _get_services(ctx) + data = Path(file).read_bytes() + vault_service.import_profile(data) typer.echo(str(file)) @vault_app.command("change-password") def vault_change_password(ctx: typer.Context) -> None: """Change the master password used for encryption.""" - pm = _get_pm(ctx) - pm.change_password() + vault_service, _profile, _sync = _get_services(ctx) + old_pw = typer.prompt("Current password", hide_input=True) + new_pw = typer.prompt("New password", hide_input=True, confirmation_prompt=True) + try: + vault_service.change_password( + ChangePasswordRequest(old_password=old_pw, new_password=new_pw) + ) + except Exception as exc: # pragma: no cover - pass through errors + typer.echo(f"Error: {exc}") + raise typer.Exit(code=1) + typer.echo("Password updated") + + +@vault_app.command("unlock") +def vault_unlock(ctx: typer.Context) -> None: + """Unlock the vault for the active profile.""" + vault_service, _profile, _sync = _get_services(ctx) + password = typer.prompt("Master password", hide_input=True) + try: + resp = vault_service.unlock(UnlockRequest(password=password)) + except Exception as exc: # pragma: no cover - pass through errors + typer.echo(f"Error: {exc}") + raise typer.Exit(code=1) + typer.echo(f"Unlocked in {resp.duration:.2f}s") @vault_app.command("lock") def vault_lock(ctx: typer.Context) -> None: """Lock the vault and clear sensitive data from memory.""" - pm = _get_pm(ctx) - pm.lock_vault() + vault_service, _profile, _sync = _get_services(ctx) + vault_service.lock() + typer.echo("locked") + + +@app.command("lock") +def root_lock(ctx: typer.Context) -> None: + """Lock the vault for the active profile.""" + vault_service, _profile, _sync = _get_services(ctx) + vault_service.lock() typer.echo("locked") @vault_app.command("stats") def vault_stats(ctx: typer.Context) -> None: """Display statistics about the current seed profile.""" - pm = _get_pm(ctx) - stats = pm.get_profile_stats() + vault_service, _profile, _sync = _get_services(ctx) + stats = vault_service.stats() typer.echo(json.dumps(stats, indent=2)) @@ -411,21 +491,24 @@ def vault_reveal_parent_seed( ), ) -> None: """Display the parent seed and optionally write an encrypted backup file.""" - pm = _get_pm(ctx) - pm.handle_backup_reveal_parent_seed(Path(file) if file else None) + vault_service, _profile, _sync = _get_services(ctx) + password = typer.prompt("Master password", hide_input=True) + vault_service.backup_parent_seed( + BackupParentSeedRequest(path=Path(file) if file else None, password=password) + ) @nostr_app.command("sync") def nostr_sync(ctx: typer.Context) -> None: """Sync with configured Nostr relays.""" - pm = _get_pm(ctx) - result = pm.sync_vault() - if result: + _vault, _profile, sync_service = _get_services(ctx) + model = sync_service.sync() + if model: typer.echo("Event IDs:") - typer.echo(f"- manifest: {result['manifest_id']}") - for cid in result["chunk_ids"]: + typer.echo(f"- manifest: {model.manifest_id}") + for cid in model.chunk_ids: typer.echo(f"- chunk: {cid}") - for did in result["delta_ids"]: + for did in model.delta_ids: typer.echo(f"- delta: {did}") else: typer.echo("Error: Failed to sync vault") @@ -434,16 +517,49 @@ def nostr_sync(ctx: typer.Context) -> None: @nostr_app.command("get-pubkey") def nostr_get_pubkey(ctx: typer.Context) -> None: """Display the active profile's npub.""" - pm = _get_pm(ctx) - npub = pm.nostr_client.key_manager.get_npub() + service = _get_nostr_service(ctx) + npub = service.get_pubkey() typer.echo(npub) +@nostr_app.command("list-relays") +def nostr_list_relays(ctx: typer.Context) -> None: + """Display configured Nostr relays.""" + service = _get_nostr_service(ctx) + relays = service.list_relays() + for i, r in enumerate(relays, 1): + typer.echo(f"{i}: {r}") + + +@nostr_app.command("add-relay") +def nostr_add_relay(ctx: typer.Context, url: str) -> None: + """Add a relay URL.""" + service = _get_nostr_service(ctx) + try: + service.add_relay(url) + except Exception as exc: # pragma: no cover - pass through errors + typer.echo(f"Error: {exc}") + raise typer.Exit(code=1) + typer.echo("Added") + + +@nostr_app.command("remove-relay") +def nostr_remove_relay(ctx: typer.Context, idx: int) -> None: + """Remove a relay by index (1-based).""" + service = _get_nostr_service(ctx) + try: + service.remove_relay(idx) + except Exception as exc: # pragma: no cover - pass through errors + typer.echo(f"Error: {exc}") + raise typer.Exit(code=1) + typer.echo("Removed") + + @config_app.command("get") def config_get(ctx: typer.Context, key: str) -> None: """Get a configuration value.""" - pm = _get_pm(ctx) - value = pm.config_manager.load_config(require_pin=False).get(key) + service = _get_config_service(ctx) + value = service.get(key) if value is None: typer.echo("Key not found") else: @@ -453,43 +569,18 @@ def config_get(ctx: typer.Context, key: str) -> None: @config_app.command("set") def config_set(ctx: typer.Context, key: str, value: str) -> None: """Set a configuration value.""" - pm = _get_pm(ctx) - cfg = pm.config_manager - - mapping = { - "inactivity_timeout": lambda v: cfg.set_inactivity_timeout(float(v)), - "secret_mode_enabled": lambda v: cfg.set_secret_mode_enabled( - v.lower() in ("1", "true", "yes", "y", "on") - ), - "clipboard_clear_delay": lambda v: cfg.set_clipboard_clear_delay(int(v)), - "additional_backup_path": lambda v: cfg.set_additional_backup_path(v or None), - "relays": lambda v: cfg.set_relays( - [r.strip() for r in v.split(",") if r.strip()], require_pin=False - ), - "kdf_iterations": lambda v: cfg.set_kdf_iterations(int(v)), - "kdf_mode": lambda v: cfg.set_kdf_mode(v), - "backup_interval": lambda v: cfg.set_backup_interval(float(v)), - "nostr_max_retries": lambda v: cfg.set_nostr_max_retries(int(v)), - "nostr_retry_delay": lambda v: cfg.set_nostr_retry_delay(float(v)), - "min_uppercase": lambda v: cfg.set_min_uppercase(int(v)), - "min_lowercase": lambda v: cfg.set_min_lowercase(int(v)), - "min_digits": lambda v: cfg.set_min_digits(int(v)), - "min_special": lambda v: cfg.set_min_special(int(v)), - "quick_unlock": lambda v: cfg.set_quick_unlock( - v.lower() in ("1", "true", "yes", "y", "on") - ), - "verbose_timing": lambda v: cfg.set_verbose_timing( - v.lower() in ("1", "true", "yes", "y", "on") - ), - } - - action = mapping.get(key) - if action is None: - typer.echo("Unknown key") - raise typer.Exit(code=1) + service = _get_config_service(ctx) try: - action(value) + val = ( + [r.strip() for r in value.split(",") if r.strip()] + if key == "relays" + else value + ) + service.set(key, val) + except KeyError: + typer.echo("Unknown key") + raise typer.Exit(code=1) except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error: {exc}") raise typer.Exit(code=1) @@ -499,12 +590,15 @@ def config_set(ctx: typer.Context, key: str, value: str) -> None: @config_app.command("toggle-secret-mode") def config_toggle_secret_mode(ctx: typer.Context) -> None: - """Interactively enable or disable secret mode.""" - pm = _get_pm(ctx) - cfg = pm.config_manager + """Interactively enable or disable secret mode. + + When enabled, newly generated and retrieved passwords are copied to the + clipboard instead of printed to the screen. + """ + service = _get_config_service(ctx) try: - enabled = cfg.get_secret_mode_enabled() - delay = cfg.get_clipboard_clear_delay() + enabled = service.get_secret_mode_enabled() + delay = service.get_clipboard_clear_delay() except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error loading settings: {exc}") raise typer.Exit(code=1) @@ -536,10 +630,7 @@ def config_toggle_secret_mode(ctx: typer.Context) -> None: raise typer.Exit(code=1) try: - cfg.set_secret_mode_enabled(enabled) - cfg.set_clipboard_clear_delay(delay) - pm.secret_mode_enabled = enabled - pm.clipboard_clear_delay = delay + service.set_secret_mode(enabled, delay) except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error: {exc}") raise typer.Exit(code=1) @@ -551,10 +642,9 @@ def config_toggle_secret_mode(ctx: typer.Context) -> None: @config_app.command("toggle-offline") def config_toggle_offline(ctx: typer.Context) -> None: """Enable or disable offline mode.""" - pm = _get_pm(ctx) - cfg = pm.config_manager + service = _get_config_service(ctx) try: - enabled = cfg.get_offline_mode() + enabled = service.get_offline_mode() except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error loading settings: {exc}") raise typer.Exit(code=1) @@ -573,8 +663,7 @@ def config_toggle_offline(ctx: typer.Context) -> None: enabled = False try: - cfg.set_offline_mode(enabled) - pm.offline_mode = enabled + service.set_offline_mode(enabled) except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error: {exc}") raise typer.Exit(code=1) @@ -586,52 +675,55 @@ def config_toggle_offline(ctx: typer.Context) -> None: @fingerprint_app.command("list") def fingerprint_list(ctx: typer.Context) -> None: """List available seed profiles.""" - pm = _get_pm(ctx) - for fp in pm.fingerprint_manager.list_fingerprints(): + _vault, profile_service, _sync = _get_services(ctx) + for fp in profile_service.list_profiles(): typer.echo(fp) @fingerprint_app.command("add") def fingerprint_add(ctx: typer.Context) -> None: """Create a new seed profile.""" - pm = _get_pm(ctx) - pm.add_new_fingerprint() + _vault, profile_service, _sync = _get_services(ctx) + profile_service.add_profile() @fingerprint_app.command("remove") def fingerprint_remove(ctx: typer.Context, fingerprint: str) -> None: """Remove a seed profile.""" - pm = _get_pm(ctx) - pm.fingerprint_manager.remove_fingerprint(fingerprint) + _vault, profile_service, _sync = _get_services(ctx) + profile_service.remove_profile(ProfileRemoveRequest(fingerprint=fingerprint)) @fingerprint_app.command("switch") def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None: """Switch to another seed profile.""" - pm = _get_pm(ctx) - pm.select_fingerprint(fingerprint) + _vault, profile_service, _sync = _get_services(ctx) + password = typer.prompt("Master password", hide_input=True) + profile_service.switch_profile( + ProfileSwitchRequest(fingerprint=fingerprint, password=password) + ) @util_app.command("generate-password") def generate_password(ctx: typer.Context, length: int = 24) -> None: """Generate a strong password.""" - pm = _get_pm(ctx) - password = pm.password_generator.generate_password(length) + service = _get_util_service(ctx) + password = service.generate_password(length) typer.echo(password) @util_app.command("verify-checksum") def verify_checksum(ctx: typer.Context) -> None: """Verify the SeedPass script checksum.""" - pm = _get_pm(ctx) - pm.handle_verify_checksum() + service = _get_util_service(ctx) + service.verify_checksum() @util_app.command("update-checksum") def update_checksum(ctx: typer.Context) -> None: """Regenerate the script checksum file.""" - pm = _get_pm(ctx) - pm.handle_update_script_checksum() + service = _get_util_service(ctx) + service.update_checksum() @api_app.command("start") @@ -657,5 +749,46 @@ def api_stop(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> N typer.echo(f"Failed to stop server: {exc}") +@app.command() +def gui() -> None: + """Launch the BeeWare GUI. + + If the platform specific backend is missing, attempt to install it and + retry launching the GUI. + """ + if not _gui_backend_available(): + if sys.platform.startswith("linux"): + pkg = "toga-gtk" + elif sys.platform == "win32": + pkg = "toga-winforms" + elif sys.platform == "darwin": + pkg = "toga-cocoa" + else: + typer.echo( + f"Unsupported platform '{sys.platform}' for BeeWare GUI.", + err=True, + ) + raise typer.Exit(1) + + typer.echo(f"Attempting to install {pkg} for GUI support...") + try: + subprocess.check_call([sys.executable, "-m", "pip", "install", pkg]) + typer.echo(f"Successfully installed {pkg}.") + except subprocess.CalledProcessError as exc: + typer.echo(f"Failed to install {pkg}: {exc}", err=True) + raise typer.Exit(1) + + if not _gui_backend_available(): + typer.echo( + "BeeWare GUI backend still unavailable after installation attempt.", + err=True, + ) + raise typer.Exit(1) + + from seedpass_gui.app import main + + main() + + if __name__ == "__main__": app() diff --git a/src/password_manager/__init__.py b/src/seedpass/core/__init__.py similarity index 81% rename from src/password_manager/__init__.py rename to src/seedpass/core/__init__.py index fd7cf15..4610c5e 100644 --- a/src/password_manager/__init__.py +++ b/src/seedpass/core/__init__.py @@ -1,10 +1,10 @@ -# password_manager/__init__.py +# seedpass.core/__init__.py """Expose password manager components with lazy imports.""" from importlib import import_module -__all__ = ["PasswordManager", "ConfigManager", "Vault", "EntryType"] +__all__ = ["PasswordManager", "ConfigManager", "Vault", "EntryType", "StateManager"] def __getattr__(name: str): @@ -16,4 +16,6 @@ def __getattr__(name: str): return import_module(".vault", __name__).Vault if name == "EntryType": return import_module(".entry_types", __name__).EntryType + if name == "StateManager": + return import_module(".state_manager", __name__).StateManager raise AttributeError(f"module '{__name__}' has no attribute '{name}'") diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py new file mode 100644 index 0000000..03e8d99 --- /dev/null +++ b/src/seedpass/core/api.py @@ -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() + ) diff --git a/src/password_manager/backup.py b/src/seedpass/core/backup.py similarity index 98% rename from src/password_manager/backup.py rename to src/seedpass/core/backup.py index 10da249..3431051 100644 --- a/src/password_manager/backup.py +++ b/src/seedpass/core/backup.py @@ -1,4 +1,4 @@ -# password_manager/backup.py +# seedpass.core/backup.py """ Backup Manager Module @@ -19,7 +19,7 @@ import traceback from pathlib import Path from termcolor import colored -from password_manager.config_manager import ConfigManager +from .config_manager import ConfigManager from utils.file_lock import exclusive_lock from constants import APP_DIR diff --git a/src/password_manager/config_manager.py b/src/seedpass/core/config_manager.py similarity index 96% rename from src/password_manager/config_manager.py rename to src/seedpass/core/config_manager.py index eb0e0cf..a474277 100644 --- a/src/password_manager/config_manager.py +++ b/src/seedpass/core/config_manager.py @@ -10,10 +10,10 @@ from utils.seed_prompt import masked_input import bcrypt -from password_manager.vault import Vault +from .vault import Vault from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS -from constants import INACTIVITY_TIMEOUT +from constants import INACTIVITY_TIMEOUT, MAX_RETRIES, RETRY_DELAY logger = logging.getLogger(__name__) @@ -52,8 +52,8 @@ class ConfigManager: "secret_mode_enabled": False, "clipboard_clear_delay": 45, "quick_unlock": False, - "nostr_max_retries": 2, - "nostr_retry_delay": 1.0, + "nostr_max_retries": MAX_RETRIES, + "nostr_retry_delay": float(RETRY_DELAY), "min_uppercase": 2, "min_lowercase": 2, "min_digits": 2, @@ -77,8 +77,8 @@ class ConfigManager: data.setdefault("secret_mode_enabled", False) data.setdefault("clipboard_clear_delay", 45) data.setdefault("quick_unlock", False) - data.setdefault("nostr_max_retries", 2) - data.setdefault("nostr_retry_delay", 1.0) + data.setdefault("nostr_max_retries", MAX_RETRIES) + data.setdefault("nostr_retry_delay", float(RETRY_DELAY)) data.setdefault("min_uppercase", 2) data.setdefault("min_lowercase", 2) data.setdefault("min_digits", 2) @@ -251,7 +251,7 @@ class ConfigManager: # Password policy settings def get_password_policy(self) -> "PasswordPolicy": """Return the password complexity policy.""" - from password_manager.password_generation import PasswordPolicy + from .password_generation import PasswordPolicy cfg = self.load_config(require_pin=False) return PasswordPolicy( @@ -303,7 +303,7 @@ class ConfigManager: def get_nostr_max_retries(self) -> int: """Retrieve the configured Nostr retry count.""" cfg = self.load_config(require_pin=False) - return int(cfg.get("nostr_max_retries", 2)) + return int(cfg.get("nostr_max_retries", MAX_RETRIES)) def set_nostr_retry_delay(self, delay: float) -> None: """Persist the delay between Nostr retry attempts.""" @@ -316,7 +316,7 @@ class ConfigManager: def get_nostr_retry_delay(self) -> float: """Retrieve the delay in seconds between Nostr retries.""" cfg = self.load_config(require_pin=False) - return float(cfg.get("nostr_retry_delay", 1.0)) + return float(cfg.get("nostr_retry_delay", float(RETRY_DELAY))) def set_verbose_timing(self, enabled: bool) -> None: cfg = self.load_config(require_pin=False) diff --git a/src/password_manager/encryption.py b/src/seedpass/core/encryption.py similarity index 93% rename from src/password_manager/encryption.py rename to src/seedpass/core/encryption.py index ae21416..1a71ced 100644 --- a/src/password_manager/encryption.py +++ b/src/seedpass/core/encryption.py @@ -1,4 +1,4 @@ -# /src/password_manager/encryption.py +# /src/seedpass.core/encryption.py import logging import traceback @@ -228,6 +228,7 @@ class EncryptionManager: relative_path: Optional[Path] = None, *, strict: bool = True, + merge: bool = False, ) -> bool: """Decrypts data from Nostr and saves it. @@ -249,6 +250,20 @@ class EncryptionManager: data = json_lib.loads(decrypted_data) else: data = json_lib.loads(decrypted_data.decode("utf-8")) + if merge and (self.fingerprint_dir / relative_path).exists(): + current = self.load_json_data(relative_path) + current_entries = current.get("entries", {}) + for idx, entry in data.get("entries", {}).items(): + cur_ts = current_entries.get(idx, {}).get("modified_ts", 0) + new_ts = entry.get("modified_ts", 0) + if idx not in current_entries or new_ts >= cur_ts: + current_entries[idx] = entry + current["entries"] = current_entries + if "schema_version" in data: + current["schema_version"] = max( + current.get("schema_version", 0), data.get("schema_version", 0) + ) + data = current self.save_json_data(data, relative_path) # This always saves in V2 format self.update_checksum(relative_path) logger.info("Index file from Nostr was processed and saved successfully.") diff --git a/src/password_manager/entry_management.py b/src/seedpass/core/entry_management.py similarity index 92% rename from src/password_manager/entry_management.py rename to src/seedpass/core/entry_management.py index b779580..260fe17 100644 --- a/src/password_manager/entry_management.py +++ b/src/seedpass/core/entry_management.py @@ -1,4 +1,4 @@ -# password_manager/entry_management.py +# seedpass.core/entry_management.py """ Entry Management Module @@ -27,18 +27,19 @@ import logging import hashlib import sys import shutil +import time from typing import Optional, Tuple, Dict, Any, List from pathlib import Path from termcolor import colored -from password_manager.migrations import LATEST_VERSION -from password_manager.entry_types import EntryType -from password_manager.totp import TotpManager +from .migrations import LATEST_VERSION +from .entry_types import EntryType +from .totp import TotpManager from utils.fingerprint import generate_fingerprint from utils.checksum import canonical_json_dumps -from password_manager.vault import Vault -from password_manager.backup import BackupManager +from .vault import Vault +from .backup import BackupManager # Instantiate the logger @@ -97,6 +98,7 @@ class EntryManager: entry["word_count"] = entry["words"] entry.pop("words", None) entry.setdefault("tags", []) + entry.setdefault("modified_ts", entry.get("updated", 0)) logger.debug("Index loaded successfully.") self._index_cache = data return data @@ -176,6 +178,7 @@ class EntryManager: "type": EntryType.PASSWORD.value, "kind": EntryType.PASSWORD.value, "notes": notes, + "modified_ts": int(time.time()), "custom_fields": custom_fields or [], "tags": tags or [], } @@ -236,6 +239,7 @@ class EntryManager: "type": EntryType.TOTP.value, "kind": EntryType.TOTP.value, "label": label, + "modified_ts": int(time.time()), "index": index, "period": period, "digits": digits, @@ -249,6 +253,7 @@ class EntryManager: "kind": EntryType.TOTP.value, "label": label, "secret": secret, + "modified_ts": int(time.time()), "period": period, "digits": digits, "archived": archived, @@ -294,6 +299,7 @@ class EntryManager: "kind": EntryType.SSH.value, "index": index, "label": label, + "modified_ts": int(time.time()), "notes": notes, "archived": archived, "tags": tags or [], @@ -312,7 +318,7 @@ class EntryManager: if not entry or (etype != EntryType.SSH.value and kind != EntryType.SSH.value): raise ValueError("Entry is not an SSH key entry") - from password_manager.password_generation import derive_ssh_key_pair + from .password_generation import derive_ssh_key_pair key_index = int(entry.get("index", index)) return derive_ssh_key_pair(parent_seed, key_index) @@ -340,6 +346,7 @@ class EntryManager: "kind": EntryType.PGP.value, "index": index, "label": label, + "modified_ts": int(time.time()), "key_type": key_type, "user_id": user_id, "notes": notes, @@ -360,7 +367,7 @@ class EntryManager: if not entry or (etype != EntryType.PGP.value and kind != EntryType.PGP.value): raise ValueError("Entry is not a PGP key entry") - from password_manager.password_generation import derive_pgp_key + from .password_generation import derive_pgp_key from local_bip85.bip85 import BIP85 from bip_utils import Bip39SeedGenerator @@ -392,6 +399,7 @@ class EntryManager: "kind": EntryType.NOSTR.value, "index": index, "label": label, + "modified_ts": int(time.time()), "notes": notes, "archived": archived, "tags": tags or [], @@ -421,6 +429,7 @@ class EntryManager: "type": EntryType.KEY_VALUE.value, "kind": EntryType.KEY_VALUE.value, "label": label, + "modified_ts": int(time.time()), "value": value, "notes": notes, "archived": archived, @@ -480,6 +489,7 @@ class EntryManager: "kind": EntryType.SEED.value, "index": index, "label": label, + "modified_ts": int(time.time()), "word_count": words_num, "notes": notes, "archived": archived, @@ -501,7 +511,7 @@ class EntryManager: ): raise ValueError("Entry is not a seed entry") - from password_manager.password_generation import derive_seed_phrase + from .password_generation import derive_seed_phrase from local_bip85.bip85 import BIP85 from bip_utils import Bip39SeedGenerator @@ -530,7 +540,7 @@ class EntryManager: if index is None: index = self.get_next_index() - from password_manager.password_generation import derive_seed_phrase + from .password_generation import derive_seed_phrase from local_bip85.bip85 import BIP85 from bip_utils import Bip39SeedGenerator @@ -552,6 +562,7 @@ class EntryManager: "kind": EntryType.MANAGED_ACCOUNT.value, "index": index, "label": label, + "modified_ts": int(time.time()), "word_count": word_count, "notes": notes, "fingerprint": fingerprint, @@ -576,7 +587,7 @@ class EntryManager: ): raise ValueError("Entry is not a managed account entry") - from password_manager.password_generation import derive_seed_phrase + from .password_generation import derive_seed_phrase from local_bip85.bip85 import BIP85 from bip_utils import Bip39SeedGenerator @@ -682,7 +693,8 @@ class EntryManager: ): entry.setdefault("custom_fields", []) logger.debug(f"Retrieved entry at index {index}: {entry}") - return entry + clean = {k: v for k, v in entry.items() if k != "modified_ts"} + return clean else: logger.warning(f"No entry found at index {index}.") print(colored(f"Warning: No entry found at index {index}.", "yellow")) @@ -887,6 +899,8 @@ class EntryManager: entry["tags"] = tags logger.debug(f"Updated tags for index {index}: {tags}") + entry["modified_ts"] = int(time.time()) + data["entries"][str(index)] = entry logger.debug(f"Modified entry at index {index}: {entry}") @@ -922,10 +936,17 @@ class EntryManager: include_archived: bool = False, verbose: bool = True, ) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]: - """List entries in the index with optional sorting and filtering. + """List entries sorted and filtered according to the provided options. - By default archived entries are omitted unless ``include_archived`` is - ``True``. + Parameters + ---------- + sort_by: + Field to sort by. Supported values are ``"index"``, ``"label"`` and + ``"updated"``. + filter_kind: + Optional entry kind to restrict the results. + + Archived entries are omitted unless ``include_archived`` is ``True``. """ try: data = self._load_index() @@ -941,11 +962,14 @@ class EntryManager: idx_str, entry = item if sort_by == "index": return int(idx_str) - if sort_by in {"website", "label"}: + if sort_by == "label": + # labels are stored in the index so no additional + # decryption is required when sorting return entry.get("label", entry.get("website", "")).lower() - if sort_by == "username": - return entry.get("username", "").lower() - raise ValueError("sort_by must be 'index', 'label', or 'username'") + if sort_by == "updated": + # sort newest first + return -int(entry.get("updated", 0)) + raise ValueError("sort_by must be 'index', 'label', or 'updated'") sorted_items = sorted(entries_data.items(), key=sort_key) @@ -1045,9 +1069,10 @@ class EntryManager: return [] def search_entries( - self, query: str + self, query: str, kinds: List[str] | None = None ) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]: - """Return entries matching the query across common fields.""" + """Return entries matching ``query`` across whitelisted metadata fields.""" + data = self._load_index() entries_data = data.get("entries", {}) @@ -1059,74 +1084,33 @@ class EntryManager: for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])): etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) + + if kinds is not None and etype not in kinds: + continue + label = entry.get("label", entry.get("website", "")) - notes = entry.get("notes", "") + username = ( + entry.get("username", "") if etype == EntryType.PASSWORD.value else None + ) + url = entry.get("url", "") if etype == EntryType.PASSWORD.value else None tags = entry.get("tags", []) + archived = entry.get("archived", entry.get("blacklisted", False)) + label_match = query_lower in label.lower() - notes_match = query_lower in notes.lower() + username_match = bool(username) and query_lower in username.lower() + url_match = bool(url) and query_lower in url.lower() tags_match = any(query_lower in str(t).lower() for t in tags) - if etype == EntryType.PASSWORD.value: - username = entry.get("username", "") - url = entry.get("url", "") - custom_fields = entry.get("custom_fields", []) - custom_match = any( - query_lower in str(cf.get("label", "")).lower() - or query_lower in str(cf.get("value", "")).lower() - for cf in custom_fields + if label_match or username_match or url_match or tags_match: + results.append( + ( + int(idx), + label, + username if username is not None else None, + url if url is not None else None, + archived, + ) ) - if ( - label_match - or query_lower in username.lower() - or query_lower in url.lower() - or notes_match - or custom_match - or tags_match - ): - results.append( - ( - int(idx), - label, - username, - url, - entry.get("archived", entry.get("blacklisted", False)), - ) - ) - elif etype in (EntryType.KEY_VALUE.value, EntryType.MANAGED_ACCOUNT.value): - value_field = str(entry.get("value", "")) - custom_fields = entry.get("custom_fields", []) - custom_match = any( - query_lower in str(cf.get("label", "")).lower() - or query_lower in str(cf.get("value", "")).lower() - for cf in custom_fields - ) - if ( - label_match - or query_lower in value_field.lower() - or notes_match - or custom_match - or tags_match - ): - results.append( - ( - int(idx), - label, - None, - None, - entry.get("archived", entry.get("blacklisted", False)), - ) - ) - else: - if label_match or notes_match or tags_match: - results.append( - ( - int(idx), - label, - None, - None, - entry.get("archived", entry.get("blacklisted", False)), - ) - ) return results diff --git a/src/password_manager/entry_types.py b/src/seedpass/core/entry_types.py similarity index 91% rename from src/password_manager/entry_types.py rename to src/seedpass/core/entry_types.py index da5bd15..a11643a 100644 --- a/src/password_manager/entry_types.py +++ b/src/seedpass/core/entry_types.py @@ -1,4 +1,4 @@ -# password_manager/entry_types.py +# seedpass.core/entry_types.py """Enumerations for entry types used by SeedPass.""" from enum import Enum diff --git a/src/password_manager/manager.py b/src/seedpass/core/manager.py similarity index 93% rename from src/password_manager/manager.py rename to src/seedpass/core/manager.py index c3c2205..0af2fb8 100644 --- a/src/password_manager/manager.py +++ b/src/seedpass/core/manager.py @@ -1,4 +1,4 @@ -# password_manager/manager.py +# seedpass.core/manager.py """ Password Manager Module @@ -25,14 +25,15 @@ from termcolor import colored from utils.color_scheme import color_text from utils.input_utils import timed_input -from password_manager.encryption import EncryptionManager -from password_manager.entry_management import EntryManager -from password_manager.password_generation import PasswordGenerator -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.portable_backup import export_backup, import_backup -from password_manager.totp import TotpManager -from password_manager.entry_types import EntryType +from .encryption import EncryptionManager +from .entry_management import EntryManager +from .password_generation import PasswordGenerator +from .backup import BackupManager +from .vault import Vault +from .portable_backup import export_backup, import_backup +from .totp import TotpManager +from .entry_types import EntryType +from .pubsub import bus from utils.key_derivation import ( derive_key_from_parent_seed, derive_key_from_password, @@ -64,7 +65,7 @@ from utils.terminal_utils import ( ) from utils.fingerprint import generate_fingerprint from constants import MIN_HEALTHY_RELAYS -from password_manager.migrations import LATEST_VERSION +from .migrations import LATEST_VERSION from constants import ( APP_DIR, @@ -94,7 +95,8 @@ from utils.fingerprint_manager import FingerprintManager # Import NostrClient from nostr.client import NostrClient, DEFAULT_RELAYS -from password_manager.config_manager import ConfigManager +from .config_manager import ConfigManager +from .state_manager import StateManager # Instantiate the logger logger = logging.getLogger(__name__) @@ -108,6 +110,24 @@ class Notification: level: str = "INFO" +class AuthGuard: + """Helper to enforce inactivity timeouts.""" + + def __init__( + self, manager: "PasswordManager", time_fn: callable = time.time + ) -> None: + self.manager = manager + self._time_fn = time_fn + + def check_timeout(self) -> None: + """Lock the vault if the inactivity timeout has been exceeded.""" + timeout = getattr(self.manager, "inactivity_timeout", 0) + if self.manager.locked or timeout <= 0: + return + if self._time_fn() - self.manager.last_activity > timeout: + self.manager.lock_vault() + + class PasswordManager: """ PasswordManager Class @@ -117,7 +137,9 @@ class PasswordManager: verification, ensuring the integrity and confidentiality of the stored password database. """ - def __init__(self, fingerprint: Optional[str] = None) -> None: + def __init__( + self, fingerprint: Optional[str] = None, *, password: Optional[str] = None + ) -> None: """Initialize the PasswordManager. Parameters @@ -138,6 +160,7 @@ class PasswordManager: self.bip85: Optional[BIP85] = None self.nostr_client: Optional[NostrClient] = None self.config_manager: Optional[ConfigManager] = None + self.state_manager: Optional[StateManager] = None self.notifications: queue.Queue[Notification] = queue.Queue() self._current_notification: Optional[Notification] = None self._notification_expiry: float = 0.0 @@ -155,13 +178,16 @@ class PasswordManager: self.last_unlock_duration: float | None = None self.verbose_timing: bool = False self._suppress_entry_actions_menu: bool = False + self.last_bip85_idx: int = 0 + self.last_sync_ts: int = 0 + self.auth_guard = AuthGuard(self) # Initialize the fingerprint manager first self.initialize_fingerprint_manager() if fingerprint: # Load the specified profile without prompting - self.select_fingerprint(fingerprint) + self.select_fingerprint(fingerprint, password=password) else: # Ensure a parent seed is set up before accessing the fingerprint directory self.setup_parent_seed() @@ -187,6 +213,11 @@ class PasswordManager: ) ) + @staticmethod + def get_password_prompt() -> str: + """Return the standard prompt for requesting a master password.""" + return "Enter your master password: " + @property def parent_seed(self) -> Optional[str]: """Return the decrypted parent seed if set.""" @@ -229,7 +260,12 @@ class PasswordManager: return (None, parent_fp, self.current_fingerprint) def update_activity(self) -> None: - """Record the current time as the last user activity.""" + """Record activity and enforce inactivity timeout.""" + guard = getattr(self, "auth_guard", None) + if guard is None: + guard = AuthGuard(self) + self.auth_guard = guard + guard.check_timeout() self.last_activity = time.time() def notify(self, message: str, level: str = "INFO") -> None: @@ -268,26 +304,35 @@ class PasswordManager: self.nostr_client = None self.config_manager = None self.locked = True + bus.publish("vault_locked") - def unlock_vault(self) -> None: - """Prompt for password and reinitialize managers.""" + def unlock_vault(self, password: Optional[str] = None) -> float: + """Unlock the vault using the provided ``password``. + + Parameters + ---------- + password: + Master password for the active profile. + + Returns + ------- + float + Duration of the unlock process in seconds. + """ start = time.perf_counter() if not self.fingerprint_dir: raise ValueError("Fingerprint directory not set") - self.setup_encryption_manager(self.fingerprint_dir) + if password is None: + password = prompt_existing_password(self.get_password_prompt()) + self.setup_encryption_manager(self.fingerprint_dir, password) self.initialize_bip85() self.initialize_managers() self.locked = False self.update_activity() self.last_unlock_duration = time.perf_counter() - start - print( - colored( - f"Vault unlocked in {self.last_unlock_duration:.2f} seconds", - "yellow", - ) - ) if getattr(self, "verbose_timing", False): logger.info("Vault unlocked in %.2f seconds", self.last_unlock_duration) + return self.last_unlock_duration def initialize_fingerprint_manager(self): """ @@ -394,7 +439,9 @@ class PasswordManager: print(colored(f"Error: Failed to add new seed profile: {e}", "red")) sys.exit(1) - def select_fingerprint(self, fingerprint: str) -> None: + def select_fingerprint( + self, fingerprint: str, *, password: Optional[str] = None + ) -> None: if self.fingerprint_manager.select_fingerprint(fingerprint): self.current_fingerprint = fingerprint # Add this line self.fingerprint_dir = ( @@ -409,7 +456,7 @@ class PasswordManager: ) sys.exit(1) # Setup the encryption manager and load parent seed - self.setup_encryption_manager(self.fingerprint_dir) + self.setup_encryption_manager(self.fingerprint_dir, password) # Initialize BIP85 and other managers self.initialize_bip85() self.initialize_managers() @@ -531,7 +578,7 @@ class PasswordManager: print(colored(f"Error: Failed to load parent seed: {e}", "red")) sys.exit(1) - def handle_switch_fingerprint(self) -> bool: + def handle_switch_fingerprint(self, *, password: Optional[str] = None) -> bool: """ Handles switching to a different seed profile. @@ -572,9 +619,10 @@ class PasswordManager: return False # Return False to indicate failure # Prompt for master password for the selected seed profile - password = prompt_existing_password( - "Enter the master password for the selected seed profile: " - ) + if password is None: + password = prompt_existing_password( + "Enter the master password for the selected seed profile: " + ) # Set up the encryption manager with the new password and seed profile directory if not self.setup_encryption_manager( @@ -596,6 +644,17 @@ class PasswordManager: config_manager=getattr(self, "config_manager", None), parent_seed=getattr(self, "parent_seed", None), ) + if getattr(self, "manifest_id", None): + from nostr.backup_models import Manifest + + with self.nostr_client._state_lock: + self.nostr_client.current_manifest_id = self.manifest_id + self.nostr_client.current_manifest = Manifest( + ver=1, + algo="gzip", + chunks=[], + delta_since=self.delta_since or None, + ) logging.info( f"NostrClient re-initialized with seed profile {self.current_fingerprint}." ) @@ -661,14 +720,14 @@ class PasswordManager: self.update_activity() self.start_background_sync() - def handle_existing_seed(self) -> None: + def handle_existing_seed(self, *, password: Optional[str] = None) -> None: """ Handles the scenario where an existing parent seed file is found. Prompts the user for the master password to decrypt the seed. """ try: - # Prompt for password using masked input - password = prompt_existing_password("Enter your login password: ") + if password is None: + password = prompt_existing_password("Enter your login password: ") # Derive encryption key from password iterations = ( @@ -763,7 +822,11 @@ class PasswordManager: sys.exit(1) def setup_existing_seed( - self, method: Literal["paste", "words"] = "paste" + self, + method: Literal["paste", "words"] = "paste", + *, + seed: Optional[str] = None, + password: Optional[str] = None, ) -> Optional[str]: """Prompt for an existing BIP-85 seed and set it up. @@ -779,7 +842,9 @@ class PasswordManager: The fingerprint if setup is successful, ``None`` otherwise. """ try: - if method == "words": + if seed is not None: + parent_seed = seed + elif method == "words": parent_seed = prompt_seed_words() else: parent_seed = masked_input("Enter your 12-word BIP-85 seed: ").strip() @@ -789,17 +854,21 @@ class PasswordManager: print(colored("Error: Invalid BIP-85 seed phrase.", "red")) sys.exit(1) - return self._finalize_existing_seed(parent_seed) + return self._finalize_existing_seed(parent_seed, password=password) except KeyboardInterrupt: logging.info("Operation cancelled by user.") self.notify("Operation cancelled by user.", level="WARNING") sys.exit(0) - def setup_existing_seed_word_by_word(self) -> Optional[str]: + def setup_existing_seed_word_by_word( + self, *, seed: Optional[str] = None, password: Optional[str] = None + ) -> Optional[str]: """Prompt for an existing seed one word at a time and set it up.""" - return self.setup_existing_seed(method="words") + return self.setup_existing_seed(method="words", seed=seed, password=password) - def _finalize_existing_seed(self, parent_seed: str) -> Optional[str]: + def _finalize_existing_seed( + self, parent_seed: str, *, password: Optional[str] = None + ) -> Optional[str]: """Common logic for initializing an existing seed.""" if self.validate_bip85_seed(parent_seed): fingerprint = self.fingerprint_manager.add_fingerprint(parent_seed) @@ -827,7 +896,8 @@ class PasswordManager: logging.info(f"Current seed profile set to {fingerprint}") try: - password = prompt_for_password() + if password is None: + password = prompt_for_password() index_key = derive_index_key(parent_seed) iterations = ( self.config_manager.get_kdf_iterations() @@ -961,7 +1031,9 @@ class PasswordManager: print(colored(f"Error: Failed to generate BIP-85 seed: {e}", "red")) sys.exit(1) - def save_and_encrypt_seed(self, seed: str, fingerprint_dir: Path) -> None: + def save_and_encrypt_seed( + self, seed: str, fingerprint_dir: Path, *, password: Optional[str] = None + ) -> None: """ Saves and encrypts the parent seed. @@ -973,8 +1045,8 @@ class PasswordManager: # Set self.fingerprint_dir self.fingerprint_dir = fingerprint_dir - # Prompt for password - password = prompt_for_password() + if password is None: + password = prompt_for_password() index_key = derive_index_key(seed) iterations = ( @@ -1042,6 +1114,7 @@ class PasswordManager: vault=self.vault, fingerprint_dir=self.fingerprint_dir, ) + self.state_manager = StateManager(self.fingerprint_dir) self.backup_manager = BackupManager( fingerprint_dir=self.fingerprint_dir, config_manager=self.config_manager, @@ -1060,7 +1133,19 @@ class PasswordManager: # Load relay configuration and initialize NostrClient config = self.config_manager.load_config() - relay_list = config.get("relays", list(DEFAULT_RELAYS)) + if getattr(self, "state_manager", None) is not None: + state = self.state_manager.state + relay_list = state.get("relays", list(DEFAULT_RELAYS)) + self.last_bip85_idx = state.get("last_bip85_idx", 0) + self.last_sync_ts = state.get("last_sync_ts", 0) + self.manifest_id = state.get("manifest_id") + self.delta_since = state.get("delta_since", 0) + else: + relay_list = list(DEFAULT_RELAYS) + self.last_bip85_idx = 0 + self.last_sync_ts = 0 + self.manifest_id = None + self.delta_since = 0 self.offline_mode = bool(config.get("offline_mode", False)) self.inactivity_timeout = config.get( "inactivity_timeout", INACTIVITY_TIMEOUT @@ -1079,6 +1164,18 @@ class PasswordManager: parent_seed=getattr(self, "parent_seed", None), ) + if getattr(self, "manifest_id", None): + from nostr.backup_models import Manifest + + with self.nostr_client._state_lock: + self.nostr_client.current_manifest_id = self.manifest_id + self.nostr_client.current_manifest = Manifest( + ver=1, + algo="gzip", + chunks=[], + delta_since=self.delta_since or None, + ) + logger.debug("Managers re-initialized for the new fingerprint.") except Exception as e: @@ -1086,45 +1183,77 @@ class PasswordManager: print(colored(f"Error: Failed to initialize managers: {e}", "red")) sys.exit(1) - def sync_index_from_nostr(self) -> None: + async def sync_index_from_nostr_async(self) -> None: """Always fetch the latest vault data from Nostr and update the local index.""" start = time.perf_counter() try: - result = asyncio.run(self.nostr_client.fetch_latest_snapshot()) + result = await self.nostr_client.fetch_latest_snapshot() if not result: + if self.nostr_client.last_error: + logger.warning( + "Unable to fetch latest snapshot from Nostr relays %s: %s", + self.nostr_client.relays, + self.nostr_client.last_error, + ) + self.notify( + f"Sync failed: {self.nostr_client.last_error}", + level="WARNING", + ) return manifest, chunks = result encrypted = gzip.decompress(b"".join(chunks)) - if manifest.delta_since: - version = int(manifest.delta_since) - deltas = asyncio.run(self.nostr_client.fetch_deltas_since(version)) - if deltas: - encrypted = deltas[-1] current = self.vault.get_encrypted_index() + updated = False if current != encrypted: if self.vault.decrypt_and_save_index_from_nostr( - encrypted, strict=False + encrypted, strict=False, merge=False ): - logger.info("Local database synchronized from Nostr.") + updated = True + current = encrypted + if manifest.delta_since: + version = int(manifest.delta_since) + deltas = await self.nostr_client.fetch_deltas_since(version) + for delta in deltas: + if current != delta: + if self.vault.decrypt_and_save_index_from_nostr( + delta, strict=False, merge=True + ): + updated = True + current = delta + if updated: + logger.info("Local database synchronized from Nostr.") except Exception as e: - logger.warning(f"Unable to sync index from Nostr: {e}") + logger.warning( + "Unable to sync index from Nostr relays %s: %s", + self.nostr_client.relays, + e, + ) + if self.nostr_client.last_error: + logger.warning( + "NostrClient last error: %s", self.nostr_client.last_error + ) + self.notify( + f"Sync failed: {self.nostr_client.last_error or e}", + level="WARNING", + ) finally: if getattr(self, "verbose_timing", False): duration = time.perf_counter() - start logger.info("sync_index_from_nostr completed in %.2f seconds", duration) + def sync_index_from_nostr(self) -> None: + asyncio.run(self.sync_index_from_nostr_async()) + def start_background_sync(self) -> None: """Launch a thread to synchronize the vault without blocking the UI.""" if getattr(self, "offline_mode", False): return - if ( - hasattr(self, "_sync_thread") - and self._sync_thread - and self._sync_thread.is_alive() + if getattr(self, "_sync_task", None) and not getattr( + self._sync_task, "done", True ): return - def _worker() -> None: + async def _worker() -> None: try: if hasattr(self, "nostr_client") and hasattr(self, "vault"): self.attempt_initial_sync() @@ -1133,8 +1262,12 @@ class PasswordManager: except Exception as exc: logger.warning(f"Background sync failed: {exc}") - self._sync_thread = threading.Thread(target=_worker, daemon=True) - self._sync_thread.start() + try: + loop = asyncio.get_running_loop() + except RuntimeError: + threading.Thread(target=lambda: asyncio.run(_worker()), daemon=True).start() + else: + self._sync_task = asyncio.create_task(_worker()) def start_background_relay_check(self) -> None: """Check relay health in a background thread.""" @@ -1170,13 +1303,26 @@ class PasswordManager: def _worker() -> None: try: - self.sync_vault(alt_summary=alt_summary) + bus.publish("sync_started") + result = asyncio.run(self.sync_vault_async(alt_summary=alt_summary)) + bus.publish("sync_finished", result) except Exception as exc: logging.error(f"Background vault sync failed: {exc}", exc_info=True) - threading.Thread(target=_worker, daemon=True).start() + try: + loop = asyncio.get_running_loop() + except RuntimeError: + threading.Thread(target=_worker, daemon=True).start() + else: - def attempt_initial_sync(self) -> bool: + async def _async_worker() -> None: + bus.publish("sync_started") + result = await self.sync_vault_async(alt_summary=alt_summary) + bus.publish("sync_finished", result) + + asyncio.create_task(_async_worker()) + + async def attempt_initial_sync_async(self) -> bool: """Attempt to download the initial vault snapshot from Nostr. Returns ``True`` if the snapshot was successfully downloaded and the @@ -1190,21 +1336,26 @@ class PasswordManager: have_data = False start = time.perf_counter() try: - result = asyncio.run(self.nostr_client.fetch_latest_snapshot()) + result = await self.nostr_client.fetch_latest_snapshot() if result: manifest, chunks = result encrypted = gzip.decompress(b"".join(chunks)) - if manifest.delta_since: - version = int(manifest.delta_since) - deltas = asyncio.run(self.nostr_client.fetch_deltas_since(version)) - if deltas: - encrypted = deltas[-1] success = self.vault.decrypt_and_save_index_from_nostr( - encrypted, strict=False + encrypted, strict=False, merge=False ) if success: - logger.info("Initialized local database from Nostr.") have_data = True + current = encrypted + if manifest.delta_since: + version = int(manifest.delta_since) + deltas = await self.nostr_client.fetch_deltas_since(version) + for delta in deltas: + if current != delta: + if self.vault.decrypt_and_save_index_from_nostr( + delta, strict=False, merge=True + ): + current = delta + logger.info("Initialized local database from Nostr.") except Exception as e: # pragma: no cover - network errors logger.warning(f"Unable to sync index from Nostr: {e}") finally: @@ -1214,17 +1365,23 @@ class PasswordManager: return have_data + def attempt_initial_sync(self) -> bool: + return asyncio.run(self.attempt_initial_sync_async()) + def sync_index_from_nostr_if_missing(self) -> None: """Retrieve the password database from Nostr if it doesn't exist locally. If no valid data is found or decryption fails, initialize a fresh local database and publish it to Nostr. """ - success = self.attempt_initial_sync() + asyncio.run(self.sync_index_from_nostr_if_missing_async()) + + async def sync_index_from_nostr_if_missing_async(self) -> None: + success = await self.attempt_initial_sync_async() if not success: self.vault.save_index({"schema_version": LATEST_VERSION, "entries": {}}) try: - self.sync_vault() + await self.sync_vault_async() except Exception as exc: # pragma: no cover - best effort logger.warning(f"Unable to publish fresh database: {exc}") @@ -1309,7 +1466,16 @@ class PasswordManager: "green", ) ) - print(colored(f"Password for {website_name}: {password}\n", "yellow")) + if self.secret_mode_enabled: + copy_to_clipboard(password, self.clipboard_clear_delay) + print( + colored( + f"[+] Password copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored(f"Password for {website_name}: {password}\n", "yellow")) # Automatically push the updated encrypted index to Nostr so the # latest changes are backed up remotely. @@ -1567,7 +1733,7 @@ class PasswordManager: print(colored("Seed Phrase:", "cyan")) print(color_text(phrase, "deterministic")) if confirm_action("Show Compact Seed QR? (Y/N): "): - from password_manager.seedqr import encode_seedqr + from .seedqr import encode_seedqr TotpManager.print_qr_code(encode_seedqr(phrase)) try: @@ -1829,7 +1995,7 @@ class PasswordManager: else: print(color_text(seed, "deterministic")) if confirm_action("Show Compact Seed QR? (Y/N): "): - from password_manager.seedqr import encode_seedqr + from .seedqr import encode_seedqr TotpManager.print_qr_code(encode_seedqr(seed)) try: @@ -2063,7 +2229,7 @@ class PasswordManager: ) print(color_text(seed, "deterministic")) - from password_manager.seedqr import encode_seedqr + from .seedqr import encode_seedqr TotpManager.print_qr_code(encode_seedqr(seed)) pause() @@ -3502,7 +3668,7 @@ class PasswordManager: :param encrypted_data: The encrypted data retrieved from Nostr. """ try: - self.vault.decrypt_and_save_index_from_nostr(encrypted_data) + self.vault.decrypt_and_save_index_from_nostr(encrypted_data, merge=True) logging.info("Index file updated from Nostr successfully.") print(colored("Index file updated from Nostr successfully.", "green")) except Exception as e: @@ -3517,7 +3683,7 @@ class PasswordManager: # Re-raise the exception to inform the calling function of the failure raise - def sync_vault( + async def sync_vault_async( self, alt_summary: str | None = None ) -> dict[str, list[str] | str] | None: """Publish the current vault contents to Nostr and return event IDs.""" @@ -3532,7 +3698,7 @@ class PasswordManager: event_id = None if callable(pub_snap): if asyncio.iscoroutinefunction(pub_snap): - manifest, event_id = asyncio.run(pub_snap(encrypted)) + manifest, event_id = await pub_snap(encrypted) else: manifest, event_id = pub_snap(encrypted) else: @@ -3544,7 +3710,15 @@ class PasswordManager: chunk_ids: list[str] = [] if manifest is not None: chunk_ids = [c.event_id for c in manifest.chunks if c.event_id] - delta_ids = getattr(self.nostr_client, "_delta_events", []) + delta_ids = self.nostr_client.get_delta_events() + if manifest is not None and self.state_manager is not None: + ts = manifest.delta_since or int(time.time()) + self.state_manager.update_state( + manifest_id=event_id, + delta_since=ts, + last_sync_ts=ts, + ) + self.last_sync_ts = ts return { "manifest_id": event_id, "chunk_ids": chunk_ids, @@ -3554,6 +3728,11 @@ class PasswordManager: logging.error(f"Failed to sync vault: {e}", exc_info=True) return None + def sync_vault( + self, alt_summary: str | None = None + ) -> dict[str, list[str] | str] | None: + return asyncio.run(self.sync_vault_async(alt_summary=alt_summary)) + def backup_database(self) -> None: """ Creates a backup of the encrypted JSON index file. @@ -3696,7 +3875,9 @@ class PasswordManager: print(colored(f"Error: Failed to export 2FA codes: {e}", "red")) return None - def handle_backup_reveal_parent_seed(self, file: Path | None = None) -> None: + def handle_backup_reveal_parent_seed( + self, file: Path | None = None, *, password: Optional[str] = None + ) -> None: """Reveal the parent seed and optionally save an encrypted backup. Parameters @@ -3726,9 +3907,10 @@ class PasswordManager: ) # Verify user's identity with secure password verification - password = prompt_existing_password( - "Enter your master password to continue: " - ) + if password is None: + password = prompt_existing_password( + "Enter your master password to continue: " + ) if not self.verify_password(password): print(colored("Incorrect password. Operation aborted.", "red")) return @@ -3872,15 +4054,11 @@ class PasswordManager: print(colored(f"Error: Failed to store hashed password: {e}", "red")) raise - def change_password(self) -> None: + def change_password(self, old_password: str, new_password: str) -> None: """Change the master password used for encryption.""" try: - current = prompt_existing_password("Enter your current master password: ") - if not self.verify_password(current): - print(colored("Incorrect password.", "red")) - return - - new_password = prompt_for_password() + if not self.verify_password(old_password): + raise ValueError("Incorrect password") # Load data with existing encryption manager index_data = self.vault.load_index() @@ -3906,7 +4084,11 @@ class PasswordManager: self.password_generator.encryption_manager = new_enc_mgr self.store_hashed_password(new_password) - relay_list = config_data.get("relays", list(DEFAULT_RELAYS)) + if getattr(self, "state_manager", None) is not None: + state = self.state_manager.state + relay_list = state.get("relays", list(DEFAULT_RELAYS)) + else: + relay_list = list(DEFAULT_RELAYS) self.nostr_client = NostrClient( encryption_manager=self.encryption_manager, fingerprint=self.current_fingerprint, @@ -3915,7 +4097,17 @@ class PasswordManager: parent_seed=getattr(self, "parent_seed", None), ) - print(colored("Master password changed successfully.", "green")) + if getattr(self, "manifest_id", None): + from nostr.backup_models import Manifest + + with self.nostr_client._state_lock: + self.nostr_client.current_manifest_id = self.manifest_id + self.nostr_client.current_manifest = Manifest( + ver=1, + algo="gzip", + chunks=[], + delta_since=self.delta_since or None, + ) # Push a fresh backup to Nostr so the newly encrypted index is # stored remotely. Include a tag to mark the password change. @@ -3928,7 +4120,7 @@ class PasswordManager: ) except Exception as e: logging.error(f"Failed to change password: {e}", exc_info=True) - print(colored(f"Error: Failed to change password: {e}", "red")) + raise def get_profile_stats(self) -> dict: """Return various statistics about the current seed profile.""" @@ -3990,13 +4182,11 @@ class PasswordManager: ) # Nostr sync info - manifest = getattr(self.nostr_client, "current_manifest", None) + manifest = self.nostr_client.get_current_manifest() if manifest is not None: stats["chunk_count"] = len(manifest.chunks) stats["delta_since"] = manifest.delta_since - stats["pending_deltas"] = len( - getattr(self.nostr_client, "_delta_events", []) - ) + stats["pending_deltas"] = len(self.nostr_client.get_delta_events()) else: stats["chunk_count"] = 0 stats["delta_since"] = None diff --git a/src/password_manager/migrations.py b/src/seedpass/core/migrations.py similarity index 100% rename from src/password_manager/migrations.py rename to src/seedpass/core/migrations.py diff --git a/src/password_manager/password_generation.py b/src/seedpass/core/password_generation.py similarity index 99% rename from src/password_manager/password_generation.py rename to src/seedpass/core/password_generation.py index b61523f..a70ab0b 100644 --- a/src/password_manager/password_generation.py +++ b/src/seedpass/core/password_generation.py @@ -1,4 +1,4 @@ -# password_manager/password_generation.py +# seedpass.core/password_generation.py """ Password Generation Module @@ -43,7 +43,7 @@ except ModuleNotFoundError: # pragma: no cover - fallback for removed module from local_bip85.bip85 import BIP85 from constants import DEFAULT_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH -from password_manager.encryption import EncryptionManager +from .encryption import EncryptionManager # Instantiate the logger logger = logging.getLogger(__name__) diff --git a/src/password_manager/portable_backup.py b/src/seedpass/core/portable_backup.py similarity index 96% rename from src/password_manager/portable_backup.py rename to src/seedpass/core/portable_backup.py index 8731818..a76879b 100644 --- a/src/password_manager/portable_backup.py +++ b/src/seedpass/core/portable_backup.py @@ -12,14 +12,14 @@ import asyncio from enum import Enum from pathlib import Path -from password_manager.vault import Vault -from password_manager.backup import BackupManager +from .vault import Vault +from .backup import BackupManager from nostr.client import NostrClient from utils.key_derivation import ( derive_index_key, EncryptionMode, ) -from password_manager.encryption import EncryptionManager +from .encryption import EncryptionManager from utils.checksum import json_checksum, canonical_json_dumps logger = logging.getLogger(__name__) diff --git a/src/seedpass/core/pubsub.py b/src/seedpass/core/pubsub.py new file mode 100644 index 0000000..fec4483 --- /dev/null +++ b/src/seedpass/core/pubsub.py @@ -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() diff --git a/src/password_manager/seedqr.py b/src/seedpass/core/seedqr.py similarity index 100% rename from src/password_manager/seedqr.py rename to src/seedpass/core/seedqr.py diff --git a/src/seedpass/core/state_manager.py b/src/seedpass/core/state_manager.py new file mode 100644 index 0000000..f2ca11e --- /dev/null +++ b/src/seedpass/core/state_manager.py @@ -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) diff --git a/src/password_manager/totp.py b/src/seedpass/core/totp.py similarity index 100% rename from src/password_manager/totp.py rename to src/seedpass/core/totp.py diff --git a/src/password_manager/vault.py b/src/seedpass/core/vault.py similarity index 93% rename from src/password_manager/vault.py rename to src/seedpass/core/vault.py index 93667c1..e1a6fbc 100644 --- a/src/password_manager/vault.py +++ b/src/seedpass/core/vault.py @@ -61,11 +61,11 @@ class Vault: return self.encryption_manager.get_encrypted_index() def decrypt_and_save_index_from_nostr( - self, encrypted_data: bytes, *, strict: bool = True + self, encrypted_data: bytes, *, strict: bool = True, merge: bool = False ) -> bool: - """Decrypt Nostr payload and overwrite the local index.""" + """Decrypt Nostr payload and update the local index.""" return self.encryption_manager.decrypt_and_save_index_from_nostr( - encrypted_data, strict=strict + encrypted_data, strict=strict, merge=merge ) # ----- Config helpers ----- diff --git a/src/seedpass_gui/__init__.py b/src/seedpass_gui/__init__.py new file mode 100644 index 0000000..4ef96bc --- /dev/null +++ b/src/seedpass_gui/__init__.py @@ -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"] diff --git a/src/seedpass_gui/__main__.py b/src/seedpass_gui/__main__.py new file mode 100644 index 0000000..37c2a2d --- /dev/null +++ b/src/seedpass_gui/__main__.py @@ -0,0 +1,4 @@ +from .app import main + +if __name__ == "__main__": + main() diff --git a/src/seedpass_gui/app.py b/src/seedpass_gui/app.py new file mode 100644 index 0000000..b3041f5 --- /dev/null +++ b/src/seedpass_gui/app.py @@ -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() diff --git a/src/tests/helpers.py b/src/tests/helpers.py index 8157bc3..c36fa65 100644 --- a/src/tests/helpers.py +++ b/src/tests/helpers.py @@ -5,8 +5,8 @@ from pathlib import Path sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.vault import Vault -from password_manager.encryption import EncryptionManager +from seedpass.core.vault import Vault +from seedpass.core.encryption import EncryptionManager from utils.key_derivation import ( derive_index_key, derive_key_from_password, diff --git a/src/tests/test_add_tags_from_retrieve.py b/src/tests/test_add_tags_from_retrieve.py index fac5866..d9ed835 100644 --- a/src/tests/test_add_tags_from_retrieve.py +++ b/src/tests/test_add_tags_from_retrieve.py @@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager class FakePasswordGenerator: diff --git a/src/tests/test_additional_backup.py b/src/tests/test_additional_backup.py index 5597394..ee7c9dc 100644 --- a/src/tests/test_additional_backup.py +++ b/src/tests/test_additional_backup.py @@ -4,9 +4,9 @@ from tempfile import TemporaryDirectory from helpers import create_vault, TEST_SEED, TEST_PASSWORD -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def test_entry_manager_additional_backup(monkeypatch): diff --git a/src/tests/test_api.py b/src/tests/test_api.py index 67f1b47..1aa4c08 100644 --- a/src/tests/test_api.py +++ b/src/tests/test_api.py @@ -179,12 +179,16 @@ def test_change_password_route(client): cl, token = client called = {} - api._pm.change_password = lambda: called.setdefault("called", True) + api._pm.change_password = lambda o, n: called.setdefault("called", (o, n)) headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"} - res = cl.post("/api/v1/change-password", headers=headers) + res = cl.post( + "/api/v1/change-password", + headers=headers, + json={"old": "old", "new": "new"}, + ) assert res.status_code == 200 assert res.json() == {"status": "ok"} - assert called.get("called") is True + assert called.get("called") == ("old", "new") assert res.headers.get("access-control-allow-origin") == "http://example.com" diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index dda8d0b..337a724 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -291,8 +291,8 @@ def test_vault_lock_endpoint(client): assert res.json() == {"status": "locked"} assert called.get("locked") is True assert api._pm.locked is True - api._pm.unlock_vault = lambda: setattr(api._pm, "locked", False) - api._pm.unlock_vault() + api._pm.unlock_vault = lambda pw: setattr(api._pm, "locked", False) + api._pm.unlock_vault("pw") assert api._pm.locked is False diff --git a/src/tests/test_archive_from_retrieve.py b/src/tests/test_archive_from_retrieve.py index bc094da..65844ba 100644 --- a/src/tests/test_archive_from_retrieve.py +++ b/src/tests/test_archive_from_retrieve.py @@ -8,10 +8,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager class FakePasswordGenerator: diff --git a/src/tests/test_archive_nonpassword.py b/src/tests/test_archive_nonpassword.py index 6296813..3a1e6b8 100644 --- a/src/tests/test_archive_nonpassword.py +++ b/src/tests/test_archive_nonpassword.py @@ -6,9 +6,9 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def setup_entry_mgr(tmp_path: Path) -> EntryManager: diff --git a/src/tests/test_archive_restore.py b/src/tests/test_archive_restore.py index 8332fd5..a906274 100644 --- a/src/tests/test_archive_restore.py +++ b/src/tests/test_archive_restore.py @@ -10,10 +10,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode def setup_entry_mgr(tmp_path: Path) -> EntryManager: diff --git a/src/tests/test_background_relay_check.py b/src/tests/test_background_relay_check.py index d537c70..ecc0c5b 100644 --- a/src/tests/test_background_relay_check.py +++ b/src/tests/test_background_relay_check.py @@ -6,7 +6,7 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager +from seedpass.core.manager import PasswordManager from constants import MIN_HEALTHY_RELAYS diff --git a/src/tests/test_background_sync_always.py b/src/tests/test_background_sync_always.py index 84faa32..f266489 100644 --- a/src/tests/test_background_sync_always.py +++ b/src/tests/test_background_sync_always.py @@ -4,8 +4,8 @@ from pathlib import Path sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager -import password_manager.manager as manager_module +from seedpass.core.manager import PasswordManager +import seedpass.core.manager as manager_module def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path): @@ -22,17 +22,12 @@ def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path): pm.config_manager = SimpleNamespace(get_quick_unlock=lambda: False) monkeypatch.setattr("builtins.input", lambda *_a, **_k: "1") - monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", lambda *_a, **_k: "pw" - ) monkeypatch.setattr( PasswordManager, "setup_encryption_manager", lambda *a, **k: True ) monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda *a, **k: None) monkeypatch.setattr(PasswordManager, "initialize_managers", lambda *a, **k: None) - monkeypatch.setattr( - "password_manager.manager.NostrClient", lambda *a, **kw: object() - ) + monkeypatch.setattr("seedpass.core.manager.NostrClient", lambda *a, **kw: object()) calls = {"count": 0} @@ -41,7 +36,7 @@ def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path): monkeypatch.setattr(PasswordManager, "start_background_sync", fake_bg) - assert pm.handle_switch_fingerprint() + assert pm.handle_switch_fingerprint(password="pw") assert calls["count"] == 1 diff --git a/src/tests/test_backup_interval.py b/src/tests/test_backup_interval.py index f7ce39a..baec9b4 100644 --- a/src/tests/test_backup_interval.py +++ b/src/tests/test_backup_interval.py @@ -4,8 +4,8 @@ from tempfile import TemporaryDirectory from helpers import create_vault, TEST_SEED, TEST_PASSWORD -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def test_backup_interval(monkeypatch): diff --git a/src/tests/test_backup_restore.py b/src/tests/test_backup_restore.py index fdbc221..d22d6a4 100644 --- a/src/tests/test_backup_restore.py +++ b/src/tests/test_backup_restore.py @@ -8,8 +8,8 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def test_backup_restore_workflow(monkeypatch): diff --git a/src/tests/test_bip85_vectors.py b/src/tests/test_bip85_vectors.py index 8d62aa3..68938b0 100644 --- a/src/tests/test_bip85_vectors.py +++ b/src/tests/test_bip85_vectors.py @@ -5,7 +5,7 @@ import pytest sys.path.append(str(Path(__file__).resolve().parents[1])) from local_bip85.bip85 import BIP85, Bip85Error -from password_manager.password_generation import ( +from seedpass.core.password_generation import ( derive_ssh_key, derive_seed_phrase, ) diff --git a/src/tests/test_cli_core_services.py b/src/tests/test_cli_core_services.py new file mode 100644 index 0000000..1c79a42 --- /dev/null +++ b/src/tests/test_cli_core_services.py @@ -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 diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py index 9937926..ac5ebb1 100644 --- a/src/tests/test_cli_doc_examples.py +++ b/src/tests/test_cli_doc_examples.py @@ -8,7 +8,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1] / "src")) from typer.testing import CliRunner from seedpass import cli -from password_manager.entry_types import EntryType +from seedpass.core.entry_types import EntryType class DummyPM: @@ -17,7 +17,7 @@ class DummyPM: list_entries=lambda sort_by="index", filter_kind=None, include_archived=False: [ (1, "Label", "user", "url", False) ], - search_entries=lambda q: [(1, "GitHub", "user", "", False)], + search_entries=lambda q, kinds=None: [(1, "GitHub", "user", "", False)], retrieve_entry=lambda idx: {"type": EntryType.PASSWORD.value, "length": 8}, get_totp_code=lambda idx, seed: "123456", add_entry=lambda label, length, username, url: 1, @@ -40,10 +40,10 @@ class DummyPM: self.handle_display_totp_codes = lambda: None self.handle_export_database = lambda path: None self.handle_import_database = lambda path: None - self.change_password = lambda: None + self.change_password = lambda *a, **kw: None self.lock_vault = lambda: None self.get_profile_stats = lambda: {"n": 1} - self.handle_backup_reveal_parent_seed = lambda path=None: None + self.handle_backup_reveal_parent_seed = lambda path=None, **_: None self.handle_verify_checksum = lambda: None self.handle_update_script_checksum = lambda: None self.add_new_fingerprint = lambda: None @@ -58,6 +58,7 @@ class DummyPM: "chunk_ids": ["c1"], "delta_ids": [], } + self.start_background_vault_sync = lambda *a, **k: self.sync_vault() self.config_manager = SimpleNamespace( load_config=lambda require_pin=False: {"inactivity_timeout": 30}, set_inactivity_timeout=lambda v: None, @@ -76,7 +77,7 @@ class DummyPM: ) self.secret_mode_enabled = True self.clipboard_clear_delay = 30 - self.select_fingerprint = lambda fp: None + self.select_fingerprint = lambda fp, **_: None def load_doc_commands() -> list[str]: @@ -84,7 +85,9 @@ def load_doc_commands() -> list[str]: cmds = set(re.findall(r"`seedpass ([^`<>]+)`", text)) cmds = {c for c in cmds if "<" not in c and ">" not in c} cmds.discard("vault export") + cmds.discard("vault export --file backup.json") cmds.discard("vault import") + cmds.discard("vault import --file backup.json") return sorted(cmds) diff --git a/src/tests/test_cli_entry_add_commands.py b/src/tests/test_cli_entry_add_commands.py index 5fbeafd..dd482b3 100644 --- a/src/tests/test_cli_entry_add_commands.py +++ b/src/tests/test_cli_entry_add_commands.py @@ -115,14 +115,14 @@ def test_entry_add_commands( called["kwargs"] = kwargs return stdout - def sync_vault(): + def start_background_vault_sync(): called["sync"] = True pm = SimpleNamespace( entry_manager=SimpleNamespace(**{method: func}), parent_seed="seed", select_fingerprint=lambda fp: None, - sync_vault=sync_vault, + start_background_vault_sync=start_background_vault_sync, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", command] + cli_args) diff --git a/src/tests/test_cli_export_import.py b/src/tests/test_cli_export_import.py index 5e268b7..e01b38d 100644 --- a/src/tests/test_cli_export_import.py +++ b/src/tests/test_cli_export_import.py @@ -6,9 +6,9 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) import main -from password_manager.portable_backup import export_backup, import_backup -from password_manager.config_manager import ConfigManager -from password_manager.backup import BackupManager +from seedpass.core.portable_backup import export_backup, import_backup +from seedpass.core.config_manager import ConfigManager +from seedpass.core.backup import BackupManager from helpers import create_vault, TEST_SEED diff --git a/src/tests/test_cli_integration.py b/src/tests/test_cli_integration.py new file mode 100644 index 0000000..a2978bb --- /dev/null +++ b/src/tests/test_cli_integration.py @@ -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) diff --git a/src/tests/test_cli_relays.py b/src/tests/test_cli_relays.py new file mode 100644 index 0000000..fcfe5fc --- /dev/null +++ b/src/tests/test_cli_relays.py @@ -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"] diff --git a/src/tests/test_cli_subcommands.py b/src/tests/test_cli_subcommands.py index 56e437f..751ac74 100644 --- a/src/tests/test_cli_subcommands.py +++ b/src/tests/test_cli_subcommands.py @@ -5,7 +5,7 @@ from pathlib import Path sys.path.append(str(Path(__file__).resolve().parents[1])) import main -from password_manager.entry_types import EntryType +from seedpass.core.entry_types import EntryType def make_pm(search_results, entry=None, totp_code="123456"): diff --git a/src/tests/test_concurrency_stress.py b/src/tests/test_concurrency_stress.py index da79dd4..2c145c4 100644 --- a/src/tests/test_concurrency_stress.py +++ b/src/tests/test_concurrency_stress.py @@ -6,10 +6,10 @@ from helpers import TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.encryption import EncryptionManager +from seedpass.core.vault import Vault +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager from utils.key_derivation import derive_index_key, derive_key_from_password diff --git a/src/tests/test_config_manager.py b/src/tests/test_config_manager.py index d26e465..c6ee18e 100644 --- a/src/tests/test_config_manager.py +++ b/src/tests/test_config_manager.py @@ -7,8 +7,8 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.config_manager import ConfigManager -from password_manager.vault import Vault +from seedpass.core.config_manager import ConfigManager +from seedpass.core.vault import Vault from nostr.client import DEFAULT_RELAYS from constants import INACTIVITY_TIMEOUT diff --git a/src/tests/test_core_services.py b/src/tests/test_core_services.py new file mode 100644 index 0000000..ea48a8a --- /dev/null +++ b/src/tests/test_core_services.py @@ -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"] diff --git a/src/tests/test_custom_fields_display.py b/src/tests/test_custom_fields_display.py index 04e2d45..b2fc943 100644 --- a/src/tests/test_custom_fields_display.py +++ b/src/tests/test_custom_fields_display.py @@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager def test_retrieve_entry_shows_custom_fields(monkeypatch, capsys): diff --git a/src/tests/test_default_encryption_mode.py b/src/tests/test_default_encryption_mode.py index dd0108f..251eeb2 100644 --- a/src/tests/test_default_encryption_mode.py +++ b/src/tests/test_default_encryption_mode.py @@ -6,7 +6,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from types import SimpleNamespace from pathlib import Path -from password_manager.manager import PasswordManager +from seedpass.core.manager import PasswordManager from utils.key_derivation import EncryptionMode diff --git a/src/tests/test_delta_merge.py b/src/tests/test_delta_merge.py new file mode 100644 index 0000000..c6b2866 --- /dev/null +++ b/src/tests/test_delta_merge.py @@ -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" diff --git a/src/tests/test_edit_tags_from_retrieve.py b/src/tests/test_edit_tags_from_retrieve.py index 143da53..ab93657 100644 --- a/src/tests/test_edit_tags_from_retrieve.py +++ b/src/tests/test_edit_tags_from_retrieve.py @@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager class FakePasswordGenerator: diff --git a/src/tests/test_encryption_checksum.py b/src/tests/test_encryption_checksum.py index 33d76fc..c95f82d 100644 --- a/src/tests/test_encryption_checksum.py +++ b/src/tests/test_encryption_checksum.py @@ -8,7 +8,7 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager from utils.checksum import verify_and_update_checksum diff --git a/src/tests/test_encryption_files.py b/src/tests/test_encryption_files.py index 0332f6f..04fb511 100644 --- a/src/tests/test_encryption_files.py +++ b/src/tests/test_encryption_files.py @@ -8,7 +8,7 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager def test_json_save_and_load_round_trip(): diff --git a/src/tests/test_entries_empty.py b/src/tests/test_entries_empty.py index f9700a5..0f05cb4 100644 --- a/src/tests/test_entries_empty.py +++ b/src/tests/test_entries_empty.py @@ -5,10 +5,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager def test_list_entries_empty(): diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index 1714da5..c7a966a 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -8,10 +8,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager def test_add_and_retrieve_entry(): @@ -44,7 +44,9 @@ def test_add_and_retrieve_entry(): data = enc_mgr.load_json_data(entry_mgr.index_file) assert str(index) in data.get("entries", {}) - assert data["entries"][str(index)] == entry + stored = data["entries"][str(index)] + stored.pop("modified_ts", None) + assert stored == entry @pytest.mark.parametrize( diff --git a/src/tests/test_entry_management_checksum_path.py b/src/tests/test_entry_management_checksum_path.py index 7f75b65..4d456b1 100644 --- a/src/tests/test_entry_management_checksum_path.py +++ b/src/tests/test_entry_management_checksum_path.py @@ -5,10 +5,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager def test_update_checksum_writes_to_expected_path(): diff --git a/src/tests/test_export_totp_codes.py b/src/tests/test_export_totp_codes.py index 2f474da..f588e9e 100644 --- a/src/tests/test_export_totp_codes.py +++ b/src/tests/test_export_totp_codes.py @@ -8,11 +8,11 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager -from password_manager.totp import TotpManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager +from seedpass.core.totp import TotpManager class FakeNostrClient: @@ -42,9 +42,7 @@ def test_handle_export_totp_codes(monkeypatch, tmp_path): export_path = tmp_path / "out.json" monkeypatch.setattr("builtins.input", lambda *a, **k: str(export_path)) - monkeypatch.setattr( - "password_manager.manager.confirm_action", lambda *_a, **_k: False - ) + monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: False) pm.handle_export_totp_codes() diff --git a/src/tests/test_fingerprint_encryption.py b/src/tests/test_fingerprint_encryption.py index a306c1f..9cc14d7 100644 --- a/src/tests/test_fingerprint_encryption.py +++ b/src/tests/test_fingerprint_encryption.py @@ -9,7 +9,7 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) from utils.fingerprint import generate_fingerprint -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager def test_generate_fingerprint_deterministic(): diff --git a/src/tests/test_full_sync_roundtrip.py b/src/tests/test_full_sync_roundtrip.py index 64f110e..69a24d3 100644 --- a/src/tests/test_full_sync_roundtrip.py +++ b/src/tests/test_full_sync_roundtrip.py @@ -4,10 +4,10 @@ from tempfile import TemporaryDirectory from helpers import create_vault, dummy_nostr_client -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode def _init_pm(dir_path: Path, client) -> PasswordManager: @@ -44,7 +44,7 @@ def test_full_sync_roundtrip(dummy_nostr_client): # Manager A publishes initial snapshot pm_a.entry_manager.add_entry("site1", 12) pm_a.sync_vault() - manifest_id = relay.manifests[-1].id + manifest_id = relay.manifests[-1].tags[0] # Manager B retrieves snapshot result = pm_b.attempt_initial_sync() diff --git a/src/tests/test_full_sync_roundtrip_new.py b/src/tests/test_full_sync_roundtrip_new.py index 64f110e..69a24d3 100644 --- a/src/tests/test_full_sync_roundtrip_new.py +++ b/src/tests/test_full_sync_roundtrip_new.py @@ -4,10 +4,10 @@ from tempfile import TemporaryDirectory from helpers import create_vault, dummy_nostr_client -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode def _init_pm(dir_path: Path, client) -> PasswordManager: @@ -44,7 +44,7 @@ def test_full_sync_roundtrip(dummy_nostr_client): # Manager A publishes initial snapshot pm_a.entry_manager.add_entry("site1", 12) pm_a.sync_vault() - manifest_id = relay.manifests[-1].id + manifest_id = relay.manifests[-1].tags[0] # Manager B retrieves snapshot result = pm_b.attempt_initial_sync() diff --git a/src/tests/test_fuzz_key_derivation.py b/src/tests/test_fuzz_key_derivation.py index 45a35b6..89e26c8 100644 --- a/src/tests/test_fuzz_key_derivation.py +++ b/src/tests/test_fuzz_key_derivation.py @@ -9,7 +9,7 @@ from utils.key_derivation import ( derive_key_from_password_argon2, derive_index_key, ) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager cfg_values = st.one_of( diff --git a/src/tests/test_gui_features.py b/src/tests/test_gui_features.py new file mode 100644 index 0000000..90f0279 --- /dev/null +++ b/src/tests/test_gui_features.py @@ -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" diff --git a/src/tests/test_gui_headless.py b/src/tests/test_gui_headless.py new file mode 100644 index 0000000..dc895a8 --- /dev/null +++ b/src/tests/test_gui_headless.py @@ -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" diff --git a/src/tests/test_gui_sync.py b/src/tests/test_gui_sync.py new file mode 100644 index 0000000..6ef1ed2 --- /dev/null +++ b/src/tests/test_gui_sync.py @@ -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() diff --git a/src/tests/test_inactivity_lock.py b/src/tests/test_inactivity_lock.py index 32d81da..2befaed 100644 --- a/src/tests/test_inactivity_lock.py +++ b/src/tests/test_inactivity_lock.py @@ -91,3 +91,32 @@ def test_input_timeout_triggers_lock(monkeypatch): assert locked["locked"] == 1 assert locked["unlocked"] == 1 + + +def test_update_activity_checks_timeout(monkeypatch): + """AuthGuard in update_activity locks the vault after inactivity.""" + import seedpass.core.manager as manager + + now = {"val": 0.0} + monkeypatch.setattr(manager.time, "time", lambda: now["val"]) + + pm = manager.PasswordManager.__new__(manager.PasswordManager) + pm.inactivity_timeout = 0.5 + pm.last_activity = 0.0 + pm.locked = False + called = {} + + def lock(): + called["locked"] = True + pm.locked = True + + pm.lock_vault = lock + pm.auth_guard = manager.AuthGuard(pm, time_fn=lambda: now["val"]) + + now["val"] = 0.4 + pm.update_activity() + assert not called + + now["val"] = 1.1 + pm.update_activity() + assert called["locked"] is True diff --git a/src/tests/test_index_cache.py b/src/tests/test_index_cache.py index e4a054b..2ee8eac 100644 --- a/src/tests/test_index_cache.py +++ b/src/tests/test_index_cache.py @@ -3,9 +3,9 @@ from tempfile import TemporaryDirectory from unittest.mock import patch from helpers import create_vault, TEST_SEED, TEST_PASSWORD -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def test_index_caching(): diff --git a/src/tests/test_index_import_export.py b/src/tests/test_index_import_export.py index 04e3194..a9ee75a 100644 --- a/src/tests/test_index_import_export.py +++ b/src/tests/test_index_import_export.py @@ -7,8 +7,8 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault +from seedpass.core.encryption import EncryptionManager +from seedpass.core.vault import Vault from utils.key_derivation import derive_index_key, derive_key_from_password SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" diff --git a/src/tests/test_kdf_modes.py b/src/tests/test_kdf_modes.py index ab453de..177d050 100644 --- a/src/tests/test_kdf_modes.py +++ b/src/tests/test_kdf_modes.py @@ -8,10 +8,10 @@ from utils.key_derivation import ( derive_key_from_password_argon2, derive_index_key, ) -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.encryption import EncryptionManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode TEST_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" TEST_PASSWORD = "pw" @@ -59,12 +59,12 @@ def test_setup_encryption_manager_kdf_modes(monkeypatch): cfg = _setup_profile(path, mode) pm = _make_pm(path, cfg) monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", + "seedpass.core.manager.prompt_existing_password", lambda *_: TEST_PASSWORD, ) if mode == "argon2": monkeypatch.setattr( - "password_manager.manager.derive_key_from_password_argon2", + "seedpass.core.manager.derive_key_from_password_argon2", lambda pw: derive_key_from_password_argon2(pw, **argon_kwargs), ) monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None) diff --git a/src/tests/test_key_value_entry.py b/src/tests/test_key_value_entry.py index 895dfbf..ededd03 100644 --- a/src/tests/test_key_value_entry.py +++ b/src/tests/test_key_value_entry.py @@ -6,9 +6,9 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def setup_entry_mgr(tmp_path: Path) -> EntryManager: @@ -41,4 +41,4 @@ def test_add_and_modify_key_value(): assert updated["value"] == "def456" results = em.search_entries("def456") - assert results == [(idx, "API", None, None, False)] + assert results == [] diff --git a/src/tests/test_last_used_fingerprint.py b/src/tests/test_last_used_fingerprint.py index b097c4a..1e294f9 100644 --- a/src/tests/test_last_used_fingerprint.py +++ b/src/tests/test_last_used_fingerprint.py @@ -3,9 +3,9 @@ from pathlib import Path from tempfile import TemporaryDirectory import constants -import password_manager.manager as manager_module +import seedpass.core.manager as manager_module from utils.fingerprint_manager import FingerprintManager -from password_manager.manager import EncryptionMode +from seedpass.core.manager import EncryptionMode from helpers import TEST_SEED diff --git a/src/tests/test_list_entries_sort_filter.py b/src/tests/test_list_entries_sort_filter.py index f56d3ef..4361008 100644 --- a/src/tests/test_list_entries_sort_filter.py +++ b/src/tests/test_list_entries_sort_filter.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.entry_types import EntryType +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.entry_types import EntryType def setup_entry_manager(tmp_path: Path) -> EntryManager: @@ -19,29 +19,35 @@ def setup_entry_manager(tmp_path: Path) -> EntryManager: return EntryManager(vault, backup_mgr) -def test_sort_by_website(): +def test_sort_by_label(): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) em = setup_entry_manager(tmp_path) idx0 = em.add_entry("b.com", 8, "user1") idx1 = em.add_entry("A.com", 8, "user2") - result = em.list_entries(sort_by="website") + result = em.list_entries(sort_by="label") assert result == [ (idx1, "A.com", "user2", "", False), (idx0, "b.com", "user1", "", False), ] -def test_sort_by_username(): +def test_sort_by_updated(): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) em = setup_entry_manager(tmp_path) - idx0 = em.add_entry("alpha.com", 8, "Charlie") - idx1 = em.add_entry("beta.com", 8, "alice") - result = em.list_entries(sort_by="username") + idx0 = em.add_entry("alpha.com", 8, "u0") + idx1 = em.add_entry("beta.com", 8, "u1") + + data = em._load_index(force_reload=True) + data["entries"][str(idx0)]["updated"] = 1 + data["entries"][str(idx1)]["updated"] = 2 + em._save_index(data) + + result = em.list_entries(sort_by="updated") assert result == [ - (idx1, "beta.com", "alice", "", False), - (idx0, "alpha.com", "Charlie", "", False), + (idx1, "beta.com", "u1", "", False), + (idx0, "alpha.com", "u0", "", False), ] diff --git a/src/tests/test_managed_account.py b/src/tests/test_managed_account.py index b62d814..a6d18cb 100644 --- a/src/tests/test_managed_account.py +++ b/src/tests/test_managed_account.py @@ -4,14 +4,14 @@ from tempfile import TemporaryDirectory from helpers import create_vault, TEST_SEED, TEST_PASSWORD from utils.fingerprint import generate_fingerprint -import password_manager.manager as manager_module -from password_manager.manager import EncryptionMode +import seedpass.core.manager as manager_module +from seedpass.core.manager import EncryptionMode sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def setup_entry_manager(tmp_path: Path) -> EntryManager: diff --git a/src/tests/test_managed_account_entry.py b/src/tests/test_managed_account_entry.py index d9d6cef..abeadac 100644 --- a/src/tests/test_managed_account_entry.py +++ b/src/tests/test_managed_account_entry.py @@ -4,15 +4,15 @@ from tempfile import TemporaryDirectory from helpers import create_vault, TEST_SEED, TEST_PASSWORD from utils.fingerprint import generate_fingerprint -import password_manager.manager as manager_module -from password_manager.manager import EncryptionMode +import seedpass.core.manager as manager_module +from seedpass.core.manager import EncryptionMode sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.password_generation import derive_seed_phrase +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.password_generation import derive_seed_phrase from local_bip85.bip85 import BIP85 from bip_utils import Bip39SeedGenerator diff --git a/src/tests/test_manager_add_password.py b/src/tests/test_manager_add_password.py new file mode 100644 index 0000000..3579f48 --- /dev/null +++ b/src/tests/test_manager_add_password.py @@ -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)] diff --git a/src/tests/test_manager_add_totp.py b/src/tests/test_manager_add_totp.py index 3dd140a..27ed550 100644 --- a/src/tests/test_manager_add_totp.py +++ b/src/tests/test_manager_add_totp.py @@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager class FakeNostrClient: diff --git a/src/tests/test_manager_checksum_backup.py b/src/tests/test_manager_checksum_backup.py index cfba90c..5eeb632 100644 --- a/src/tests/test_manager_checksum_backup.py +++ b/src/tests/test_manager_checksum_backup.py @@ -3,7 +3,7 @@ from pathlib import Path sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.manager import PasswordManager, EncryptionMode import queue @@ -29,8 +29,8 @@ def test_handle_verify_checksum_success(monkeypatch, tmp_path, capsys): pm = _make_pm() chk_file = tmp_path / "chk.txt" chk_file.write_text("abc") - monkeypatch.setattr("password_manager.manager.SCRIPT_CHECKSUM_FILE", chk_file) - monkeypatch.setattr("password_manager.manager.calculate_checksum", lambda _: "abc") + monkeypatch.setattr("seedpass.core.manager.SCRIPT_CHECKSUM_FILE", chk_file) + monkeypatch.setattr("seedpass.core.manager.calculate_checksum", lambda _: "abc") pm.handle_verify_checksum() out = capsys.readouterr().out assert "Checksum verification passed." in out @@ -40,8 +40,8 @@ def test_handle_verify_checksum_failure(monkeypatch, tmp_path, capsys): pm = _make_pm() chk_file = tmp_path / "chk.txt" chk_file.write_text("xyz") - monkeypatch.setattr("password_manager.manager.SCRIPT_CHECKSUM_FILE", chk_file) - monkeypatch.setattr("password_manager.manager.calculate_checksum", lambda _: "abc") + monkeypatch.setattr("seedpass.core.manager.SCRIPT_CHECKSUM_FILE", chk_file) + monkeypatch.setattr("seedpass.core.manager.calculate_checksum", lambda _: "abc") pm.handle_verify_checksum() out = capsys.readouterr().out assert "Checksum verification failed" in out @@ -50,13 +50,13 @@ def test_handle_verify_checksum_failure(monkeypatch, tmp_path, capsys): def test_handle_verify_checksum_missing(monkeypatch, tmp_path, capsys): pm = _make_pm() chk_file = tmp_path / "chk.txt" - monkeypatch.setattr("password_manager.manager.SCRIPT_CHECKSUM_FILE", chk_file) - monkeypatch.setattr("password_manager.manager.calculate_checksum", lambda _: "abc") + monkeypatch.setattr("seedpass.core.manager.SCRIPT_CHECKSUM_FILE", chk_file) + monkeypatch.setattr("seedpass.core.manager.calculate_checksum", lambda _: "abc") def raise_missing(*_args, **_kwargs): raise FileNotFoundError - monkeypatch.setattr("password_manager.manager.verify_checksum", raise_missing) + monkeypatch.setattr("seedpass.core.manager.verify_checksum", raise_missing) pm.handle_verify_checksum() note = pm.notifications.get_nowait() assert note.level == "WARNING" diff --git a/src/tests/test_manager_current_notification.py b/src/tests/test_manager_current_notification.py index ab94d9e..a9d341c 100644 --- a/src/tests/test_manager_current_notification.py +++ b/src/tests/test_manager_current_notification.py @@ -5,7 +5,7 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager, Notification +from seedpass.core.manager import PasswordManager, Notification from constants import NOTIFICATION_DURATION @@ -20,7 +20,7 @@ def _make_pm(): def test_notify_sets_current(monkeypatch): pm = _make_pm() current = {"val": 100.0} - monkeypatch.setattr("password_manager.manager.time.time", lambda: current["val"]) + monkeypatch.setattr("seedpass.core.manager.time.time", lambda: current["val"]) pm.notify("hello") note = pm._current_notification assert hasattr(note, "message") @@ -32,7 +32,7 @@ def test_notify_sets_current(monkeypatch): def test_get_current_notification_ttl(monkeypatch): pm = _make_pm() now = {"val": 0.0} - monkeypatch.setattr("password_manager.manager.time.time", lambda: now["val"]) + monkeypatch.setattr("seedpass.core.manager.time.time", lambda: now["val"]) pm.notify("note1") assert pm.get_current_notification().message == "note1" diff --git a/src/tests/test_manager_display_totp_codes.py b/src/tests/test_manager_display_totp_codes.py index 649bcd2..783e985 100644 --- a/src/tests/test_manager_display_totp_codes.py +++ b/src/tests/test_manager_display_totp_codes.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager class FakeNostrClient: @@ -50,7 +50,7 @@ def test_handle_display_totp_codes(monkeypatch, capsys): # interrupt the loop after first iteration monkeypatch.setattr( - "password_manager.manager.timed_input", + "seedpass.core.manager.timed_input", lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()), ) @@ -91,7 +91,7 @@ def test_display_totp_codes_excludes_archived(monkeypatch, capsys): ) monkeypatch.setattr( - "password_manager.manager.timed_input", + "seedpass.core.manager.timed_input", lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()), ) diff --git a/src/tests/test_manager_edit_totp.py b/src/tests/test_manager_edit_totp.py index 53e43d4..8d2bbbe 100644 --- a/src/tests/test_manager_edit_totp.py +++ b/src/tests/test_manager_edit_totp.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager class FakeNostrClient: @@ -49,8 +49,8 @@ def test_edit_totp_period_from_retrieve(monkeypatch): monkeypatch.setattr( pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1 ) - monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) - monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b") + monkeypatch.setattr("seedpass.core.manager.time.sleep", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.timed_input", lambda *a, **k: "b") pm.handle_retrieve_entry() entry = entry_mgr.retrieve_entry(0) diff --git a/src/tests/test_manager_list_entries.py b/src/tests/test_manager_list_entries.py index 444d420..6e39282 100644 --- a/src/tests/test_manager_list_entries.py +++ b/src/tests/test_manager_list_entries.py @@ -10,11 +10,11 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.entry_types import EntryType -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.entry_types import EntryType +from seedpass.core.config_manager import ConfigManager def test_handle_list_entries(monkeypatch, capsys): @@ -79,9 +79,9 @@ def test_list_entries_show_details(monkeypatch, capsys): monkeypatch.setattr( pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1 ) - monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.time.sleep", lambda *a, **k: None) monkeypatch.setattr( - "password_manager.manager.timed_input", + "seedpass.core.manager.timed_input", lambda *a, **k: "b", ) @@ -119,7 +119,7 @@ def test_show_entry_details_by_index(monkeypatch): header_calls = [] monkeypatch.setattr( - "password_manager.manager.clear_header_with_notification", + "seedpass.core.manager.clear_header_with_notification", lambda *a, **k: header_calls.append(True), ) @@ -134,9 +134,9 @@ def test_show_entry_details_by_index(monkeypatch): "_entry_actions_menu", lambda *a, **k: call_order.append("actions"), ) - monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None) monkeypatch.setattr( - "password_manager.manager.confirm_action", lambda *a, **k: False + "seedpass.core.manager.confirm_action", lambda *a, **k: False ) pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw123") monkeypatch.setattr(pm, "notify", lambda *a, **k: None) @@ -168,16 +168,14 @@ def _setup_manager(tmp_path): def _detail_common(monkeypatch, pm): monkeypatch.setattr( - "password_manager.manager.clear_header_with_notification", + "seedpass.core.manager.clear_header_with_notification", lambda *a, **k: None, ) - monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None) monkeypatch.setattr("builtins.input", lambda *a, **k: "") - monkeypatch.setattr( - "password_manager.manager.confirm_action", lambda *a, **k: False - ) - monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b") - monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *a, **k: False) + monkeypatch.setattr("seedpass.core.manager.timed_input", lambda *a, **k: "b") + monkeypatch.setattr("seedpass.core.manager.time.sleep", lambda *a, **k: None) monkeypatch.setattr(pm, "notify", lambda *a, **k: None) pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw123") called = [] @@ -300,21 +298,21 @@ def test_show_entry_details_sensitive(monkeypatch, capsys, entry_type): pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw123") monkeypatch.setattr( - "password_manager.manager.confirm_action", lambda *a, **k: True + "seedpass.core.manager.confirm_action", lambda *a, **k: True ) monkeypatch.setattr( - "password_manager.manager.copy_to_clipboard", lambda *a, **k: None + "seedpass.core.manager.copy_to_clipboard", lambda *a, **k: None ) - monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b") - monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.timed_input", lambda *a, **k: "b") + monkeypatch.setattr("seedpass.core.manager.time.sleep", lambda *a, **k: None) monkeypatch.setattr( - "password_manager.manager.TotpManager.print_qr_code", lambda *a, **k: None + "seedpass.core.manager.TotpManager.print_qr_code", lambda *a, **k: None ) monkeypatch.setattr( - "password_manager.manager.clear_header_with_notification", + "seedpass.core.manager.clear_header_with_notification", lambda *a, **k: None, ) - monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None) input_val = "r" if entry_type == "managed_account" else "" monkeypatch.setattr("builtins.input", lambda *a, **k: input_val) diff --git a/src/tests/test_manager_retrieve_totp.py b/src/tests/test_manager_retrieve_totp.py index 2d300ad..8d01c28 100644 --- a/src/tests/test_manager_retrieve_totp.py +++ b/src/tests/test_manager_retrieve_totp.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode, TotpManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode, TotpManager +from seedpass.core.config_manager import ConfigManager class FakeNostrClient: @@ -49,9 +49,9 @@ def test_handle_retrieve_totp_entry(monkeypatch, capsys): monkeypatch.setattr( pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1 ) - monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.time.sleep", lambda *a, **k: None) monkeypatch.setattr( - "password_manager.manager.timed_input", + "seedpass.core.manager.timed_input", lambda *a, **k: "b", ) diff --git a/src/tests/test_manager_search_display.py b/src/tests/test_manager_search_display.py index 5116ae2..6781f0b 100644 --- a/src/tests/test_manager_search_display.py +++ b/src/tests/test_manager_search_display.py @@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager def test_search_entries_prompt_for_details(monkeypatch, capsys): @@ -38,8 +38,8 @@ def test_search_entries_prompt_for_details(monkeypatch, capsys): monkeypatch.setattr( pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1 ) - monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) - monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b") + monkeypatch.setattr("seedpass.core.manager.time.sleep", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.timed_input", lambda *a, **k: "b") inputs = iter(["Example", "0"]) monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) diff --git a/src/tests/test_manager_seed_setup.py b/src/tests/test_manager_seed_setup.py index ea6baf9..4759373 100644 --- a/src/tests/test_manager_seed_setup.py +++ b/src/tests/test_manager_seed_setup.py @@ -1,6 +1,6 @@ import builtins from mnemonic import Mnemonic -from password_manager.manager import PasswordManager +from seedpass.core.manager import PasswordManager from utils import seed_prompt @@ -28,7 +28,7 @@ def test_setup_existing_seed_words(monkeypatch): words = phrase.split() word_iter = iter(words) monkeypatch.setattr( - "password_manager.manager.masked_input", + "seedpass.core.manager.masked_input", lambda *_: next(word_iter), ) # Ensure prompt_seed_words uses the patched function @@ -36,7 +36,7 @@ def test_setup_existing_seed_words(monkeypatch): monkeypatch.setattr(builtins, "input", lambda *_: "y") pm = PasswordManager.__new__(PasswordManager) - monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed: seed) + monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed, **_: seed) result = pm.setup_existing_seed(method="words") assert result == phrase @@ -52,7 +52,7 @@ def test_setup_existing_seed_paste(monkeypatch): called["prompt"] = prompt return phrase - monkeypatch.setattr("password_manager.manager.masked_input", fake_masked_input) + monkeypatch.setattr("seedpass.core.manager.masked_input", fake_masked_input) monkeypatch.setattr( builtins, "input", @@ -60,8 +60,32 @@ def test_setup_existing_seed_paste(monkeypatch): ) pm = PasswordManager.__new__(PasswordManager) - monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed: seed) + monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed, **_: seed) result = pm.setup_existing_seed(method="paste") assert result == phrase assert called["prompt"].startswith("Enter your 12-word BIP-85 seed") + + +def test_setup_existing_seed_with_args(monkeypatch): + m = Mnemonic("english") + phrase = m.generate(strength=128) + + called = {} + + monkeypatch.setattr( + "seedpass.core.manager.masked_input", + lambda *_: (_ for _ in ()).throw(RuntimeError("prompt")), + ) + + def finalize(seed, *, password=None): + called["seed"] = seed + called["pw"] = password + return "fp" + + pm = PasswordManager.__new__(PasswordManager) + monkeypatch.setattr(pm, "_finalize_existing_seed", finalize) + result = pm.setup_existing_seed(method="paste", seed=phrase, password="pw") + assert result == "fp" + assert called["seed"] == phrase + assert called["pw"] == "pw" diff --git a/src/tests/test_manager_warning_notifications.py b/src/tests/test_manager_warning_notifications.py index 55ad85c..1c55db6 100644 --- a/src/tests/test_manager_warning_notifications.py +++ b/src/tests/test_manager_warning_notifications.py @@ -5,11 +5,11 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager from helpers import create_vault, TEST_SEED, TEST_PASSWORD -from password_manager.config_manager import ConfigManager +from seedpass.core.config_manager import ConfigManager def _make_pm(tmp_path: Path) -> PasswordManager: @@ -34,9 +34,9 @@ def _make_pm(tmp_path: Path) -> PasswordManager: def test_handle_search_entries_no_query(monkeypatch, tmp_path): pm = _make_pm(tmp_path) monkeypatch.setattr( - "password_manager.manager.clear_header_with_notification", lambda *a, **k: None + "seedpass.core.manager.clear_header_with_notification", lambda *a, **k: None ) - monkeypatch.setattr("password_manager.manager.pause", lambda: None) + monkeypatch.setattr("seedpass.core.manager.pause", lambda: None) monkeypatch.setattr("builtins.input", lambda *_: "") pm.handle_search_entries() diff --git a/src/tests/test_manager_workflow.py b/src/tests/test_manager_workflow.py index a5046b2..99bea0a 100644 --- a/src/tests/test_manager_workflow.py +++ b/src/tests/test_manager_workflow.py @@ -5,11 +5,11 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.vault import Vault -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.vault import Vault +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager class FakePasswordGenerator: @@ -34,7 +34,7 @@ def test_manager_workflow(monkeypatch): backup_mgr = BackupManager(tmp_path, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) - monkeypatch.setattr("password_manager.manager.NostrClient", FakeNostrClient) + monkeypatch.setattr("seedpass.core.manager.NostrClient", FakeNostrClient) pm = PasswordManager.__new__(PasswordManager) pm.encryption_mode = EncryptionMode.SEED_ONLY diff --git a/src/tests/test_manifest_state_restore.py b/src/tests/test_manifest_state_restore.py new file mode 100644 index 0000000..c28fad7 --- /dev/null +++ b/src/tests/test_manifest_state_restore.py @@ -0,0 +1,70 @@ +import asyncio +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import create_vault, dummy_nostr_client, TEST_SEED + +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.state_manager import StateManager +from seedpass.core.manager import PasswordManager, EncryptionMode + + +def _init_pm(dir_path: Path, client) -> PasswordManager: + vault, enc_mgr = create_vault(dir_path) + cfg_mgr = ConfigManager(vault, dir_path) + backup_mgr = BackupManager(dir_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + state_mgr = StateManager(dir_path) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.config_manager = cfg_mgr + pm.state_manager = state_mgr + pm.nostr_client = client + pm.fingerprint_dir = dir_path + pm.current_fingerprint = "fp" + pm.parent_seed = TEST_SEED + pm.is_dirty = False + return pm + + +def test_manifest_state_restored(monkeypatch, tmp_path): + client, relay = dummy_nostr_client.__wrapped__(tmp_path / "c1", monkeypatch) + with TemporaryDirectory() as tmpdir: + fp_dir = Path(tmpdir) + pm1 = _init_pm(fp_dir, client) + pm1.entry_manager.add_entry("site", 8) + result = pm1.sync_vault() + manifest_id = relay.manifests[-1].tags[0] + state = pm1.state_manager.state + delta_ts = state["delta_since"] + assert state["manifest_id"] == manifest_id + assert delta_ts > 0 + assert result["manifest_id"] == manifest_id + + client2, _ = dummy_nostr_client.__wrapped__(tmp_path / "c2", monkeypatch) + monkeypatch.setattr( + "seedpass.core.manager.NostrClient", lambda *a, **k: client2 + ) + + pm2 = PasswordManager.__new__(PasswordManager) + pm2.encryption_mode = EncryptionMode.SEED_ONLY + vault2, enc_mgr2 = create_vault(fp_dir) + pm2.encryption_manager = enc_mgr2 + pm2.vault = vault2 + pm2.fingerprint_dir = fp_dir + pm2.current_fingerprint = "fp" + pm2.parent_seed = TEST_SEED + pm2.bip85 = None + pm2.initialize_managers() + + assert pm2.nostr_client is client2 + assert pm2.nostr_client.get_current_manifest_id() == manifest_id + assert pm2.nostr_client.get_current_manifest().delta_since == delta_ts + assert pm2.last_sync_ts == delta_ts diff --git a/src/tests/test_migrations.py b/src/tests/test_migrations.py index 2371203..7f361ad 100644 --- a/src/tests/test_migrations.py +++ b/src/tests/test_migrations.py @@ -5,7 +5,7 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.migrations import LATEST_VERSION +from seedpass.core.migrations import LATEST_VERSION def setup(tmp_path: Path): diff --git a/src/tests/test_modify_totp_entry.py b/src/tests/test_modify_totp_entry.py index 8e038d6..262f95b 100644 --- a/src/tests/test_modify_totp_entry.py +++ b/src/tests/test_modify_totp_entry.py @@ -1,9 +1,9 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD import pytest -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def test_modify_totp_entry_period_digits_and_archive(tmp_path): diff --git a/src/tests/test_multiple_deltas_sync.py b/src/tests/test_multiple_deltas_sync.py new file mode 100644 index 0000000..3b2b2f7 --- /dev/null +++ b/src/tests/test_multiple_deltas_sync.py @@ -0,0 +1,96 @@ +import asyncio +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import create_vault, dummy_nostr_client + +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode + + +def _init_pm(dir_path: Path, client) -> PasswordManager: + vault, enc_mgr = create_vault(dir_path) + cfg_mgr = ConfigManager(vault, dir_path) + backup_mgr = BackupManager(dir_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.config_manager = cfg_mgr + pm.nostr_client = client + pm.fingerprint_dir = dir_path + pm.is_dirty = False + return pm + + +def test_sync_applies_multiple_deltas(dummy_nostr_client): + client, relay = dummy_nostr_client + with TemporaryDirectory() as tmpdir: + base = Path(tmpdir) + dir_a = base / "A" + dir_b = base / "B" + dir_a.mkdir() + dir_b.mkdir() + + pm_a = _init_pm(dir_a, client) + pm_b = _init_pm(dir_b, client) + + # Initial snapshot from manager A + pm_a.entry_manager.add_entry("site1", 12) + pm_a.sync_vault() + manifest_id = relay.manifests[-1].tags[0] + + # Manager B downloads snapshot + assert pm_b.attempt_initial_sync() is True + + # Two deltas published sequentially + pm_a.entry_manager.add_entry("site2", 12) + delta1 = pm_a.vault.get_encrypted_index() or b"" + asyncio.run(client.publish_delta(delta1, manifest_id)) + + pm_a.entry_manager.add_entry("site3", 12) + delta2 = pm_a.vault.get_encrypted_index() or b"" + asyncio.run(client.publish_delta(delta2, manifest_id)) + + # B syncs and should apply both deltas + pm_b.sync_index_from_nostr() + pm_b.entry_manager.clear_cache() + labels = [e[1] for e in pm_b.entry_manager.list_entries()] + assert sorted(labels) == ["site1", "site2", "site3"] + + +def test_initial_sync_applies_multiple_deltas(dummy_nostr_client): + client, relay = dummy_nostr_client + with TemporaryDirectory() as tmpdir: + base = Path(tmpdir) + dir_a = base / "A" + dir_b = base / "B" + dir_a.mkdir() + dir_b.mkdir() + + pm_a = _init_pm(dir_a, client) + pm_b = _init_pm(dir_b, client) + + pm_a.entry_manager.add_entry("site1", 12) + pm_a.sync_vault() + manifest_id = relay.manifests[-1].tags[0] + + pm_a.entry_manager.add_entry("site2", 12) + delta1 = pm_a.vault.get_encrypted_index() or b"" + asyncio.run(client.publish_delta(delta1, manifest_id)) + + pm_a.entry_manager.add_entry("site3", 12) + delta2 = pm_a.vault.get_encrypted_index() or b"" + asyncio.run(client.publish_delta(delta2, manifest_id)) + + # Initial sync after both deltas published + assert pm_b.attempt_initial_sync() is True + pm_b.entry_manager.clear_cache() + labels = [e[1] for e in pm_b.entry_manager.list_entries()] + assert sorted(labels) == ["site1", "site2", "site3"] diff --git a/src/tests/test_multiple_fingerprint_prompt.py b/src/tests/test_multiple_fingerprint_prompt.py index f065ac6..c36dfaf 100644 --- a/src/tests/test_multiple_fingerprint_prompt.py +++ b/src/tests/test_multiple_fingerprint_prompt.py @@ -3,7 +3,7 @@ from pathlib import Path from tempfile import TemporaryDirectory import constants -import password_manager.manager as manager_module +import seedpass.core.manager as manager_module from utils.fingerprint_manager import FingerprintManager from helpers import TEST_SEED diff --git a/src/tests/test_noninteractive_init_unlock.py b/src/tests/test_noninteractive_init_unlock.py new file mode 100644 index 0000000..b696905 --- /dev/null +++ b/src/tests/test_noninteractive_init_unlock.py @@ -0,0 +1,84 @@ +import importlib +import bcrypt +from pathlib import Path +from tempfile import TemporaryDirectory + +import constants +import seedpass.core.manager as manager_module +from utils.fingerprint_manager import FingerprintManager +from seedpass.core.config_manager import ConfigManager +from tests.helpers import TEST_SEED, TEST_PASSWORD, create_vault + + +def test_init_with_password(monkeypatch): + with TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + monkeypatch.setattr(Path, "home", lambda: tmp) + importlib.reload(constants) + importlib.reload(manager_module) + + fm = FingerprintManager(constants.APP_DIR) + fp = fm.add_fingerprint(TEST_SEED) + dir_path = constants.APP_DIR / fp + vault, _enc = create_vault(dir_path, TEST_SEED, TEST_PASSWORD) + cfg = ConfigManager(vault, dir_path) + cfg.set_password_hash( + bcrypt.hashpw(TEST_PASSWORD.encode(), bcrypt.gensalt()).decode() + ) + cfg.set_kdf_iterations(100_000) + + called = {} + + def fake_setup(self, path, pw=None, **_): + called["password"] = pw + return True + + monkeypatch.setattr( + manager_module.PasswordManager, "initialize_bip85", lambda self: None + ) + monkeypatch.setattr( + manager_module.PasswordManager, "initialize_managers", lambda self: None + ) + monkeypatch.setattr( + manager_module.PasswordManager, "setup_encryption_manager", fake_setup + ) + + pm = manager_module.PasswordManager(fingerprint=fp, password=TEST_PASSWORD) + assert called["password"] == TEST_PASSWORD + + +def test_unlock_with_password(monkeypatch): + with TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + monkeypatch.setattr(Path, "home", lambda: tmp) + importlib.reload(constants) + importlib.reload(manager_module) + + fm = FingerprintManager(constants.APP_DIR) + fp = fm.add_fingerprint(TEST_SEED) + dir_path = constants.APP_DIR / fp + vault, _enc = create_vault(dir_path, TEST_SEED, TEST_PASSWORD) + cfg = ConfigManager(vault, dir_path) + cfg.set_password_hash( + bcrypt.hashpw(TEST_PASSWORD.encode(), bcrypt.gensalt()).decode() + ) + + pm = manager_module.PasswordManager.__new__(manager_module.PasswordManager) + pm.fingerprint_dir = dir_path + pm.config_manager = cfg + pm.locked = True + called = {} + + def fake_setup(path, pw=None): + called["password"] = pw + + monkeypatch.setattr( + manager_module.PasswordManager, "initialize_bip85", lambda self: None + ) + monkeypatch.setattr( + manager_module.PasswordManager, "initialize_managers", lambda self: None + ) + pm.setup_encryption_manager = fake_setup + + pm.unlock_vault(TEST_PASSWORD) + assert called["password"] == TEST_PASSWORD diff --git a/src/tests/test_nostr_backup.py b/src/tests/test_nostr_backup.py index b4ca998..f2f56c3 100644 --- a/src/tests/test_nostr_backup.py +++ b/src/tests/test_nostr_backup.py @@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager from nostr.client import NostrClient diff --git a/src/tests/test_nostr_client.py b/src/tests/test_nostr_client.py index c3a6e9a..1aa998f 100644 --- a/src/tests/test_nostr_client.py +++ b/src/tests/test_nostr_client.py @@ -9,9 +9,10 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager from nostr.client import NostrClient import nostr.client as nostr_client +import constants def test_nostr_client_uses_custom_relays(): @@ -151,3 +152,31 @@ def test_update_relays_reinitializes_pool(tmp_path, monkeypatch): assert called["ran"] is True assert isinstance(client.client, FakeAddRelaysClient) assert client.relays == new_relays + + +def test_retrieve_json_sync_backoff(tmp_path, monkeypatch): + client = _setup_client(tmp_path, FakeAddRelayClient) + + monkeypatch.setattr("nostr.client.MAX_RETRIES", 3) + monkeypatch.setattr("nostr.client.RETRY_DELAY", 1) + monkeypatch.setattr("constants.MAX_RETRIES", 3) + monkeypatch.setattr("constants.RETRY_DELAY", 1) + monkeypatch.setattr("seedpass.core.config_manager.MAX_RETRIES", 3) + monkeypatch.setattr("seedpass.core.config_manager.RETRY_DELAY", 1) + + sleeps: list[float] = [] + + def fake_sleep(d): + sleeps.append(d) + + monkeypatch.setattr(nostr_client.time, "sleep", fake_sleep) + + async def fake_async(self): + return None + + monkeypatch.setattr(NostrClient, "_retrieve_json_from_nostr", fake_async) + + result = client.retrieve_json_from_nostr_sync() + + assert result is None + assert sleeps == [1, 2] diff --git a/src/tests/test_nostr_contract.py b/src/tests/test_nostr_contract.py index 5501be0..be56e58 100644 --- a/src/tests/test_nostr_contract.py +++ b/src/tests/test_nostr_contract.py @@ -8,7 +8,7 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager from nostr.client import NostrClient, Manifest diff --git a/src/tests/test_nostr_dummy_client.py b/src/tests/test_nostr_dummy_client.py index 5284a1e..763e5b0 100644 --- a/src/tests/test_nostr_dummy_client.py +++ b/src/tests/test_nostr_dummy_client.py @@ -1,13 +1,15 @@ import asyncio import gzip import math +import pytest -from helpers import create_vault, dummy_nostr_client -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from helpers import create_vault, dummy_nostr_client, TEST_SEED +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager from nostr.client import prepare_snapshot from nostr.backup_models import KIND_SNAPSHOT_CHUNK +import constants def test_manifest_generation(tmp_path): @@ -54,7 +56,7 @@ def test_publish_and_fetch_deltas(dummy_nostr_client): client, relay = dummy_nostr_client base = b"base" manifest, _ = asyncio.run(client.publish_snapshot(base)) - manifest_id = relay.manifests[-1].id + manifest_id = relay.manifests[-1].tags[0] d1 = b"d1" d2 = b"d2" asyncio.run(client.publish_delta(d1, manifest_id)) @@ -73,7 +75,17 @@ def test_fetch_snapshot_fallback_on_missing_chunk(dummy_nostr_client, monkeypatc client, relay = dummy_nostr_client monkeypatch.setattr("nostr.client.MAX_RETRIES", 3) - monkeypatch.setattr("nostr.client.RETRY_DELAY", 0) + monkeypatch.setattr("nostr.client.RETRY_DELAY", 1) + monkeypatch.setattr("constants.MAX_RETRIES", 3) + monkeypatch.setattr("constants.RETRY_DELAY", 1) + monkeypatch.setattr("seedpass.core.config_manager.MAX_RETRIES", 3) + monkeypatch.setattr("seedpass.core.config_manager.RETRY_DELAY", 1) + delays: list[float] = [] + + async def fake_sleep(d): + delays.append(d) + + monkeypatch.setattr("nostr.client.asyncio.sleep", fake_sleep) data1 = os.urandom(60000) manifest1, _ = asyncio.run(client.publish_snapshot(data1)) @@ -88,12 +100,9 @@ def test_fetch_snapshot_fallback_on_missing_chunk(dummy_nostr_client, monkeypatc relay.filters.clear() - fetched_manifest, chunk_bytes = asyncio.run(client.fetch_latest_snapshot()) + result = asyncio.run(client.fetch_latest_snapshot()) - assert gzip.decompress(b"".join(chunk_bytes)) == data1 - assert [c.event_id for c in fetched_manifest.chunks] == [ - c.event_id for c in manifest1.chunks - ] + assert result is None attempts = sum( 1 @@ -105,6 +114,7 @@ def test_fetch_snapshot_fallback_on_missing_chunk(dummy_nostr_client, monkeypatc ) ) assert attempts == 3 + assert delays == [1, 2] def test_fetch_snapshot_uses_event_ids(dummy_nostr_client): @@ -132,3 +142,44 @@ def test_fetch_snapshot_uses_event_ids(dummy_nostr_client): if getattr(f, "kind_val", None) == KIND_SNAPSHOT_CHUNK ] assert id_filters and all(id_filters) + + +def test_publish_delta_aborts_if_outdated(tmp_path, monkeypatch, dummy_nostr_client): + client1, relay = dummy_nostr_client + + from cryptography.fernet import Fernet + from nostr.client import NostrClient + from seedpass.core.encryption import EncryptionManager + + enc_mgr = EncryptionManager(Fernet.generate_key(), tmp_path) + + class DummyKeys: + def private_key_hex(self): + return "1" * 64 + + def public_key_hex(self): + return "2" * 64 + + class DummyKeyManager: + def __init__(self, *a, **k): + self.keys = DummyKeys() + + with pytest.MonkeyPatch().context() as mp: + mp.setattr("nostr.client.KeyManager", DummyKeyManager) + mp.setattr(enc_mgr, "decrypt_parent_seed", lambda: TEST_SEED) + client2 = NostrClient(enc_mgr, "fp") + + base = b"base" + manifest, _ = asyncio.run(client1.publish_snapshot(base)) + with client1._state_lock: + client1.current_manifest.delta_since = 0 + import copy + + with client2._state_lock: + client2.current_manifest = copy.deepcopy(manifest) + client2.current_manifest_id = manifest_id = relay.manifests[-1].tags[0] + + asyncio.run(client2.publish_delta(b"d1", manifest_id)) + + with pytest.raises(RuntimeError): + asyncio.run(client1.publish_delta(b"d2", manifest_id)) diff --git a/src/tests/test_nostr_entry.py b/src/tests/test_nostr_entry.py index c049850..b8e1edb 100644 --- a/src/tests/test_nostr_entry.py +++ b/src/tests/test_nostr_entry.py @@ -8,10 +8,10 @@ from nostr.coincurve_keys import Keys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager def test_nostr_key_determinism(): diff --git a/src/tests/test_nostr_index_size.py b/src/tests/test_nostr_index_size.py index 4277a76..a7598a2 100644 --- a/src/tests/test_nostr_index_size.py +++ b/src/tests/test_nostr_index_size.py @@ -15,11 +15,11 @@ import os sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.encryption import EncryptionManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager from nostr.client import NostrClient, Kind, KindStandard diff --git a/src/tests/test_nostr_qr.py b/src/tests/test_nostr_qr.py index 0ad7fe3..1d032aa 100644 --- a/src/tests/test_nostr_qr.py +++ b/src/tests/test_nostr_qr.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode, TotpManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode, TotpManager +from seedpass.core.config_manager import ConfigManager from utils.color_scheme import color_text @@ -49,7 +49,7 @@ def test_show_qr_for_nostr_keys(monkeypatch): monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) called = [] monkeypatch.setattr( - "password_manager.manager.TotpManager.print_qr_code", + "seedpass.core.manager.TotpManager.print_qr_code", lambda data: called.append(data), ) @@ -85,7 +85,7 @@ def test_show_private_key_qr(monkeypatch, capsys): monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) called = [] monkeypatch.setattr( - "password_manager.manager.TotpManager.print_qr_code", + "seedpass.core.manager.TotpManager.print_qr_code", lambda data: called.append(data), ) @@ -130,7 +130,7 @@ def test_qr_menu_case_insensitive(monkeypatch): monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) called = [] monkeypatch.setattr( - "password_manager.manager.TotpManager.print_qr_code", + "seedpass.core.manager.TotpManager.print_qr_code", lambda data: called.append(data), ) diff --git a/src/tests/test_nostr_real.py b/src/tests/test_nostr_real.py index 0226626..14c2e89 100644 --- a/src/tests/test_nostr_real.py +++ b/src/tests/test_nostr_real.py @@ -13,7 +13,7 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager from nostr.client import NostrClient diff --git a/src/tests/test_nostr_snapshot.py b/src/tests/test_nostr_snapshot.py index b466f46..c1d642c 100644 --- a/src/tests/test_nostr_snapshot.py +++ b/src/tests/test_nostr_snapshot.py @@ -9,7 +9,7 @@ import asyncio from unittest.mock import patch from nostr import prepare_snapshot, NostrClient -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager def test_prepare_snapshot_roundtrip(): diff --git a/src/tests/test_offline_mode_behavior.py b/src/tests/test_offline_mode_behavior.py index 0480207..df9d1ef 100644 --- a/src/tests/test_offline_mode_behavior.py +++ b/src/tests/test_offline_mode_behavior.py @@ -1,7 +1,7 @@ import time from types import SimpleNamespace -from password_manager.manager import PasswordManager +from seedpass.core.manager import PasswordManager def test_sync_vault_skips_network(monkeypatch): diff --git a/src/tests/test_parent_seed_backup.py b/src/tests/test_parent_seed_backup.py index ff379a6..a25fd4a 100644 --- a/src/tests/test_parent_seed_backup.py +++ b/src/tests/test_parent_seed_backup.py @@ -6,7 +6,7 @@ import queue sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.manager import PasswordManager, EncryptionMode from constants import DEFAULT_SEED_BACKUP_FILENAME @@ -24,12 +24,9 @@ def _make_pm(tmp_path: Path) -> PasswordManager: def test_handle_backup_reveal_parent_seed_confirm(monkeypatch, tmp_path, capsys): pm = _make_pm(tmp_path) - monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", lambda *_: "pw" - ) confirms = iter([True, True]) monkeypatch.setattr( - "password_manager.manager.confirm_action", lambda *_a, **_k: next(confirms) + "seedpass.core.manager.confirm_action", lambda *_a, **_k: next(confirms) ) saved = [] @@ -39,7 +36,7 @@ def test_handle_backup_reveal_parent_seed_confirm(monkeypatch, tmp_path, capsys) pm.encryption_manager = SimpleNamespace(encrypt_and_save_file=fake_save) monkeypatch.setattr(builtins, "input", lambda *_: "mybackup.enc") - pm.handle_backup_reveal_parent_seed() + pm.handle_backup_reveal_parent_seed(password="pw") out = capsys.readouterr().out assert "seed phrase" in out @@ -50,18 +47,13 @@ def test_handle_backup_reveal_parent_seed_confirm(monkeypatch, tmp_path, capsys) def test_handle_backup_reveal_parent_seed_cancel(monkeypatch, tmp_path, capsys): pm = _make_pm(tmp_path) - monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", lambda *_: "pw" - ) - monkeypatch.setattr( - "password_manager.manager.confirm_action", lambda *_a, **_k: False - ) + monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: False) saved = [] pm.encryption_manager = SimpleNamespace( encrypt_and_save_file=lambda data, path: saved.append((data, path)) ) - pm.handle_backup_reveal_parent_seed() + pm.handle_backup_reveal_parent_seed(password="pw") out = capsys.readouterr().out assert "seed phrase" not in out diff --git a/src/tests/test_password_change.py b/src/tests/test_password_change.py index 7401559..efe001d 100644 --- a/src/tests/test_password_change.py +++ b/src/tests/test_password_change.py @@ -8,11 +8,11 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.config_manager import ConfigManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.entry_management import EntryManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.manager import PasswordManager, EncryptionMode def test_change_password_triggers_nostr_backup(monkeypatch): @@ -36,16 +36,9 @@ def test_change_password_triggers_nostr_backup(monkeypatch): pm.store_hashed_password = lambda pw: None pm.verify_password = lambda pw: True - monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", lambda *_: "old" - ) - monkeypatch.setattr( - "password_manager.manager.prompt_for_password", lambda: "new" - ) - - with patch("password_manager.manager.NostrClient") as MockClient: + with patch("seedpass.core.manager.NostrClient") as MockClient: mock_instance = MockClient.return_value mock_instance.publish_snapshot = AsyncMock(return_value=(None, "abcd")) pm.nostr_client = mock_instance - pm.change_password() + pm.change_password("old", "new") mock_instance.publish_snapshot.assert_called_once() diff --git a/src/tests/test_password_generation_policy.py b/src/tests/test_password_generation_policy.py index 5384075..a4df419 100644 --- a/src/tests/test_password_generation_policy.py +++ b/src/tests/test_password_generation_policy.py @@ -4,7 +4,7 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.password_generation import PasswordGenerator, PasswordPolicy +from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy class DummyEnc: diff --git a/src/tests/test_password_helpers.py b/src/tests/test_password_helpers.py index d6f661c..080e363 100644 --- a/src/tests/test_password_helpers.py +++ b/src/tests/test_password_helpers.py @@ -1,5 +1,5 @@ import string -from password_manager.password_generation import PasswordGenerator, PasswordPolicy +from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy class DummyEnc: diff --git a/src/tests/test_password_length_constraints.py b/src/tests/test_password_length_constraints.py index eaa4941..a800f9f 100644 --- a/src/tests/test_password_length_constraints.py +++ b/src/tests/test_password_length_constraints.py @@ -4,7 +4,7 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.password_generation import PasswordGenerator, PasswordPolicy +from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy from constants import MIN_PASSWORD_LENGTH diff --git a/src/tests/test_password_properties.py b/src/tests/test_password_properties.py index 60fce89..0c5f2ba 100644 --- a/src/tests/test_password_properties.py +++ b/src/tests/test_password_properties.py @@ -5,8 +5,8 @@ from hypothesis import given, strategies as st, settings sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.password_generation import PasswordGenerator, PasswordPolicy -from password_manager.entry_types import EntryType +from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy +from seedpass.core.entry_types import EntryType class DummyEnc: diff --git a/src/tests/test_password_unlock_after_change.py b/src/tests/test_password_unlock_after_change.py index 114b7f1..a0bda0a 100644 --- a/src/tests/test_password_unlock_after_change.py +++ b/src/tests/test_password_unlock_after_change.py @@ -7,12 +7,12 @@ import bcrypt sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.encryption import EncryptionManager +from seedpass.core.vault import Vault +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode from utils.key_derivation import derive_index_key, derive_key_from_password SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" @@ -61,29 +61,27 @@ def test_password_change_and_unlock(monkeypatch): ) monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", lambda *_: old_pw + "seedpass.core.manager.prompt_existing_password", lambda *_: old_pw ) + monkeypatch.setattr("seedpass.core.manager.prompt_for_password", lambda: new_pw) monkeypatch.setattr( - "password_manager.manager.prompt_for_password", lambda: new_pw - ) - monkeypatch.setattr( - "password_manager.manager.NostrClient", + "seedpass.core.manager.NostrClient", lambda *a, **kw: SimpleNamespace( publish_snapshot=lambda *a, **k: (None, "abcd") ), ) - pm.change_password() + pm.change_password(old_pw, new_pw) pm.lock_vault() monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", lambda *_: new_pw + "seedpass.core.manager.prompt_existing_password", lambda *_: new_pw ) monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None) monkeypatch.setattr(PasswordManager, "initialize_managers", lambda self: None) monkeypatch.setattr(PasswordManager, "sync_index_from_nostr", lambda self: None) - pm.unlock_vault() + pm.unlock_vault(new_pw) assert pm.parent_seed == SEED assert pm.verify_password(new_pw) diff --git a/src/tests/test_pgp_entry.py b/src/tests/test_pgp_entry.py index 494d4d5..c1fd37f 100644 --- a/src/tests/test_pgp_entry.py +++ b/src/tests/test_pgp_entry.py @@ -6,9 +6,9 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def test_pgp_key_determinism(): diff --git a/src/tests/test_portable_backup.py b/src/tests/test_portable_backup.py index dc8910b..7e1b0ff 100644 --- a/src/tests/test_portable_backup.py +++ b/src/tests/test_portable_backup.py @@ -9,11 +9,11 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.portable_backup import export_backup, import_backup +from seedpass.core.encryption import EncryptionManager +from seedpass.core.vault import Vault +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.portable_backup import export_backup, import_backup from utils.key_derivation import derive_index_key, derive_key_from_password diff --git a/src/tests/test_profile_cleanup.py b/src/tests/test_profile_cleanup.py index 1959489..d85653f 100644 --- a/src/tests/test_profile_cleanup.py +++ b/src/tests/test_profile_cleanup.py @@ -11,7 +11,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) def setup_pm(tmp_path): import constants - import password_manager.manager as manager_module + import seedpass.core.manager as manager_module importlib.reload(constants) importlib.reload(manager_module) @@ -30,7 +30,7 @@ def test_generate_seed_cleanup_on_failure(monkeypatch): pm, const, mgr = setup_pm(tmp_path) - with patch("password_manager.manager.confirm_action", return_value=True): + with patch("seedpass.core.manager.confirm_action", return_value=True): monkeypatch.setattr( pm, "save_and_encrypt_seed", diff --git a/src/tests/test_profile_export_import.py b/src/tests/test_profile_export_import.py new file mode 100644 index 0000000..0929d95 --- /dev/null +++ b/src/tests/test_profile_export_import.py @@ -0,0 +1,33 @@ +from pathlib import Path +from types import SimpleNamespace + +from seedpass.core.api import VaultService +from helpers import create_vault, TEST_SEED, TEST_PASSWORD + + +def test_profile_export_import_round_trip(tmp_path): + dir1 = tmp_path / "a" + vault1, _ = create_vault(dir1, TEST_SEED, TEST_PASSWORD) + data = { + "schema_version": 4, + "entries": {"0": {"label": "example", "type": "password"}}, + } + vault1.save_index(data) + pm1 = SimpleNamespace(vault=vault1, sync_vault=lambda: None) + service1 = VaultService(pm1) + blob = service1.export_profile() + + dir2 = tmp_path / "b" + vault2, _ = create_vault(dir2, TEST_SEED, TEST_PASSWORD) + vault2.save_index({"schema_version": 4, "entries": {}}) + called = {} + + def sync(): + called["synced"] = True + + pm2 = SimpleNamespace(vault=vault2, sync_vault=sync) + service2 = VaultService(pm2) + service2.import_profile(blob) + + assert called.get("synced") is True + assert vault2.load_index() == data diff --git a/src/tests/test_profile_init_integration.py b/src/tests/test_profile_init_integration.py index 62c291a..484447e 100644 --- a/src/tests/test_profile_init_integration.py +++ b/src/tests/test_profile_init_integration.py @@ -3,7 +3,7 @@ import importlib.util from pathlib import Path from tempfile import TemporaryDirectory -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.manager import PasswordManager, EncryptionMode def load_script(): @@ -33,7 +33,7 @@ def test_initialize_profile_and_manager(monkeypatch): pm.current_fingerprint = fingerprint monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", + "seedpass.core.manager.prompt_existing_password", lambda *_: gtp.DEFAULT_PASSWORD, ) monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None) diff --git a/src/tests/test_profile_management.py b/src/tests/test_profile_management.py index 7665b87..e5169a4 100644 --- a/src/tests/test_profile_management.py +++ b/src/tests/test_profile_management.py @@ -11,12 +11,12 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from utils.fingerprint_manager import FingerprintManager import constants -import password_manager.manager as manager_module -from password_manager.vault import Vault -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import EncryptionMode -from password_manager.config_manager import ConfigManager +import seedpass.core.manager as manager_module +from seedpass.core.vault import Vault +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import EncryptionMode +from seedpass.core.config_manager import ConfigManager def test_add_and_delete_entry(monkeypatch): diff --git a/src/tests/test_profiles.py b/src/tests/test_profiles.py index aec32c8..35e05db 100644 --- a/src/tests/test_profiles.py +++ b/src/tests/test_profiles.py @@ -5,7 +5,7 @@ from tempfile import TemporaryDirectory sys.path.append(str(Path(__file__).resolve().parents[1])) from utils.fingerprint_manager import FingerprintManager -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.manager import PasswordManager, EncryptionMode from helpers import create_vault, dummy_nostr_client import gzip from nostr.backup_models import Manifest, ChunkMeta @@ -31,10 +31,6 @@ def test_add_and_switch_fingerprint(monkeypatch): pm.current_fingerprint = None monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "1") - monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", - lambda *_a, **_k: "pass", - ) monkeypatch.setattr( PasswordManager, "setup_encryption_manager", @@ -47,10 +43,10 @@ def test_add_and_switch_fingerprint(monkeypatch): PasswordManager, "sync_index_from_nostr_if_missing", lambda self: None ) monkeypatch.setattr( - "password_manager.manager.NostrClient", lambda *a, **kw: object() + "seedpass.core.manager.NostrClient", lambda *a, **kw: object() ) - assert pm.handle_switch_fingerprint() + assert pm.handle_switch_fingerprint(password="pass") assert pm.current_fingerprint == fingerprint assert fm.current_fingerprint == fingerprint assert pm.fingerprint_dir == expected_dir diff --git a/src/tests/test_publish_json_result.py b/src/tests/test_publish_json_result.py index 0abc648..5172427 100644 --- a/src/tests/test_publish_json_result.py +++ b/src/tests/test_publish_json_result.py @@ -9,7 +9,7 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager from nostr.client import NostrClient, Manifest @@ -84,7 +84,7 @@ def test_publish_snapshot_success(): ) as mock_send: manifest, event_id = asyncio.run(client.publish_snapshot(b"data")) assert isinstance(manifest, Manifest) - assert event_id == "abcd" + assert event_id == "seedpass-manifest-fp" assert mock_send.await_count >= 1 diff --git a/src/tests/test_pubsub.py b/src/tests/test_pubsub.py new file mode 100644 index 0000000..7cf21c9 --- /dev/null +++ b/src/tests/test_pubsub.py @@ -0,0 +1,28 @@ +from seedpass.core.pubsub import PubSub + + +def test_subscribe_and_publish(): + bus = PubSub() + calls = [] + + def handler(arg): + calls.append(arg) + + bus.subscribe("event", handler) + bus.publish("event", 123) + + assert calls == [123] + + +def test_unsubscribe(): + bus = PubSub() + calls = [] + + def handler(): + calls.append(True) + + bus.subscribe("event", handler) + bus.unsubscribe("event", handler) + bus.publish("event") + + assert calls == [] diff --git a/src/tests/test_retrieve_pause_sensitive_entries.py b/src/tests/test_retrieve_pause_sensitive_entries.py index dcb719b..09d3852 100644 --- a/src/tests/test_retrieve_pause_sensitive_entries.py +++ b/src/tests/test_retrieve_pause_sensitive_entries.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager import pytest @@ -45,13 +45,13 @@ def test_pause_before_entry_actions(monkeypatch, adder, needs_confirm): pause_calls = [] monkeypatch.setattr( - "password_manager.manager.pause", lambda *a, **k: pause_calls.append(True) + "seedpass.core.manager.pause", lambda *a, **k: pause_calls.append(True) ) monkeypatch.setattr(pm, "_entry_actions_menu", lambda *a, **k: None) monkeypatch.setattr("builtins.input", lambda *a, **k: str(index)) if needs_confirm: monkeypatch.setattr( - "password_manager.manager.confirm_action", lambda *a, **k: True + "seedpass.core.manager.confirm_action", lambda *a, **k: True ) pm.handle_retrieve_entry() diff --git a/src/tests/test_search_entries.py b/src/tests/test_search_entries.py index 86e6f35..9db94a2 100644 --- a/src/tests/test_search_entries.py +++ b/src/tests/test_search_entries.py @@ -6,9 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.entry_types import EntryType def setup_entry_manager(tmp_path: Path) -> EntryManager: @@ -64,11 +65,12 @@ def test_search_by_notes_and_totp(): idx_totp = entry_mgr.search_entries("GH")[0][0] entry_mgr.modify_entry(idx_totp, notes="otp note") + # notes are no longer searchable res_notes = entry_mgr.search_entries("secret") - assert res_notes == [(idx_pw, "Site", "", "", False)] + assert res_notes == [] res_totp = entry_mgr.search_entries("otp") - assert res_totp == [(idx_totp, "GH", None, None, False)] + assert res_totp == [] def test_search_by_custom_field(): @@ -83,7 +85,7 @@ def test_search_by_custom_field(): idx = entry_mgr.add_entry("Example", 8, custom_fields=custom) result = entry_mgr.search_entries("secret123") - assert result == [(idx, "Example", "", "", False)] + assert result == [] def test_search_key_value_value(): @@ -94,7 +96,7 @@ def test_search_key_value_value(): idx = entry_mgr.add_key_value("API", "token123") result = entry_mgr.search_entries("token123") - assert result == [(idx, "API", None, None, False)] + assert result == [] def test_search_no_results(): @@ -128,3 +130,21 @@ def test_search_by_tag_totp(): result = entry_mgr.search_entries("mfa") assert result == [(idx, "OTPAccount", None, None, False)] + + +def test_search_with_kind_filter(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + entry_mgr = setup_entry_manager(tmp_path) + + idx_pw = entry_mgr.add_entry("Site", 8) + entry_mgr.add_totp("OTP", TEST_SEED) + idx_totp = entry_mgr.search_entries("OTP")[0][0] + + all_results = entry_mgr.search_entries( + "", kinds=[EntryType.PASSWORD.value, EntryType.TOTP.value] + ) + assert {r[0] for r in all_results} == {idx_pw, idx_totp} + + only_pw = entry_mgr.search_entries("", kinds=[EntryType.PASSWORD.value]) + assert only_pw == [(idx_pw, "Site", "", "", False)] diff --git a/src/tests/test_secret_mode.py b/src/tests/test_secret_mode.py index 3a524d7..6c4339d 100644 --- a/src/tests/test_secret_mode.py +++ b/src/tests/test_secret_mode.py @@ -8,10 +8,10 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager def setup_pm(tmp_path): @@ -45,7 +45,7 @@ def test_password_retrieve_secret_mode(monkeypatch, capsys): monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) called = [] monkeypatch.setattr( - "password_manager.manager.copy_to_clipboard", + "seedpass.core.manager.copy_to_clipboard", lambda text, t: called.append((text, t)), ) @@ -67,12 +67,12 @@ def test_totp_display_secret_mode(monkeypatch, capsys): pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 30 ) monkeypatch.setattr( - "password_manager.manager.timed_input", + "seedpass.core.manager.timed_input", lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()), ) called = [] monkeypatch.setattr( - "password_manager.manager.copy_to_clipboard", + "seedpass.core.manager.copy_to_clipboard", lambda text, t: called.append((text, t)), ) @@ -94,7 +94,7 @@ def test_password_retrieve_no_secret_mode(monkeypatch, capsys): monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) called = [] monkeypatch.setattr( - "password_manager.manager.copy_to_clipboard", + "seedpass.core.manager.copy_to_clipboard", lambda *a, **k: called.append((a, k)), ) @@ -117,12 +117,12 @@ def test_totp_display_no_secret_mode(monkeypatch, capsys): pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 30 ) monkeypatch.setattr( - "password_manager.manager.timed_input", + "seedpass.core.manager.timed_input", lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()), ) called = [] monkeypatch.setattr( - "password_manager.manager.copy_to_clipboard", + "seedpass.core.manager.copy_to_clipboard", lambda *a, **k: called.append((a, k)), ) diff --git a/src/tests/test_seed_entry.py b/src/tests/test_seed_entry.py index d7d9d36..06665fe 100644 --- a/src/tests/test_seed_entry.py +++ b/src/tests/test_seed_entry.py @@ -7,10 +7,10 @@ from mnemonic import Mnemonic sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.password_generation import derive_seed_phrase +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.password_generation import derive_seed_phrase from local_bip85.bip85 import BIP85 from bip_utils import Bip39SeedGenerator diff --git a/src/tests/test_seed_generation.py b/src/tests/test_seed_generation.py index 99f6e57..eb6b6ca 100644 --- a/src/tests/test_seed_generation.py +++ b/src/tests/test_seed_generation.py @@ -10,7 +10,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) def setup_password_manager(): """Instantiate PasswordManager using a temporary APP_DIR without running __init__.""" import constants - import password_manager.manager as manager_module + import seedpass.core.manager as manager_module # Reload modules so constants use the mocked home directory importlib.reload(constants) @@ -34,7 +34,7 @@ def test_generate_bip85_and_new_seed(monkeypatch): mnemonic = pm.generate_bip85_seed() assert len(mnemonic.split()) == 12 - with patch("password_manager.manager.confirm_action", return_value=True): + with patch("seedpass.core.manager.confirm_action", return_value=True): fingerprint = pm.generate_new_seed() expected_dir = const.APP_DIR / fingerprint diff --git a/src/tests/test_seed_import.py b/src/tests/test_seed_import.py index eb6db7f..7cbbe6d 100644 --- a/src/tests/test_seed_import.py +++ b/src/tests/test_seed_import.py @@ -7,8 +7,8 @@ from mnemonic import Mnemonic sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.encryption import EncryptionManager +from seedpass.core.manager import PasswordManager, EncryptionMode def test_seed_encryption_round_trip(): diff --git a/src/tests/test_seed_migration.py b/src/tests/test_seed_migration.py index a273be3..845dfaa 100644 --- a/src/tests/test_seed_migration.py +++ b/src/tests/test_seed_migration.py @@ -7,7 +7,7 @@ from utils.key_derivation import derive_key_from_password sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager def test_parent_seed_migrates_from_fernet(tmp_path: Path) -> None: diff --git a/src/tests/test_seedqr_encoding.py b/src/tests/test_seedqr_encoding.py index 7d0b5e7..b986ee0 100644 --- a/src/tests/test_seedqr_encoding.py +++ b/src/tests/test_seedqr_encoding.py @@ -3,7 +3,7 @@ from pathlib import Path sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.seedqr import encode_seedqr +from seedpass.core.seedqr import encode_seedqr def test_seedqr_standard_example(): diff --git a/src/tests/test_settings_menu.py b/src/tests/test_settings_menu.py index 6899822..d7699d0 100644 --- a/src/tests/test_settings_menu.py +++ b/src/tests/test_settings_menu.py @@ -11,8 +11,8 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) import main from nostr.client import DEFAULT_RELAYS -from password_manager.config_manager import ConfigManager -from password_manager.vault import Vault +from seedpass.core.config_manager import ConfigManager +from seedpass.core.vault import Vault from utils.fingerprint_manager import FingerprintManager diff --git a/src/tests/test_ssh_entry.py b/src/tests/test_ssh_entry.py index f037437..b0d8a4d 100644 --- a/src/tests/test_ssh_entry.py +++ b/src/tests/test_ssh_entry.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager def test_add_and_retrieve_ssh_key_pair(): diff --git a/src/tests/test_ssh_entry_valid.py b/src/tests/test_ssh_entry_valid.py index 9b7ecad..c74945d 100644 --- a/src/tests/test_ssh_entry_valid.py +++ b/src/tests/test_ssh_entry_valid.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager from cryptography.hazmat.primitives import serialization diff --git a/src/tests/test_state_manager.py b/src/tests/test_state_manager.py new file mode 100644 index 0000000..71abe25 --- /dev/null +++ b/src/tests/test_state_manager.py @@ -0,0 +1,35 @@ +from tempfile import TemporaryDirectory +from pathlib import Path + +from seedpass.core.state_manager import StateManager +from nostr.client import DEFAULT_RELAYS + + +def test_state_manager_round_trip(): + with TemporaryDirectory() as tmpdir: + sm = StateManager(Path(tmpdir)) + state = sm.state + assert state["relays"] == list(DEFAULT_RELAYS) + assert state["last_bip85_idx"] == 0 + assert state["last_sync_ts"] == 0 + assert state["manifest_id"] is None + assert state["delta_since"] == 0 + + sm.add_relay("wss://example.com") + sm.update_state( + last_bip85_idx=5, + last_sync_ts=123, + manifest_id="mid", + delta_since=111, + ) + + sm2 = StateManager(Path(tmpdir)) + state2 = sm2.state + assert "wss://example.com" in state2["relays"] + assert state2["last_bip85_idx"] == 5 + assert state2["last_sync_ts"] == 123 + assert state2["manifest_id"] == "mid" + assert state2["delta_since"] == 111 + + sm2.remove_relay(1) # remove first default relay + assert len(sm2.list_relays()) == len(DEFAULT_RELAYS) diff --git a/src/tests/test_sync_race_conditions.py b/src/tests/test_sync_race_conditions.py new file mode 100644 index 0000000..0786659 --- /dev/null +++ b/src/tests/test_sync_race_conditions.py @@ -0,0 +1,98 @@ +import asyncio +import threading +from pathlib import Path + + +from helpers import create_vault, dummy_nostr_client + +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode + + +def _init_pm(dir_path: Path, client) -> PasswordManager: + vault, enc_mgr = create_vault(dir_path) + cfg_mgr = ConfigManager(vault, dir_path) + backup_mgr = BackupManager(dir_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.config_manager = cfg_mgr + pm.nostr_client = client + pm.fingerprint_dir = dir_path + pm.is_dirty = False + pm.state_manager = None + return pm + + +def test_sync_race_conditions(monkeypatch, tmp_path): + client_a, relay = dummy_nostr_client.__wrapped__(tmp_path / "c1", monkeypatch) + + from cryptography.fernet import Fernet + from nostr.client import NostrClient + from seedpass.core.encryption import EncryptionManager + from helpers import TEST_SEED + + enc_mgr = EncryptionManager(Fernet.generate_key(), tmp_path / "c2") + + class DummyKeys: + def private_key_hex(self): + return "1" * 64 + + def public_key_hex(self): + return "2" * 64 + + class DummyKeyManager: + def __init__(self, *a, **k): + self.keys = DummyKeys() + + monkeypatch.setattr("nostr.client.KeyManager", DummyKeyManager) + monkeypatch.setattr(enc_mgr, "decrypt_parent_seed", lambda: TEST_SEED) + client_b = NostrClient(enc_mgr, "fp") + + dir_a = tmp_path / "A" + dir_b = tmp_path / "B" + dir_a.mkdir() + dir_b.mkdir() + + pm_a = _init_pm(dir_a, client_a) + pm_b = _init_pm(dir_b, client_b) + + pm_a.entry_manager.add_entry("init", 12) + pm_a.sync_vault() + manifest_id = relay.manifests[-1].tags[0] + assert pm_b.attempt_initial_sync() is True + + pm_b.entry_manager.get_next_index = lambda: 2 + + def publish(pm: PasswordManager, client, label: str) -> None: + pm.entry_manager.add_entry(label, 12) + data = pm.vault.get_encrypted_index() or b"" + try: + asyncio.run(client.publish_delta(data, manifest_id)) + except RuntimeError: + pm.sync_index_from_nostr() + pm.entry_manager.clear_cache() + pm.entry_manager.add_entry(label, 12) + data = pm.vault.get_encrypted_index() or b"" + asyncio.run(client.publish_delta(data, manifest_id)) + + t1 = threading.Thread(target=publish, args=(pm_a, client_a, "from_a")) + t2 = threading.Thread(target=publish, args=(pm_b, client_b, "from_b")) + t1.start() + t2.start() + t1.join() + t2.join() + + assert len(relay.deltas) >= 1 + + pm_b.sync_index_from_nostr() + pm_b.entry_manager.clear_cache() + labels = [e[1] for e in pm_b.entry_manager.list_entries()] + assert "from_a" in labels and "from_b" in labels diff --git a/src/tests/test_tag_persistence.py b/src/tests/test_tag_persistence.py index 487fb3c..3de796f 100644 --- a/src/tests/test_tag_persistence.py +++ b/src/tests/test_tag_persistence.py @@ -6,9 +6,9 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def setup_entry_manager(tmp_path: Path) -> EntryManager: diff --git a/src/tests/test_totp.py b/src/tests/test_totp.py index ddaacd9..62ea473 100644 --- a/src/tests/test_totp.py +++ b/src/tests/test_totp.py @@ -7,7 +7,7 @@ from freezegun import freeze_time sys.path.append(str(Path(__file__).resolve().parents[1])) from helpers import TEST_SEED -from password_manager.totp import TotpManager +from seedpass.core.totp import TotpManager @freeze_time("1970-01-01 00:16:40") @@ -25,6 +25,6 @@ def test_time_remaining(): def test_print_progress_bar_terminates(monkeypatch): monkeypatch.setattr(TotpManager, "time_remaining", lambda period: 0) calls = [] - monkeypatch.setattr("password_manager.totp.time.sleep", lambda s: calls.append(s)) + monkeypatch.setattr("seedpass.core.totp.time.sleep", lambda s: calls.append(s)) TotpManager.print_progress_bar(period=30) assert calls == [] diff --git a/src/tests/test_totp_entry.py b/src/tests/test_totp_entry.py index 6eb0b12..eff4988 100644 --- a/src/tests/test_totp_entry.py +++ b/src/tests/test_totp_entry.py @@ -9,11 +9,11 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager -from password_manager.totp import TotpManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager +from seedpass.core.totp import TotpManager import pyotp diff --git a/src/tests/test_totp_uri.py b/src/tests/test_totp_uri.py index 26b8429..1970794 100644 --- a/src/tests/test_totp_uri.py +++ b/src/tests/test_totp_uri.py @@ -5,7 +5,7 @@ import pytest sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.totp import TotpManager +from seedpass.core.totp import TotpManager # Test parsing a normal otpauth URI with custom period and digits diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index 878fd0c..d49803c 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -8,7 +8,7 @@ from typer.testing import CliRunner from seedpass.cli import app, PasswordManager from seedpass import cli -from password_manager.entry_types import EntryType +from seedpass.core.entry_types import EntryType runner = CliRunner() @@ -34,7 +34,7 @@ def test_entry_list(monkeypatch): def test_entry_search(monkeypatch): pm = SimpleNamespace( entry_manager=SimpleNamespace( - search_entries=lambda q: [(1, "L", None, None, False)] + search_entries=lambda q, kinds=None: [(1, "L", None, None, False)] ), select_fingerprint=lambda fp: None, ) @@ -45,7 +45,7 @@ def test_entry_search(monkeypatch): def test_entry_get_password(monkeypatch): - def search(q): + def search(q, kinds=None): return [(2, "Example", "", "", False)] entry = {"type": EntryType.PASSWORD.value, "length": 8} @@ -68,72 +68,67 @@ def test_entry_get_password(monkeypatch): def test_vault_export(monkeypatch, tmp_path): called = {} - def export_db(path): - called["path"] = path + def export_profile(self): + called["export"] = True + return b"data" - pm = SimpleNamespace( - handle_export_database=export_db, select_fingerprint=lambda fp: None - ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli.VaultService, "export_profile", export_profile) + monkeypatch.setattr(cli, "PasswordManager", lambda: SimpleNamespace()) out_path = tmp_path / "out.json" result = runner.invoke(app, ["vault", "export", "--file", str(out_path)]) assert result.exit_code == 0 - assert called["path"] == out_path + assert called.get("export") is True + assert out_path.read_bytes() == b"data" def test_vault_import(monkeypatch, tmp_path): called = {} - def import_db(path): - called["path"] = path + def import_profile(self, data): + called["data"] = data - pm = SimpleNamespace( - handle_import_database=import_db, - select_fingerprint=lambda fp: None, - sync_vault=lambda: None, - ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli.VaultService, "import_profile", import_profile) + monkeypatch.setattr(cli, "PasswordManager", lambda: SimpleNamespace()) in_path = tmp_path / "in.json" - in_path.write_text("{}") + in_path.write_bytes(b"inp") result = runner.invoke(app, ["vault", "import", "--file", str(in_path)]) assert result.exit_code == 0 - assert called["path"] == in_path + assert called["data"] == b"inp" def test_vault_import_triggers_sync(monkeypatch, tmp_path): called = {} - def import_db(path): - called["path"] = path + def import_profile(self, data): + called["data"] = data + self._manager.sync_vault() - def sync(): + def sync_vault(): called["sync"] = True - pm = SimpleNamespace( - handle_import_database=import_db, - sync_vault=sync, - select_fingerprint=lambda fp: None, + monkeypatch.setattr(cli.VaultService, "import_profile", import_profile) + monkeypatch.setattr( + cli, "PasswordManager", lambda: SimpleNamespace(sync_vault=sync_vault) ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) in_path = tmp_path / "in.json" - in_path.write_text("{}") + in_path.write_bytes(b"inp") result = runner.invoke(app, ["vault", "import", "--file", str(in_path)]) assert result.exit_code == 0 - assert called["path"] == in_path + assert called.get("data") == b"inp" assert called.get("sync") is True def test_vault_change_password(monkeypatch): called = {} - def change_pw(): - called["called"] = True + def change_pw(old, new): + called["args"] = (old, new) pm = SimpleNamespace(change_password=change_pw, select_fingerprint=lambda fp: None) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) - result = runner.invoke(app, ["vault", "change-password"]) + result = runner.invoke(app, ["vault", "change-password"], input="old\nnew\nnew\n") assert result.exit_code == 0 - assert called.get("called") is True + assert called.get("args") == ("old", "new") def test_vault_lock(monkeypatch): @@ -153,10 +148,27 @@ def test_vault_lock(monkeypatch): assert pm.locked is True +def test_root_lock(monkeypatch): + called = {} + + def lock(): + called["locked"] = True + pm.locked = True + + pm = SimpleNamespace( + lock_vault=lock, locked=False, select_fingerprint=lambda fp: None + ) + monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + result = runner.invoke(app, ["lock"]) + assert result.exit_code == 0 + assert called.get("locked") is True + assert pm.locked is True + + def test_vault_reveal_parent_seed(monkeypatch, tmp_path): called = {} - def reveal(path=None): + def reveal(path=None, **_): called["path"] = path pm = SimpleNamespace( @@ -165,7 +177,9 @@ def test_vault_reveal_parent_seed(monkeypatch, tmp_path): monkeypatch.setattr(cli, "PasswordManager", lambda: pm) out_path = tmp_path / "seed.enc" result = runner.invoke( - app, ["vault", "reveal-parent-seed", "--file", str(out_path)] + app, + ["vault", "reveal-parent-seed", "--file", str(out_path)], + input="pw\n", ) assert result.exit_code == 0 assert called["path"] == out_path @@ -231,14 +245,14 @@ def test_fingerprint_remove(monkeypatch): def test_fingerprint_switch(monkeypatch): called = {} - def switch(fp): + def switch(fp, **_): called["fp"] = fp pm = SimpleNamespace( select_fingerprint=switch, fingerprint_manager=SimpleNamespace() ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) - result = runner.invoke(app, ["fingerprint", "switch", "def"]) + result = runner.invoke(app, ["fingerprint", "switch", "def"], input="pw\n") assert result.exit_code == 0 assert called.get("fp") == "def" @@ -363,7 +377,7 @@ def test_entry_add(monkeypatch): pm = SimpleNamespace( entry_manager=SimpleNamespace(add_entry=add_entry), select_fingerprint=lambda fp: None, - sync_vault=lambda: None, + start_background_vault_sync=lambda: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke( @@ -394,7 +408,7 @@ def test_entry_modify(monkeypatch): pm = SimpleNamespace( entry_manager=SimpleNamespace(modify_entry=modify_entry), select_fingerprint=lambda fp: None, - sync_vault=lambda: None, + start_background_vault_sync=lambda: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "modify", "1", "--username", "alice"]) @@ -409,7 +423,7 @@ def test_entry_modify_invalid(monkeypatch): pm = SimpleNamespace( entry_manager=SimpleNamespace(modify_entry=modify_entry), select_fingerprint=lambda fp: None, - sync_vault=lambda: None, + start_background_vault_sync=lambda: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "modify", "1", "--username", "alice"]) @@ -426,7 +440,7 @@ def test_entry_archive(monkeypatch): pm = SimpleNamespace( entry_manager=SimpleNamespace(archive_entry=archive_entry), select_fingerprint=lambda fp: None, - sync_vault=lambda: None, + start_background_vault_sync=lambda: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "archive", "3"]) @@ -444,7 +458,7 @@ def test_entry_unarchive(monkeypatch): pm = SimpleNamespace( entry_manager=SimpleNamespace(restore_entry=restore_entry), select_fingerprint=lambda fp: None, - sync_vault=lambda: None, + start_background_vault_sync=lambda: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "unarchive", "4"]) @@ -530,3 +544,55 @@ def test_tui_forward_fingerprint(monkeypatch): result = runner.invoke(app, ["--fingerprint", "abc"]) assert result.exit_code == 0 assert called.get("fp") == "abc" + + +def test_gui_command(monkeypatch): + called = {} + + def fake_main(): + called["called"] = True + + monkeypatch.setitem( + sys.modules, + "seedpass_gui.app", + SimpleNamespace(main=fake_main), + ) + monkeypatch.setattr(cli.importlib.util, "find_spec", lambda n: True) + result = runner.invoke(app, ["gui"]) + assert result.exit_code == 0 + assert called.get("called") is True + + +def test_gui_command_no_backend(monkeypatch): + """Install backend if missing and launch GUI.""" + + call_count = {"n": 0} + + def backend_available() -> bool: + call_count["n"] += 1 + return call_count["n"] > 1 + + monkeypatch.setattr(cli, "_gui_backend_available", backend_available) + + installed = {} + + def fake_check_call(cmd): + installed["cmd"] = cmd + + monkeypatch.setattr(cli.subprocess, "check_call", fake_check_call) + + called = {} + + def fake_main(): + called["gui"] = True + + monkeypatch.setitem( + sys.modules, + "seedpass_gui.app", + SimpleNamespace(main=fake_main), + ) + + result = runner.invoke(app, ["gui"]) + assert result.exit_code == 0 + assert installed.get("cmd") is not None + assert called.get("gui") is True diff --git a/src/tests/test_unlock_sync.py b/src/tests/test_unlock_sync.py index d618974..a2852c4 100644 --- a/src/tests/test_unlock_sync.py +++ b/src/tests/test_unlock_sync.py @@ -5,8 +5,8 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager -from password_manager import manager as manager_module +from seedpass.core.manager import PasswordManager +from seedpass.core import manager as manager_module def test_unlock_triggers_sync(monkeypatch, tmp_path): @@ -22,7 +22,7 @@ def test_unlock_triggers_sync(monkeypatch, tmp_path): monkeypatch.setattr(PasswordManager, "sync_index_from_nostr", fake_sync) - pm.unlock_vault() + pm.unlock_vault("pw") pm.start_background_sync() time.sleep(0.05) diff --git a/src/tests/test_v2_prefix_fallback.py b/src/tests/test_v2_prefix_fallback.py index 0d23cbf..6460409 100644 --- a/src/tests/test_v2_prefix_fallback.py +++ b/src/tests/test_v2_prefix_fallback.py @@ -7,7 +7,7 @@ from cryptography.fernet import InvalidToken from helpers import TEST_SEED from utils.key_derivation import derive_index_key -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager def test_v2_prefix_fernet_fallback(tmp_path: Path, caplog) -> None: @@ -18,7 +18,7 @@ def test_v2_prefix_fernet_fallback(tmp_path: Path, caplog) -> None: token = manager.fernet.encrypt(original) payload = b"V2:" + token - caplog.set_level(logging.WARNING, logger="password_manager.encryption") + caplog.set_level(logging.WARNING, logger="seedpass.core.encryption") decrypted = manager.decrypt_data(payload) assert decrypted == original @@ -31,7 +31,7 @@ def test_aesgcm_payload_too_short(tmp_path: Path, caplog) -> None: payload = b"V2:" + os.urandom(12) + b"short" - caplog.set_level(logging.ERROR, logger="password_manager.encryption") + caplog.set_level(logging.ERROR, logger="seedpass.core.encryption") with pytest.raises(InvalidToken, match="AES-GCM payload too short"): manager.decrypt_data(payload) diff --git a/src/tests/test_vault_initialization.py b/src/tests/test_vault_initialization.py index 38e90c8..f3b0d57 100644 --- a/src/tests/test_vault_initialization.py +++ b/src/tests/test_vault_initialization.py @@ -5,8 +5,8 @@ from unittest.mock import patch sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.vault import Vault +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.vault import Vault VALID_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" @@ -20,11 +20,9 @@ def test_save_and_encrypt_seed_initializes_vault(monkeypatch): pm.config_manager = None pm.current_fingerprint = "fp" + monkeypatch.setattr("seedpass.core.manager.prompt_for_password", lambda: "pw") monkeypatch.setattr( - "password_manager.manager.prompt_for_password", lambda: "pw" - ) - monkeypatch.setattr( - "password_manager.manager.NostrClient", lambda *a, **kw: object() + "seedpass.core.manager.NostrClient", lambda *a, **kw: object() ) pm.save_and_encrypt_seed(VALID_SEED, tmp_path) diff --git a/src/tests/test_vault_lock_event.py b/src/tests/test_vault_lock_event.py new file mode 100644 index 0000000..2f2aba0 --- /dev/null +++ b/src/tests/test_vault_lock_event.py @@ -0,0 +1,28 @@ +from seedpass.core.manager import PasswordManager +from seedpass.core.pubsub import bus + + +def test_lock_vault_publishes_event(): + pm = PasswordManager.__new__(PasswordManager) + pm.entry_manager = None + pm.encryption_manager = None + pm.password_generator = None + pm.backup_manager = None + pm.vault = None + pm.bip85 = None + pm.nostr_client = None + pm.config_manager = None + pm.locked = False + pm._parent_seed_secret = None + + called = [] + + def handler(): + called.append(True) + + bus.subscribe("vault_locked", handler) + pm.lock_vault() + bus.unsubscribe("vault_locked", handler) + + assert pm.locked + assert called == [True] diff --git a/src/tests/test_verbose_timing.py b/src/tests/test_verbose_timing.py index 79cd5ca..a623b25 100644 --- a/src/tests/test_verbose_timing.py +++ b/src/tests/test_verbose_timing.py @@ -1,24 +1,22 @@ import asyncio import logging -from password_manager.manager import PasswordManager +from seedpass.core.manager import PasswordManager from helpers import dummy_nostr_client def test_unlock_vault_logs_time(monkeypatch, caplog, tmp_path): pm = PasswordManager.__new__(PasswordManager) pm.fingerprint_dir = tmp_path - pm.setup_encryption_manager = lambda path: None + pm.setup_encryption_manager = lambda path, pw=None: None pm.initialize_bip85 = lambda: None pm.initialize_managers = lambda: None pm.update_activity = lambda: None pm.verbose_timing = True - caplog.set_level(logging.INFO, logger="password_manager.manager") + caplog.set_level(logging.INFO, logger="seedpass.core.manager") times = iter([0.0, 1.0]) - monkeypatch.setattr( - "password_manager.manager.time.perf_counter", lambda: next(times) - ) - pm.unlock_vault() + monkeypatch.setattr("seedpass.core.manager.time.perf_counter", lambda: next(times)) + pm.unlock_vault("pw") assert "Vault unlocked in 1.00 seconds" in caplog.text