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 . When Python 3.13 or newer is detected without the Microsoft C++ build tools, the installer now attempts to download Python 3.12 automatically so you don't have to compile packages from source. **Note:** If this fallback fails, install Python 3.12 manually or install the [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and rerun the installer. + +#### Windows Nostr Sync Troubleshooting + +When backing up or restoring from Nostr on Windows, a few issues are common: + +* **Event loop errors** – Messages like `RuntimeError: Event loop is closed` usually mean the async runtime failed to initialize. Running SeedPass with `--verbose` provides more detail about which coroutine failed. +* **Permission problems** – If you see `Access is denied` when writing to `~/.seedpass`, launch your terminal with "Run as administrator" so the app can create files in your profile directory. +* **Missing dependencies** – Ensure `websockets` and other requirements are installed inside your virtual environment: + + ```bash + pip install websockets + ``` + +Using increased log verbosity helps diagnose sync issues and confirm that the WebSocket connections to your configured relays succeed. ### Uninstall Run the matching uninstaller if you need to remove a previous installation or clean up an old `seedpass` command: @@ -196,6 +246,53 @@ seedpass list --filter totp For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md). Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md). +### Getting Started with the GUI + +SeedPass also ships with a simple BeeWare desktop interface. Launch it from +your virtual environment using any of the following commands: + +```bash +seedpass gui +python -m seedpass_gui +seedpass-gui +``` + +Only `toga-core` and the headless `toga-dummy` backend are included by default. +The quick installer automatically installs the correct BeeWare backend so the +GUI works out of the box. If you set up SeedPass manually, install the backend +for your platform: + +```bash +# Linux +pip install toga-gtk + +# If you see build errors about "cairo" on Linux, install the cairo +# development headers using your package manager, e.g.: +sudo apt-get install libcairo2 libcairo2-dev + +# Windows +pip install toga-winforms + +# macOS +pip install toga-cocoa +``` + +The GUI works with the same vault and configuration files as the CLI. + +```mermaid +graph TD + core["seedpass.core"] + cli["CLI"] + api["FastAPI server"] + gui["BeeWare GUI"] + ext["Browser Extension"] + + cli --> core + gui --> core + api --> core + ext --> api +``` + ### Vault JSON Layout The encrypted index file `seedpass_entries_db.json.enc` begins with `schema_version` `2` and stores an `entries` map keyed by entry numbers. @@ -326,11 +423,11 @@ When choosing **Add Entry**, you can now select from: ### Using Secret Mode -When **Secret Mode** is enabled, SeedPass copies retrieved passwords directly to your clipboard instead of displaying them on screen. The clipboard clears automatically after the delay you choose. +When **Secret Mode** is enabled, SeedPass copies newly generated and retrieved passwords directly to your clipboard instead of displaying them on screen. The clipboard clears automatically after the delay you choose. 1. From the main menu open **Settings** and select **Toggle Secret Mode**. 2. Choose how many seconds to keep passwords on the clipboard. -3. Retrieve an entry and SeedPass will confirm the password was copied. +3. Generate or retrieve an entry and SeedPass will confirm the password was copied. ### Viewing Entry Details @@ -494,6 +591,10 @@ If the checksum file is missing, generate it manually: python scripts/update_checksum.py ``` +If SeedPass prints a "script checksum mismatch" warning on startup, regenerate +the checksum with `seedpass util update-checksum` or select "Generate Script +Checksum" from the Settings menu. + To run mutation tests locally, generate coverage data first and then execute `mutmut`: ```bash @@ -537,8 +638,39 @@ scripts/vendor_dependencies.sh pyinstaller SeedPass.spec ``` +You can also produce packaged installers for the GUI with BeeWare's Briefcase: + +```bash +briefcase build +``` + +Pre-built installers are published for each `seedpass-gui` tag. Visit the +project's **Actions** or **Releases** page on GitHub to download the latest +package for your platform. + The standalone executable will appear in the `dist/` directory. This process works on Windows, macOS and Linux but you must build on each platform for a native binary. +## Packaging with Briefcase + +For step-by-step instructions see [docs/docs/content/01-getting-started/05-briefcase.md](docs/docs/content/01-getting-started/05-briefcase.md). + +Install Briefcase and create a platform-specific scaffold: + +```bash +python -m pip install briefcase +briefcase create +``` + +Build and run the packaged GUI: + +```bash +briefcase build +briefcase run +``` + +You can also launch the GUI directly with `seedpass gui` or `seedpass-gui`. + + ## Security Considerations **Important:** The password you use to encrypt your parent seed is also required to decrypt the seed index data retrieved from Nostr. **It is imperative to remember this password** and be sure to use it with the same seed, as losing it means you won't be able to access your stored index. Secure your 12-word seed **and** your master password. diff --git a/dev-plan.md b/dev-plan.md deleted file mode 100644 index d3aa788..0000000 --- a/dev-plan.md +++ /dev/null @@ -1,93 +0,0 @@ -### SeedPass Road-to-1.0 — Detailed Development Plan - -*(Assumes today = 1 July 2025, team of 1-3 devs, weekly release cadence)* - -| Phase | Goal | Key Deliverables | Target Window | -| ------------------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | -| **0 – Vision Lock-in** | Be explicit about where you’re going so every later trade-off is easy. | • 2-page “north-star” doc covering product scope, security promises, platforms, and **“CLI is source of truth”** principle.
• 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

Architecture Overview

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