mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-07 06:48:52 +00:00
27
.github/workflows/briefcase.yml
vendored
Normal file
27
.github/workflows/briefcase.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Build GUI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'seedpass-gui*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r src/requirements.txt
|
||||
pip install briefcase
|
||||
- name: Build with Briefcase
|
||||
run: briefcase build
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: seedpass-gui
|
||||
path: dist/**
|
12
.github/workflows/python-ci.yml
vendored
12
.github/workflows/python-ci.yml
vendored
@@ -84,12 +84,24 @@ jobs:
|
||||
timeout-minutes: 16
|
||||
shell: bash
|
||||
run: scripts/run_ci_tests.sh
|
||||
- name: Run desktop tests
|
||||
timeout-minutes: 10
|
||||
shell: bash
|
||||
env:
|
||||
TOGA_BACKEND: toga_dummy
|
||||
run: scripts/run_gui_tests.sh
|
||||
- name: Upload pytest log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pytest-log-${{ matrix.os }}
|
||||
path: pytest.log
|
||||
- name: Upload GUI pytest log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gui-pytest-log-${{ matrix.os }}
|
||||
path: pytest_gui.log
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
138
README.md
138
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 <https://www.python.org/downloads/windows/>. When Python 3.13 or newer is detected without the Microsoft C++ build tools, the installer now attempts to download Python 3.12 automatically so you don't have to compile packages from source.
|
||||
|
||||
**Note:** If this fallback fails, install Python 3.12 manually or install the [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and rerun the installer.
|
||||
|
||||
#### Windows Nostr Sync Troubleshooting
|
||||
|
||||
When backing up or restoring from Nostr on Windows, a few issues are common:
|
||||
|
||||
* **Event loop errors** – Messages like `RuntimeError: Event loop is closed` usually mean the async runtime failed to initialize. Running SeedPass with `--verbose` provides more detail about which coroutine failed.
|
||||
* **Permission problems** – If you see `Access is denied` when writing to `~/.seedpass`, launch your terminal with "Run as administrator" so the app can create files in your profile directory.
|
||||
* **Missing dependencies** – Ensure `websockets` and other requirements are installed inside your virtual environment:
|
||||
|
||||
```bash
|
||||
pip install websockets
|
||||
```
|
||||
|
||||
Using increased log verbosity helps diagnose sync issues and confirm that the WebSocket connections to your configured relays succeed.
|
||||
### Uninstall
|
||||
|
||||
Run the matching uninstaller if you need to remove a previous installation or clean up an old `seedpass` command:
|
||||
@@ -196,6 +246,53 @@ seedpass list --filter totp
|
||||
|
||||
For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md). Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md).
|
||||
|
||||
### Getting Started with the GUI
|
||||
|
||||
SeedPass also ships with a simple BeeWare desktop interface. Launch it from
|
||||
your virtual environment using any of the following commands:
|
||||
|
||||
```bash
|
||||
seedpass gui
|
||||
python -m seedpass_gui
|
||||
seedpass-gui
|
||||
```
|
||||
|
||||
Only `toga-core` and the headless `toga-dummy` backend are included by default.
|
||||
The quick installer automatically installs the correct BeeWare backend so the
|
||||
GUI works out of the box. If you set up SeedPass manually, install the backend
|
||||
for your platform:
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
pip install toga-gtk
|
||||
|
||||
# If you see build errors about "cairo" on Linux, install the cairo
|
||||
# development headers using your package manager, e.g.:
|
||||
sudo apt-get install libcairo2 libcairo2-dev
|
||||
|
||||
# Windows
|
||||
pip install toga-winforms
|
||||
|
||||
# macOS
|
||||
pip install toga-cocoa
|
||||
```
|
||||
|
||||
The GUI works with the same vault and configuration files as the CLI.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
core["seedpass.core"]
|
||||
cli["CLI"]
|
||||
api["FastAPI server"]
|
||||
gui["BeeWare GUI"]
|
||||
ext["Browser Extension"]
|
||||
|
||||
cli --> core
|
||||
gui --> core
|
||||
api --> core
|
||||
ext --> api
|
||||
```
|
||||
|
||||
### Vault JSON Layout
|
||||
|
||||
The encrypted index file `seedpass_entries_db.json.enc` begins with `schema_version` `2` and stores an `entries` map keyed by entry numbers.
|
||||
@@ -326,11 +423,11 @@ When choosing **Add Entry**, you can now select from:
|
||||
|
||||
### Using Secret Mode
|
||||
|
||||
When **Secret Mode** is enabled, SeedPass copies retrieved passwords directly to your clipboard instead of displaying them on screen. The clipboard clears automatically after the delay you choose.
|
||||
When **Secret Mode** is enabled, SeedPass copies newly generated and retrieved passwords directly to your clipboard instead of displaying them on screen. The clipboard clears automatically after the delay you choose.
|
||||
|
||||
1. From the main menu open **Settings** and select **Toggle Secret Mode**.
|
||||
2. Choose how many seconds to keep passwords on the clipboard.
|
||||
3. Retrieve an entry and SeedPass will confirm the password was copied.
|
||||
3. Generate or retrieve an entry and SeedPass will confirm the password was copied.
|
||||
|
||||
### Viewing Entry Details
|
||||
|
||||
@@ -494,6 +591,10 @@ If the checksum file is missing, generate it manually:
|
||||
python scripts/update_checksum.py
|
||||
```
|
||||
|
||||
If SeedPass prints a "script checksum mismatch" warning on startup, regenerate
|
||||
the checksum with `seedpass util update-checksum` or select "Generate Script
|
||||
Checksum" from the Settings menu.
|
||||
|
||||
To run mutation tests locally, generate coverage data first and then execute `mutmut`:
|
||||
|
||||
```bash
|
||||
@@ -537,8 +638,39 @@ scripts/vendor_dependencies.sh
|
||||
pyinstaller SeedPass.spec
|
||||
```
|
||||
|
||||
You can also produce packaged installers for the GUI with BeeWare's Briefcase:
|
||||
|
||||
```bash
|
||||
briefcase build
|
||||
```
|
||||
|
||||
Pre-built installers are published for each `seedpass-gui` tag. Visit the
|
||||
project's **Actions** or **Releases** page on GitHub to download the latest
|
||||
package for your platform.
|
||||
|
||||
The standalone executable will appear in the `dist/` directory. This process works on Windows, macOS and Linux but you must build on each platform for a native binary.
|
||||
|
||||
## Packaging with Briefcase
|
||||
|
||||
For step-by-step instructions see [docs/docs/content/01-getting-started/05-briefcase.md](docs/docs/content/01-getting-started/05-briefcase.md).
|
||||
|
||||
Install Briefcase and create a platform-specific scaffold:
|
||||
|
||||
```bash
|
||||
python -m pip install briefcase
|
||||
briefcase create
|
||||
```
|
||||
|
||||
Build and run the packaged GUI:
|
||||
|
||||
```bash
|
||||
briefcase build
|
||||
briefcase run
|
||||
```
|
||||
|
||||
You can also launch the GUI directly with `seedpass gui` or `seedpass-gui`.
|
||||
|
||||
|
||||
## Security Considerations
|
||||
|
||||
**Important:** The password you use to encrypt your parent seed is also required to decrypt the seed index data retrieved from Nostr. **It is imperative to remember this password** and be sure to use it with the same seed, as losing it means you won't be able to access your stored index. Secure your 12-word seed **and** your master password.
|
||||
|
93
dev-plan.md
93
dev-plan.md
@@ -1,93 +0,0 @@
|
||||
### SeedPass Road-to-1.0 — Detailed Development Plan
|
||||
|
||||
*(Assumes today = 1 July 2025, team of 1-3 devs, weekly release cadence)*
|
||||
|
||||
| Phase | Goal | Key Deliverables | Target Window |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
|
||||
| **0 – Vision Lock-in** | Be explicit about where you’re going so every later trade-off is easy. | • 2-page “north-star” doc covering product scope, security promises, platforms, and **“CLI is source of truth”** principle. <br>• Public roadmap Kanban board. | **Week 0** |
|
||||
| **1 – Package-ready Codebase** | Turn loose `src/` tree into a pip-installable library + console script. | • `pyproject.toml` with PEP-621 metadata, `setuptools-scm` dynamic version. <br>• Restructure to `seedpass/` (or keep `src/` but list `packages = ["seedpass"]`). <br>• Entry-point: `seedpass = "seedpass.main:cli"`. <br>• Dev extras: `pytest-cov`, `ruff`, `mypy`, `pre-commit`. <br>• Split pure business logic from I/O (e.g., encryption, BIP-85, vault ops) so GUI can reuse. | **Weeks 0-2** |
|
||||
| **2 – Local Quality Net** | Fail fast before CI runs. | • `make test` / `tox` quick matrix (3.10–3.12). <br>• 90 % line coverage gate. <br>• Static checks in pre-commit (black, ruff, mypy). | **Weeks 1-3** |
|
||||
| **3 – CI / Release Automation** | One Git tag → everything ships. | • GitHub Actions matrix (Ubuntu, macOS, Windows). <br>• Steps: install → unit tests → build wheels (`python -m build`) → PyInstaller one-file artefacts → upload to Release. <br>• Secrets for PyPI / code-signing left empty until 1.0. | **Weeks 2-4** |
|
||||
| **4 – OS-Native Packages** | Users can “apt install / brew install / flatpak install / download .exe”. | **Linux** • `stdeb` → `.deb`, `reprepro` mini-APT repo. <br>**Flatpak** • YAML manifest + GitHub Action to build & push to Flathub beta repo. <br>**Windows** • PyInstaller `--onefile` → NSIS installer. <br>**macOS** • Briefcase → notarised `.pkg` or `.dmg` (signing cert later). | **Weeks 4-8** |
|
||||
| **5 – Experimental GUI Track** | Ship a GUI **without** slowing CLI velocity. | • Decide stack (recommend **Textual** first; upgrade later to Toga or PySide). <br>• Create `seedpass.gui` package calling existing APIs; flag with `--gui`. <br>• Feature flag via env var `SEEDPASS_GUI=1` or CLI switch. <br>• Separate workflow that builds GUI artefacts, but does **not** block CLI releases. | **Weeks 6-12** (parallel) |
|
||||
| **6 – Plugin / Extensibility Layer** | Keep core slim while allowing future features. | • Define `entry_points={"seedpass.plugins": …}`. <br>• Document simple example plugin (e.g., custom password rule). <br>• Load plugins lazily to avoid startup cost. | **Weeks 10-14** |
|
||||
| **7 – Security & Hardening** | Turn security assumptions into guarantees before 1.0 | • SAST scan (Bandit, Semgrep). <br>• Threat-model doc: key-storage, BIP-85 determinism, Nostr backup flow. <br>• Repro-build check for PyInstaller artefacts. <br>• Signed releases (Sigstore, minisign). | **Weeks 12-16** |
|
||||
| **8 – 1.0 Launch Prep** | Final polish + docs. | • User manual (MkDocs, `docs.seedpass.org`). <br>• In-app `--check-update` hitting GitHub API. <br>• Blog post & template release notes. | **Weeks 16-18** |
|
||||
|
||||
---
|
||||
|
||||
### Ongoing Practices to Keep Development Nimble
|
||||
|
||||
| Practice | What to do |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| **Dynamic versioning** | Keep `version` dynamic via `setuptools-scm` / `hatch-vcs`; tag and push – nothing else. |
|
||||
| **Trunk-based dev** | Short-lived branches, PRs < 300 LOC; merge when tests pass. |
|
||||
| **Feature flags** | `seedpass.config.is_enabled("X")` so unfinished work can ship dark. |
|
||||
| **Fast feedback loops** | Local editable install; `invoke run --watch` (or `uvicorn --reload` for GUI) to hot-reload. |
|
||||
| **Weekly beta release** | Even during heavy GUI work, cut “beta” tags weekly; real users shake out regressions early. |
|
||||
|
||||
---
|
||||
|
||||
### First 2-Week Sprint (Concrete To-Dos)
|
||||
|
||||
1. **Bootstrap packaging**
|
||||
|
||||
```bash
|
||||
pip install --upgrade pip build setuptools_scm
|
||||
poetry init # if you prefer Poetry, else stick with setuptools
|
||||
```
|
||||
|
||||
Add `pyproject.toml`, move code to `seedpass/`.
|
||||
|
||||
2. **Console entry-point**
|
||||
In `seedpass/__main__.py` add `from .main import cli; cli()`.
|
||||
|
||||
3. **Editable dev install**
|
||||
`pip install -e .[dev]` → run `seedpass --help`.
|
||||
|
||||
4. **Set up pre-commit**
|
||||
`pre-commit install` with ruff + black + mypy hooks.
|
||||
|
||||
5. **GitHub Action skeleton** (`.github/workflows/ci.yml`)
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix: os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
python-version: ['3.12', '3.11']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with: {python-version: ${{ matrix.python-version }}}
|
||||
- run: pip install --upgrade pip
|
||||
- run: pip install -e .[dev]
|
||||
- run: pytest -n auto
|
||||
```
|
||||
|
||||
6. **Smoke PyInstaller locally**
|
||||
`pyinstaller --onefile seedpass/main.py` – fix missing data/hooks; check binary runs.
|
||||
|
||||
When that’s green, cut tag `v0.1.0-beta` and let CI build artefacts automatically.
|
||||
|
||||
---
|
||||
|
||||
### Choosing the GUI Path (decision by Week 6)
|
||||
|
||||
| If you value… | Choose |
|
||||
| ---------------------------------- | ---------------------------- |
|
||||
| Terminal-first UX, live coding | **Textual (Rich-TUI)** |
|
||||
| Native look, single code base | **Toga / Briefcase** |
|
||||
| Advanced widgets, designer tooling | **PySide-6 / Qt for Python** |
|
||||
|
||||
Prototype one screen (vault list + “Add” dialog) and benchmark bundle size + startup time with PyInstaller before committing.
|
||||
|
||||
---
|
||||
|
||||
## Recap
|
||||
|
||||
* **Packaging & CI first** – lets every future feature ride an established release train.
|
||||
* **GUI lives in its own layer** – CLI stays stable; dev cycles remain quick.
|
||||
* **Security & signing** land after functionality is stable, before v1.0 marketing push.
|
||||
|
||||
Follow the phase table, keep weekly betas flowing, and you’ll reach a polished, installer-ready, GUI-enhanced 1.0 in roughly four months without sacrificing day-to-day agility.
|
41
docs/ARCHITECTURE.md
Normal file
41
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# SeedPass Architecture
|
||||
|
||||
SeedPass follows a layered design that keeps the security-critical logic isolated in a reusable core package. Interfaces like the command line tool, REST API and graphical client act as thin adapters around this core.
|
||||
|
||||
## Core Components
|
||||
|
||||
- **`seedpass.core`** – houses all encryption, key derivation and vault management code.
|
||||
- **`VaultService`** and **`EntryService`** – thread-safe wrappers exposing the main API.
|
||||
- **`PasswordManager`** – orchestrates vault operations, migrations and Nostr sync.
|
||||
|
||||
## Adapters
|
||||
|
||||
- **CLI/TUI** – implemented in [`seedpass.cli`](src/seedpass/cli.py). The [Advanced CLI](docs/docs/content/01-getting-started/01-advanced_cli.md) guide details all commands.
|
||||
- **FastAPI server** – defined in [`seedpass.api`](src/seedpass/api.py). See the [API Reference](docs/docs/content/01-getting-started/02-api_reference.md) for endpoints.
|
||||
- **BeeWare GUI** – located in [`seedpass_gui`](src/seedpass_gui/app.py) and explained in the [GUI Adapter](docs/docs/content/01-getting-started/06-gui_adapter.md) page.
|
||||
|
||||
## Planned Extensions
|
||||
|
||||
SeedPass is built to support additional adapters. Planned or experimental options include:
|
||||
|
||||
- A browser extension communicating with the API
|
||||
- Automation scripts using the CLI
|
||||
- Additional vault import/export helpers described in [JSON Entries](docs/docs/content/01-getting-started/03-json_entries.md)
|
||||
|
||||
## Overview Diagram
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
core["seedpass.core"]
|
||||
cli["CLI / TUI"]
|
||||
api["FastAPI server"]
|
||||
gui["BeeWare GUI"]
|
||||
ext["Browser extension"]
|
||||
|
||||
cli --> core
|
||||
api --> core
|
||||
gui --> core
|
||||
ext --> api
|
||||
```
|
||||
|
||||
All adapters depend on the same core, allowing features to evolve without duplicating logic across interfaces.
|
@@ -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.
|
||||
|
@@ -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:
|
||||
|
29
docs/docs/content/01-getting-started/05-briefcase.md
Normal file
29
docs/docs/content/01-getting-started/05-briefcase.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Packaging the GUI with Briefcase
|
||||
|
||||
This project uses [BeeWare's Briefcase](https://beeware.org) to generate
|
||||
platform‑native installers. Once your development environment is set up,
|
||||
package the GUI by running the following commands from the repository root:
|
||||
|
||||
```bash
|
||||
# Create the application scaffold for your platform
|
||||
briefcase create
|
||||
|
||||
# Compile dependencies and produce a distributable bundle
|
||||
briefcase build
|
||||
|
||||
# Run the packaged application
|
||||
briefcase run
|
||||
```
|
||||
|
||||
## Command Overview
|
||||
|
||||
- **`briefcase create`** — generates the project scaffold for your
|
||||
operating system. Run this once per platform.
|
||||
- **`briefcase build`** — compiles dependencies and produces the
|
||||
distributable bundle.
|
||||
- **`briefcase run`** — launches the packaged application so you can test
|
||||
it locally.
|
||||
|
||||
After the initial creation step you can repeatedly run `briefcase build`
|
||||
followed by `briefcase run` to test your packaged application on Windows,
|
||||
macOS or Linux.
|
158
docs/docs/content/01-getting-started/06-gui_adapter.md
Normal file
158
docs/docs/content/01-getting-started/06-gui_adapter.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# BeeWare GUI Adapter
|
||||
|
||||
SeedPass ships with a proof-of-concept graphical interface built using [BeeWare](https://beeware.org). The GUI interacts with the same core services as the CLI by instantiating wrappers around `PasswordManager`.
|
||||
|
||||
|
||||
## Getting Started with the GUI
|
||||
|
||||
After installing the project dependencies, launch the desktop interface with one
|
||||
of the following commands:
|
||||
|
||||
```bash
|
||||
seedpass gui
|
||||
python -m seedpass_gui
|
||||
seedpass-gui
|
||||
```
|
||||
|
||||
Only `toga-core` and the headless `toga-dummy` backend ship with the project.
|
||||
The installation scripts automatically install the correct BeeWare backend so
|
||||
the GUI works out of the box. If you set up SeedPass manually, install the
|
||||
backend for your platform:
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
pip install toga-gtk
|
||||
|
||||
# If installation fails with cairo errors, install libcairo2-dev or the
|
||||
# cairo development package using your distro's package manager.
|
||||
|
||||
# Windows
|
||||
pip install toga-winforms
|
||||
|
||||
# macOS
|
||||
pip install toga-cocoa
|
||||
```
|
||||
|
||||
The GUI shares the same encrypted vault and configuration as the command line tool.
|
||||
|
||||
To generate a packaged binary, run `briefcase build` (after the initial `briefcase create`).
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
core["seedpass.core"]
|
||||
cli["CLI"]
|
||||
api["FastAPI server"]
|
||||
gui["BeeWare GUI"]
|
||||
ext["Browser Extension"]
|
||||
|
||||
cli --> core
|
||||
gui --> core
|
||||
api --> core
|
||||
ext --> api
|
||||
```
|
||||
|
||||
## VaultService and EntryService
|
||||
|
||||
`VaultService` provides thread-safe access to vault operations like exporting, importing, unlocking and locking the vault. `EntryService` exposes methods for listing, searching and modifying entries. Both classes live in `seedpass.core.api` and hold a `PasswordManager` instance protected by a `threading.Lock` to ensure safe concurrent access.
|
||||
|
||||
```python
|
||||
class VaultService:
|
||||
"""Thread-safe wrapper around vault operations."""
|
||||
def __init__(self, manager: PasswordManager) -> None:
|
||||
self._manager = manager
|
||||
self._lock = Lock()
|
||||
```
|
||||
|
||||
```python
|
||||
class EntryService:
|
||||
"""Thread-safe wrapper around entry operations."""
|
||||
def __init__(self, manager: PasswordManager) -> None:
|
||||
self._manager = manager
|
||||
self._lock = Lock()
|
||||
```
|
||||
|
||||
## BeeWare Windows
|
||||
|
||||
The GUI defines two main windows in `src/seedpass_gui/app.py`. `LockScreenWindow` prompts for the master password and then opens `MainWindow` to display the vault entries.
|
||||
|
||||
```python
|
||||
class LockScreenWindow(toga.Window):
|
||||
"""Window prompting for the master password."""
|
||||
def __init__(self, app: SeedPassApp, vault: VaultService, entries: EntryService) -> None:
|
||||
super().__init__("Unlock Vault")
|
||||
self.app = app
|
||||
self.vault = vault
|
||||
self.entries = entries
|
||||
...
|
||||
```
|
||||
|
||||
```python
|
||||
class MainWindow(toga.Window):
|
||||
"""Main application window showing vault entries."""
|
||||
def __init__(self, app: SeedPassApp, vault: VaultService, entries: EntryService) -> None:
|
||||
super().__init__("SeedPass")
|
||||
self.app = app
|
||||
self.vault = vault
|
||||
self.entries = entries
|
||||
...
|
||||
```
|
||||
|
||||
Each window receives the service instances and calls methods such as `vault.unlock()` or `entries.add_entry()` when buttons are pressed. This keeps the UI thin while reusing the core logic.
|
||||
|
||||
## Asynchronous Synchronization
|
||||
|
||||
`PasswordManager` performs network synchronization with Nostr using `asyncio`. Methods like `start_background_vault_sync()` create a coroutine that calls `sync_vault_async()` in a background thread or task without blocking the UI.
|
||||
|
||||
```python
|
||||
async def sync_vault_async(self, alt_summary: str | None = None) -> dict[str, list[str] | str] | None:
|
||||
"""Publish the current vault contents to Nostr and return event IDs."""
|
||||
...
|
||||
```
|
||||
|
||||
```python
|
||||
def start_background_vault_sync(self, alt_summary: str | None = None) -> None:
|
||||
if getattr(self, "offline_mode", False):
|
||||
return
|
||||
def _worker() -> None:
|
||||
asyncio.run(self.sync_vault_async(alt_summary=alt_summary))
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
threading.Thread(target=_worker, daemon=True).start()
|
||||
else:
|
||||
asyncio.create_task(self.sync_vault_async(alt_summary=alt_summary))
|
||||
```
|
||||
|
||||
This approach ensures synchronization happens asynchronously whether the GUI is running inside or outside an existing event loop.
|
||||
|
||||
## Relay Manager and Status Bar
|
||||
|
||||
The *Relays* button opens a dialog for adding or removing Nostr relay URLs. The
|
||||
status bar at the bottom of the main window shows when the last synchronization
|
||||
completed. It updates automatically when `sync_started` and `sync_finished`
|
||||
events are published on the internal pubsub bus.
|
||||
|
||||
When a ``vault_locked`` event is emitted, the GUI automatically returns to the
|
||||
lock screen so the session can be reopened with the master password.
|
||||
|
||||
|
||||
## Event Handling
|
||||
|
||||
The GUI subscribes to a few core events so the interface reacts automatically when the vault changes state. When `MainWindow` is created it registers callbacks for `sync_started`, `sync_finished` and `vault_locked` on the global pubsub `bus`:
|
||||
|
||||
```python
|
||||
bus.subscribe("sync_started", self.sync_started)
|
||||
bus.subscribe("sync_finished", self.sync_finished)
|
||||
bus.subscribe("vault_locked", self.vault_locked)
|
||||
```
|
||||
|
||||
Each handler updates the status bar or returns to the lock screen. The `cleanup` method removes these hooks when the window closes:
|
||||
|
||||
```python
|
||||
def cleanup(self, *args: object, **kwargs: object) -> None:
|
||||
bus.unsubscribe("sync_started", self.sync_started)
|
||||
bus.unsubscribe("sync_finished", self.sync_finished)
|
||||
bus.unsubscribe("vault_locked", self.vault_locked)
|
||||
```
|
||||
|
||||
The [TOTP window](../../02-api_reference.md#totp) demonstrates how such events keep the UI fresh: it shows live two-factor codes that reflect the latest vault data after synchronization.
|
@@ -16,6 +16,22 @@ This software was not developed by an experienced security expert and should be
|
||||
✔ Windows 10/11 • macOS 12+ • Any modern Linux
|
||||
SeedPass now uses the `portalocker` library for cross-platform file locking. No WSL or Cygwin required.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
core(seedpass.core)
|
||||
cli(CLI/TUI)
|
||||
gui(BeeWare GUI)
|
||||
ext(Browser extension)
|
||||
cli --> core
|
||||
gui --> core
|
||||
ext --> core
|
||||
```
|
||||
|
||||
SeedPass uses a modular design with a single core library that handles all
|
||||
security-critical logic. The current CLI/TUI adapter communicates with
|
||||
`seedpass.core`, and future interfaces like a BeeWare GUI and a browser
|
||||
extension can hook into the same layer. This architecture keeps the codebase
|
||||
maintainable while enabling a consistent experience on multiple platforms.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -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
|
||||
|
@@ -84,6 +84,26 @@ flowchart TB
|
||||
<h2 class="section-title" id="architecture-heading">Architecture Overview</h2>
|
||||
<pre class="mermaid">
|
||||
---
|
||||
config:
|
||||
layout: fixed
|
||||
theme: base
|
||||
themeVariables:
|
||||
primaryColor: '#e94a39'
|
||||
primaryBorderColor: '#e94a39'
|
||||
lineColor: '#e94a39'
|
||||
look: classic
|
||||
---
|
||||
graph TD
|
||||
core(seedpass.core)
|
||||
cli(CLI/TUI)
|
||||
gui(BeeWare GUI)
|
||||
ext(Browser extension)
|
||||
cli --> core
|
||||
gui --> core
|
||||
ext --> core
|
||||
</pre>
|
||||
<pre class="mermaid">
|
||||
---
|
||||
config:
|
||||
layout: fixed
|
||||
theme: base
|
||||
|
@@ -1,78 +0,0 @@
|
||||
---
|
||||
|
||||
# SeedPass Feature Back‑Log (v2)
|
||||
|
||||
> **Encryption invariant** Everything at rest **and** in export remains cipher‑text that ultimately derives from the **profile master‑password + parent seed**. No unencrypted payload leaves the vault.
|
||||
>
|
||||
> **Surface rule** UI layers (CLI, GUI, future mobile) may *display* decrypted data **after** user unlock, but must never write plaintext to disk or network.
|
||||
|
||||
---
|
||||
|
||||
## Track vocabulary
|
||||
|
||||
| Label | Meaning |
|
||||
| ------------ | ------------------------------------------------------------------------------ |
|
||||
| **Core API** | `seedpass.api` – headless services consumed by CLI / GUI |
|
||||
| **Profile** | A fingerprint‑scoped vault: parent‑seed + hashed pw + entries |
|
||||
| **Entry** | One encrypted JSON blob on disk plus Nostr snapshot chunks and delta events |
|
||||
| **GUI MVP** | Desktop app built with PySide 6 announced in the v2 roadmap |
|
||||
|
||||
---
|
||||
|
||||
## Phase A • Core‑level enhancements (blockers for GUI)
|
||||
|
||||
| Prio | Feature | Notes |
|
||||
| ------ | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🔥 | **Encrypted Search API** | • `VaultService.search(query:str, *, kinds=None) -> List[EntryMeta]` <br>• Decrypt *only* whitelisted meta‑fields per `kind` (title, username, url, tags) for in‑memory matching. |
|
||||
| 🔥 | **Rich Listing / Sort / Filter** | • `list_entries(sort_by="updated", kind="note")` <br>• Sorting by `title` must decrypt that field on‑the‑fly. |
|
||||
| 🔥 | **Custom Relay Set (per profile)** | • `StateManager.state["relays"]: List[str]` <br>• CRUD CLI commands & GUI dialog. <br>• `NostrClient` reads from state at instantiation. |
|
||||
| ⚡ | **Session Lock & Idle Timeout** | • Config `SESSION_TIMEOUT` (default 15 min). <br>• `AuthGuard` clears in‑memory keys & seeds. <br>• CLI `seedpass lock` + GUI menu “Lock vault”. |
|
||||
|
||||
**Exit‑criteria** : All functions green in CI, consumed by both CLI (Typer) *and* a minimal Qt test harness.
|
||||
|
||||
---
|
||||
|
||||
## Phase B • Data Portability (encrypted only)
|
||||
|
||||
| Prio | Feature | Notes | |
|
||||
| ------ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| ⭐ | **Encrypted Profile Export** | • CLI `seedpass export --out myprofile.enc` <br>• Serialise *encrypted* entry files → single JSON wrapper → `EncryptionManager.encrypt_data()` <br>• Always require active profile unlock. | |
|
||||
| ⭐ | **Encrypted Profile Import / Merge** | • CLI \`seedpass import myprofile.enc \[--strategy skip | overwrite-newer]` <br>• Verify fingerprint match before ingest. <br>• Conflict policy pluggable; default `skip\`. |
|
||||
|
||||
---
|
||||
|
||||
## Phase C • Advanced secrets & sync
|
||||
|
||||
| Prio | Feature | Notes |
|
||||
| ------ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ◇ | **TOTP entry kind** | • `kind="totp_secret"` fields: title, issuer, username, secret\_key <br>• `secret_key` encrypted; handler uses `pyotp` to show current code. |
|
||||
| ◇ | **Manual Conflict Resolver** | • When `checksum` mismatch *and* both sides newer than last sync → prompt user (CLI) or modal (GUI). |
|
||||
|
||||
---
|
||||
|
||||
## Phase D • Desktop GUI MVP (Qt 6)
|
||||
|
||||
*Features here ride on the Core API; keep UI totally stateless.*
|
||||
|
||||
| Prio | Feature | Notes |
|
||||
| ------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 🔥 | **Login Window** | • Unlock profile with master pw. <br>• Profile switcher drop‑down. |
|
||||
| 🔥 | **Vault Window** | • Sidebar (Entries, Search, Backups, Settings). <br>• `QTableView` bound to `VaultService.list_entries()` <br>• Sort & basic filters built‑in. |
|
||||
| 🔥 | **Entry Editor Dialog** | • Dynamic form driven by `kinds.py`. <br>• Add / Edit. |
|
||||
| ⭐ | **Sync Status Bar** | • Pulsing icon + last sync timestamp; hooks into `SyncService` bus. |
|
||||
| ◇ | **Relay Manager Dialog** | • CRUD & ping test per relay. |
|
||||
|
||||
*Binary packaging (PyInstaller matrix build) is already tracked in the roadmap and is not duplicated here.*
|
||||
|
||||
---
|
||||
|
||||
## Phase E • Later / Research
|
||||
|
||||
• Hardware‑wallet unlock (SLIP‑39 share)
|
||||
• Background daemon (`seedpassd` + gRPC)
|
||||
• Mobile companion (Flutter FFI)
|
||||
• Federated search across multiple profiles
|
||||
|
||||
---
|
||||
|
||||
**Reminder:** *No plaintext exports, no on‑disk temp files, and no writing decrypted data to Nostr.* Everything funnels through the encryption stack or stays in memory for the current unlocked session only.
|
@@ -2,10 +2,50 @@
|
||||
name = "seedpass"
|
||||
version = "0.1.0"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project.scripts]
|
||||
seedpass = "seedpass.cli:app"
|
||||
seedpass-gui = "seedpass_gui.app:main"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
strict = true
|
||||
mypy_path = "src"
|
||||
|
||||
[tool.briefcase.app.seedpass-gui]
|
||||
formal-name = "SeedPass"
|
||||
description = "Deterministic password manager with a BeeWare GUI"
|
||||
sources = ["src"]
|
||||
requires = [
|
||||
"toga-core>=0.5.2",
|
||||
"colorama>=0.4.6",
|
||||
"termcolor>=1.1.0",
|
||||
"cryptography>=40.0.2",
|
||||
"bip-utils>=2.5.0",
|
||||
"bech32==1.2.0",
|
||||
"coincurve>=18.0.0",
|
||||
"mnemonic",
|
||||
"aiohttp>=3.12.14",
|
||||
"bcrypt",
|
||||
"portalocker>=2.8",
|
||||
"nostr-sdk>=0.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"
|
||||
|
113
refactor.md
113
refactor.md
@@ -1,113 +0,0 @@
|
||||
# SeedPass v2 Roadmap — CLI → Desktop GUI
|
||||
|
||||
> **Guiding principles**
|
||||
>
|
||||
> 1. **Core-first** – a headless, testable Python package (`seedpass.core`) that is 100 % GUI-agnostic.
|
||||
> 2. **Thin adapters** – CLI, GUI, and future mobile layers merely call the core API.
|
||||
> 3. **Stateless UI** – all persistence lives in core services; UI never touches vault files directly.
|
||||
> 4. **Parity at every step** – CLI must keep working while GUI evolves.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 • Tooling Baseline
|
||||
|
||||
| # | Task | Rationale |
|
||||
| --- | ---------------------------------------------------------------------------------------------- | --------------------------------- |
|
||||
| 0.1 | ✅ **Adopt `poetry`** (or `hatch`) for builds & dependency pins. | Single-source version + lockfile. |
|
||||
| 0.2 | ✅ **GitHub Actions**: lint (ruff), type-check (mypy), tests (pytest -q), coverage gate ≥ 85 %. | Prevent regressions. |
|
||||
| 0.3 | ✅ Pre-commit hooks: ruff –fix, black, isort. | Uniform style. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 • Finalize Core Refactor (CLI still primary)
|
||||
|
||||
> *Most of this is already drafted – here’s what must ship before GUI work starts.*
|
||||
|
||||
| # | Component | Must-have work |
|
||||
| --- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| 1.1 | **`kinds.py` registry + per-kind handler modules** | import-safe; handler signature `(data,fingerprint,**svc)` |
|
||||
| 1.2 | **`StateManager`** | JSON file w/ fcntl lock<br>keys: `last_bip85_idx`, `last_sync_ts` |
|
||||
| 1.3 | **Checksum inside entry metadata** | `sha256(json.dumps(data,sort_keys=True))` |
|
||||
| 1.4 | **Replaceable Nostr events** (kind 31111, `d` tag = `"{kindtag}{entry_num}"`) | publish/update/delete tombstone |
|
||||
| 1.5 | **Per-entry `EntryManager` / `BackupManager`** | Save / load / backup / restore individual encrypted files |
|
||||
| 1.6 | **CLI rewritten with Typer** | Typer commands map 1-to-1 with core service methods; preserves colours. |
|
||||
| 1.7 | **Legacy index migration command** | `seedpass migrate-legacy` – idempotent, uses `add_entry()` under the hood. |
|
||||
| 1.8 | **bcrypt + NFKD master password hash** | Stored per fingerprint. |
|
||||
|
||||
> **Exit-criteria:** end-to-end flow (`add → list → sync → restore`) green in CI and covered by tests.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 • Core API Hardening (prep for GUI)
|
||||
|
||||
| # | Task | Deliverable |
|
||||
| --- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| 2.1 | **Public Service Layer** (`seedpass.api`) | Facade classes:<br>`VaultService`, `ProfileService`, `SyncService` – *no* CLI / UI imports. |
|
||||
| 2.2 | **Thread-safe gate** | Re-entrancy locks so GUI threads can call core safely. |
|
||||
| 2.3 | **Fast in-process event bus** | Simple `pubsub.py` (observer pattern) for GUI to receive progress callbacks (e.g. sync progress, long ops). |
|
||||
| 2.4 | **Docstrings + pydantic models** | Typed request/response objects → eases RPC later (e.g. REST, gRPC). |
|
||||
| 2.5 | **Library packaging** | `python -m pip install .` gives importable `seedpass`. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 • Desktop GUI MVP
|
||||
|
||||
| # | Decision | Notes |
|
||||
| --- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| 3.0 | **Framework: PySide 6 (Qt 6)** | ✓ LGPL, ✓ native look, ✓ Python-first, ✓ WebEngine if needed. |
|
||||
| 3.1 | **Process model** | *Same* process; GUI thread ↔ core API via signals/slots.<br>(If we outgrow this, swap to a local gRPC server later.) |
|
||||
| 3.2 | **UI Skeleton (milestone “Hello Vault”)** | |
|
||||
| – | `LoginWindow` | master-password prompt → opens default profile |
|
||||
| – | `VaultWindow` | sidebar (Profiles, Entries, Backups) + stacked views |
|
||||
| – | `EntryTableView` | QTableView bound to `VaultService.list_entries()` |
|
||||
| – | `EntryEditorDialog` | Add / Edit forms – field set driven by `kinds.py` |
|
||||
| – | `SyncStatusBar` | pulse animation + last-sync timestamp |
|
||||
| 3.3 | **Icons / theming** | Start with Qt-built-in icons; later swap to SVG set. |
|
||||
| 3.4 | **Packaging** | `PyInstaller --onefile` for Win / macOS / Linux AppImage; GitHub Actions matrix build. |
|
||||
| 3.5 | **GUI E2E tests** | PyTest + pytest-qt (QtBot) smoke flows; run headless in CI (Xvfb). |
|
||||
|
||||
> **Stretch option:** wrap the same UI in **Tauri** later for a lighter binary (\~5 MB), reusing the core API through a local websocket RPC.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 • Unified Workflows & Coverage
|
||||
|
||||
| # | Task |
|
||||
| --- | --------------------------------------------------------------------------------------- |
|
||||
| 4.1 | Extend GitHub Actions to build GUI artifacts on every tag. |
|
||||
| 4.2 | Add synthetic coverage for GUI code paths (QtBot). |
|
||||
| 4.3 | Nightly job: spin up headless GUI, run `sync` against test relay, assert no exceptions. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 • Future-Proofing (post-GUI v1)
|
||||
|
||||
| Idea | Sketch |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| **Background daemon** | Optional `seedpassd` exposing Unix socket + JSON-RPC; both CLI & GUI become thin clients. |
|
||||
| **Hardware-wallet unlock** | Replace master password with HWW + SLIP-39 share; requires PyUSB bridge. |
|
||||
| **Mobile companion app** | Reuse core via BeeWare or Flutter FFI; sync over Nostr only (no local vault). |
|
||||
| **End-to-end test farm** | dedicated relay docker-compose + pytest-subprocess to fake flaky relays. |
|
||||
|
||||
---
|
||||
|
||||
## Deliverables Checklist
|
||||
|
||||
* [ ] Core refactor merged, tests ≥ 85 % coverage
|
||||
* [ ] `seedpass` installs and passes `python -m seedpass.cli --help`
|
||||
* [ ] `seedpass-gui` binary opens vault, lists entries, adds & edits, syncs
|
||||
* [ ] GitHub Actions builds binaries for Win/macOS/Linux on tag
|
||||
* [ ] `docs/ARCHITECTURE.md` diagrams core ↔ CLI ↔ GUI layers
|
||||
|
||||
When the above are ✅ we can ship `v2.0.0-beta.1` and invite early desktop testers.
|
||||
|
||||
---
|
||||
|
||||
### 🔑 Key Takeaways
|
||||
|
||||
1. **Keep all state & crypto in the core package.**
|
||||
2. **Expose a clean Python API first – GUI is “just another client.”**
|
||||
3. **Checksum + replaceable Nostr events give rock-solid sync & conflict handling.**
|
||||
4. **Lock files and StateManager prevent index reuse and vault corruption.**
|
||||
5. **The GUI sprint starts only after Phase 1 + 2 are fully green in CI.**
|
||||
|
@@ -20,7 +20,7 @@ cryptography==45.0.4
|
||||
ecdsa==0.19.1
|
||||
ed25519-blake2b==1.4.1
|
||||
execnet==2.1.1
|
||||
fastapi==0.116.0
|
||||
fastapi==0.116.1
|
||||
frozenlist==1.7.0
|
||||
glob2==0.7
|
||||
hypothesis==6.135.20
|
||||
@@ -61,6 +61,7 @@ toml==0.10.2
|
||||
tomli==2.2.1
|
||||
urllib3==2.5.0
|
||||
uvicorn==0.35.0
|
||||
starlette==0.47.2
|
||||
httpx==0.28.1
|
||||
varint==1.0.2
|
||||
websocket-client==1.7.0
|
||||
|
@@ -38,11 +38,11 @@ consts.SCRIPT_CHECKSUM_FILE = consts.APP_DIR / "seedpass_script_checksum.txt"
|
||||
|
||||
from constants import APP_DIR, initialize_app
|
||||
from utils.key_derivation import derive_key_from_password, derive_index_key
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from password_manager.vault import Vault
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.entry_management import EntryManager
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
from seedpass.core.vault import Vault
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from nostr.client import NostrClient
|
||||
from utils.fingerprint import generate_fingerprint
|
||||
from utils.fingerprint_manager import FingerprintManager
|
||||
|
@@ -260,6 +260,10 @@ if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to install SeedPass package"
|
||||
}
|
||||
|
||||
Write-Info "Installing BeeWare GUI backend..."
|
||||
& "$VenvDir\Scripts\python.exe" -m pip install toga-winforms
|
||||
if ($LASTEXITCODE -ne 0) { Write-Warning "Failed to install GUI backend" }
|
||||
|
||||
# 5. Create launcher script
|
||||
Write-Info "Creating launcher script..."
|
||||
if (-not (Test-Path $LauncherDir)) { New-Item -ItemType Directory -Path $LauncherDir | Out-Null }
|
||||
@@ -279,6 +283,18 @@ if ($existingSeedpass -and $existingSeedpass.Source -ne $LauncherPath) {
|
||||
Write-Warning "Ensure '$LauncherDir' comes first in your PATH or remove the old installation."
|
||||
}
|
||||
|
||||
# Detect additional seedpass executables on PATH that are not our launcher
|
||||
$allSeedpass = Get-Command seedpass -All -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source
|
||||
$stale = @()
|
||||
foreach ($cmd in $allSeedpass) {
|
||||
if ($cmd -ne $LauncherPath) { $stale += $cmd }
|
||||
}
|
||||
if ($stale.Count -gt 0) {
|
||||
Write-Warning "Stale 'seedpass' executables detected:"
|
||||
foreach ($cmd in $stale) { Write-Warning " - $cmd" }
|
||||
Write-Warning "Remove or rename these to avoid launching outdated code."
|
||||
}
|
||||
|
||||
# 6. Add launcher directory to User's PATH if needed
|
||||
Write-Info "Checking if '$LauncherDir' is in your PATH..."
|
||||
$UserPath = [System.Environment]::GetEnvironmentVariable("Path", "User")
|
||||
|
@@ -86,13 +86,26 @@ main() {
|
||||
# 3. Install OS-specific dependencies
|
||||
print_info "Checking for build dependencies..."
|
||||
if [ "$OS_NAME" = "Linux" ]; then
|
||||
if command -v apt-get &> /dev/null; then sudo apt-get update && sudo apt-get install -y build-essential pkg-config xclip;
|
||||
elif command -v dnf &> /dev/null; then sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y pkg-config xclip;
|
||||
elif command -v pacman &> /dev/null; then sudo pacman -Syu --noconfirm base-devel pkg-config xclip;
|
||||
else print_warning "Could not detect package manager. Ensure build tools and pkg-config are installed."; fi
|
||||
if command -v apt-get &> /dev/null; then
|
||||
sudo apt-get update && sudo apt-get install -y \
|
||||
build-essential pkg-config xclip \
|
||||
libcairo2 libcairo2-dev \
|
||||
libgirepository-2.0-dev gir1.2-girepository-2.0 \
|
||||
gobject-introspection \
|
||||
gir1.2-gtk-3.0 python3-dev
|
||||
elif command -v dnf &> /dev/null; then
|
||||
sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y \
|
||||
pkg-config cairo cairo-devel xclip \
|
||||
gobject-introspection-devel cairo-devel gtk3-devel python3-devel
|
||||
elif command -v pacman &> /dev/null; then
|
||||
sudo pacman -Syu --noconfirm base-devel pkg-config cairo xclip \
|
||||
gobject-introspection cairo gtk3 python
|
||||
else
|
||||
print_warning "Could not detect package manager. Ensure build tools, cairo, and pkg-config are installed."
|
||||
fi
|
||||
elif [ "$OS_NAME" = "Darwin" ]; then
|
||||
if ! command -v brew &> /dev/null; then print_error "Homebrew not installed. See https://brew.sh/"; fi
|
||||
brew install pkg-config
|
||||
brew install pkg-config cairo
|
||||
fi
|
||||
|
||||
# 4. Clone or update the repository
|
||||
@@ -120,6 +133,14 @@ main() {
|
||||
pip install --upgrade pip
|
||||
pip install -r src/requirements.txt
|
||||
pip install -e .
|
||||
print_info "Installing platform-specific Toga backend..."
|
||||
if [ "$OS_NAME" = "Linux" ]; then
|
||||
print_info "Installing toga-gtk for Linux..."
|
||||
pip install toga-gtk
|
||||
elif [ "$OS_NAME" = "Darwin" ]; then
|
||||
print_info "Installing toga-cocoa for macOS..."
|
||||
pip install toga-cocoa
|
||||
fi
|
||||
deactivate
|
||||
|
||||
# 7. Create launcher script
|
||||
@@ -138,6 +159,23 @@ EOF2
|
||||
print_warning "Ensure '$LAUNCHER_DIR' comes first in your PATH or remove the old installation."
|
||||
fi
|
||||
|
||||
# Detect any additional seedpass executables on PATH that are not our launcher
|
||||
IFS=':' read -ra _sp_paths <<< "$PATH"
|
||||
stale_cmds=()
|
||||
for _dir in "${_sp_paths[@]}"; do
|
||||
_candidate="$_dir/seedpass"
|
||||
if [ -x "$_candidate" ] && [ "$_candidate" != "$LAUNCHER_PATH" ]; then
|
||||
stale_cmds+=("$_candidate")
|
||||
fi
|
||||
done
|
||||
if [ ${#stale_cmds[@]} -gt 0 ]; then
|
||||
print_warning "Stale 'seedpass' executables detected:"
|
||||
for cmd in "${stale_cmds[@]}"; do
|
||||
print_warning " - $cmd"
|
||||
done
|
||||
print_warning "Remove or rename these to avoid launching outdated code."
|
||||
fi
|
||||
|
||||
# 8. Final instructions
|
||||
print_success "Installation/update complete!"
|
||||
print_info "You can now launch the interactive TUI by typing: seedpass"
|
||||
|
32
scripts/run_gui_tests.sh
Executable file
32
scripts/run_gui_tests.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
pytest_args=(-vv --desktop -m desktop src/tests)
|
||||
if [[ "${RUNNER_OS:-}" == "Windows" ]]; then
|
||||
pytest_args+=(-n 1)
|
||||
fi
|
||||
|
||||
timeout_bin="timeout"
|
||||
if ! command -v "$timeout_bin" >/dev/null 2>&1; then
|
||||
if command -v gtimeout >/dev/null 2>&1; then
|
||||
timeout_bin="gtimeout"
|
||||
else
|
||||
timeout_bin=""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$timeout_bin" ]]; then
|
||||
$timeout_bin 10m pytest "${pytest_args[@]}" 2>&1 | tee pytest_gui.log
|
||||
status=${PIPESTATUS[0]}
|
||||
else
|
||||
echo "timeout command not found; running tests without timeout" >&2
|
||||
pytest "${pytest_args[@]}" 2>&1 | tee pytest_gui.log
|
||||
status=${PIPESTATUS[0]}
|
||||
fi
|
||||
|
||||
if [[ $status -eq 124 ]]; then
|
||||
echo "::error::Desktop tests exceeded 10-minute limit"
|
||||
tail -n 20 pytest_gui.log
|
||||
exit 1
|
||||
fi
|
||||
exit $status
|
@@ -14,7 +14,7 @@ from constants import SCRIPT_CHECKSUM_FILE, initialize_app
|
||||
def main() -> None:
|
||||
"""Calculate checksum for the main script and write it to SCRIPT_CHECKSUM_FILE."""
|
||||
initialize_app()
|
||||
script_path = SRC_DIR / "password_manager" / "manager.py"
|
||||
script_path = SRC_DIR / "seedpass/core" / "manager.py"
|
||||
if not update_checksum_file(str(script_path), str(SCRIPT_CHECKSUM_FILE)):
|
||||
raise SystemExit(f"Failed to update checksum for {script_path}")
|
||||
print(f"Updated checksum written to {SCRIPT_CHECKSUM_FILE}")
|
||||
|
@@ -9,9 +9,11 @@ logger = logging.getLogger(__name__)
|
||||
# -----------------------------------
|
||||
# Nostr Relay Connection Settings
|
||||
# -----------------------------------
|
||||
# Retry fewer times with a shorter wait by default
|
||||
MAX_RETRIES = 2 # Maximum number of retries for relay connections
|
||||
RETRY_DELAY = 1 # Seconds to wait before retrying a failed connection
|
||||
# Retry fewer times with a shorter wait by default. These values
|
||||
# act as defaults that can be overridden via ``ConfigManager``
|
||||
# entries ``nostr_max_retries`` and ``nostr_retry_delay``.
|
||||
MAX_RETRIES = 2 # Default maximum number of retry attempts
|
||||
RETRY_DELAY = 1 # Default seconds to wait before retrying
|
||||
MIN_HEALTHY_RELAYS = 2 # Minimum relays that should return data on startup
|
||||
|
||||
# -----------------------------------
|
||||
|
@@ -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 (
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
||||
|
@@ -27,3 +27,4 @@ requests>=2.32
|
||||
python-multipart
|
||||
orjson
|
||||
argon2-cffi
|
||||
toga-core>=0.5.2
|
||||
|
@@ -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"}
|
||||
|
||||
|
||||
|
@@ -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()
|
||||
|
@@ -1,10 +1,10 @@
|
||||
# password_manager/__init__.py
|
||||
# seedpass.core/__init__.py
|
||||
|
||||
"""Expose password manager components with lazy imports."""
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
__all__ = ["PasswordManager", "ConfigManager", "Vault", "EntryType"]
|
||||
__all__ = ["PasswordManager", "ConfigManager", "Vault", "EntryType", "StateManager"]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
@@ -16,4 +16,6 @@ def __getattr__(name: str):
|
||||
return import_module(".vault", __name__).Vault
|
||||
if name == "EntryType":
|
||||
return import_module(".entry_types", __name__).EntryType
|
||||
if name == "StateManager":
|
||||
return import_module(".state_manager", __name__).StateManager
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
580
src/seedpass/core/api.py
Normal file
580
src/seedpass/core/api.py
Normal file
@@ -0,0 +1,580 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Service layer wrapping :class:`PasswordManager` operations.
|
||||
|
||||
These services provide thread-safe methods for common operations used by the CLI
|
||||
and API. Request and response payloads are represented using Pydantic models to
|
||||
allow easy validation and documentation.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import List, Optional, Dict
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .manager import PasswordManager
|
||||
from .pubsub import bus
|
||||
|
||||
|
||||
class VaultExportRequest(BaseModel):
|
||||
"""Parameters required to export the vault."""
|
||||
|
||||
path: Path
|
||||
|
||||
|
||||
class VaultExportResponse(BaseModel):
|
||||
"""Result of a vault export operation."""
|
||||
|
||||
path: Path
|
||||
|
||||
|
||||
class VaultImportRequest(BaseModel):
|
||||
"""Parameters required to import a vault."""
|
||||
|
||||
path: Path
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
"""Payload for :meth:`VaultService.change_password`."""
|
||||
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class UnlockRequest(BaseModel):
|
||||
"""Payload for unlocking the vault."""
|
||||
|
||||
password: str
|
||||
|
||||
|
||||
class UnlockResponse(BaseModel):
|
||||
"""Duration taken to unlock the vault."""
|
||||
|
||||
duration: float
|
||||
|
||||
|
||||
class BackupParentSeedRequest(BaseModel):
|
||||
"""Optional path to write the encrypted seed backup."""
|
||||
|
||||
path: Optional[Path] = None
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
class ProfileSwitchRequest(BaseModel):
|
||||
"""Select a different seed profile."""
|
||||
|
||||
fingerprint: str
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
class ProfileRemoveRequest(BaseModel):
|
||||
"""Remove a seed profile."""
|
||||
|
||||
fingerprint: str
|
||||
|
||||
|
||||
class SyncResponse(BaseModel):
|
||||
"""Information about uploaded events after syncing."""
|
||||
|
||||
manifest_id: str
|
||||
chunk_ids: List[str] = []
|
||||
delta_ids: List[str] = []
|
||||
|
||||
|
||||
class VaultService:
|
||||
"""Thread-safe wrapper around vault operations."""
|
||||
|
||||
def __init__(self, manager: PasswordManager) -> None:
|
||||
self._manager = manager
|
||||
self._lock = Lock()
|
||||
|
||||
def export_vault(self, req: VaultExportRequest) -> VaultExportResponse:
|
||||
"""Export the vault to ``req.path``."""
|
||||
|
||||
with self._lock:
|
||||
self._manager.handle_export_database(req.path)
|
||||
return VaultExportResponse(path=req.path)
|
||||
|
||||
def import_vault(self, req: VaultImportRequest) -> None:
|
||||
"""Import the vault from ``req.path`` and sync."""
|
||||
|
||||
with self._lock:
|
||||
self._manager.handle_import_database(req.path)
|
||||
self._manager.sync_vault()
|
||||
|
||||
def export_profile(self) -> bytes:
|
||||
"""Return encrypted profile data for backup."""
|
||||
|
||||
with self._lock:
|
||||
data = self._manager.vault.load_index()
|
||||
payload = json.dumps(data, sort_keys=True, separators=(",", ":")).encode(
|
||||
"utf-8"
|
||||
)
|
||||
return self._manager.vault.encryption_manager.encrypt_data(payload)
|
||||
|
||||
def import_profile(self, data: bytes) -> None:
|
||||
"""Restore a profile from ``data`` and sync."""
|
||||
|
||||
with self._lock:
|
||||
decrypted = self._manager.vault.encryption_manager.decrypt_data(data)
|
||||
index = json.loads(decrypted.decode("utf-8"))
|
||||
self._manager.vault.save_index(index)
|
||||
self._manager.sync_vault()
|
||||
|
||||
def change_password(self, req: ChangePasswordRequest) -> None:
|
||||
"""Change the master password."""
|
||||
|
||||
with self._lock:
|
||||
self._manager.change_password(req.old_password, req.new_password)
|
||||
|
||||
def unlock(self, req: UnlockRequest) -> UnlockResponse:
|
||||
"""Unlock the vault and return the duration."""
|
||||
|
||||
with self._lock:
|
||||
duration = self._manager.unlock_vault(req.password)
|
||||
return UnlockResponse(duration=duration)
|
||||
|
||||
def lock(self) -> None:
|
||||
"""Lock the vault and clear sensitive data."""
|
||||
|
||||
with self._lock:
|
||||
self._manager.lock_vault()
|
||||
|
||||
def backup_parent_seed(self, req: BackupParentSeedRequest) -> None:
|
||||
"""Backup and reveal the parent seed."""
|
||||
|
||||
with self._lock:
|
||||
self._manager.handle_backup_reveal_parent_seed(
|
||||
req.path, password=req.password
|
||||
)
|
||||
|
||||
def stats(self) -> Dict:
|
||||
"""Return statistics about the current profile."""
|
||||
|
||||
with self._lock:
|
||||
return self._manager.get_profile_stats()
|
||||
|
||||
|
||||
class ProfileService:
|
||||
"""Thread-safe wrapper around profile management operations."""
|
||||
|
||||
def __init__(self, manager: PasswordManager) -> None:
|
||||
self._manager = manager
|
||||
self._lock = Lock()
|
||||
|
||||
def list_profiles(self) -> List[str]:
|
||||
"""List available seed profiles."""
|
||||
|
||||
with self._lock:
|
||||
return list(self._manager.fingerprint_manager.list_fingerprints())
|
||||
|
||||
def add_profile(self) -> Optional[str]:
|
||||
"""Create a new seed profile and return its fingerprint if available."""
|
||||
|
||||
with self._lock:
|
||||
self._manager.add_new_fingerprint()
|
||||
return getattr(
|
||||
self._manager.fingerprint_manager, "current_fingerprint", None
|
||||
)
|
||||
|
||||
def remove_profile(self, req: ProfileRemoveRequest) -> None:
|
||||
"""Remove the specified seed profile."""
|
||||
|
||||
with self._lock:
|
||||
self._manager.fingerprint_manager.remove_fingerprint(req.fingerprint)
|
||||
|
||||
def switch_profile(self, req: ProfileSwitchRequest) -> None:
|
||||
"""Switch to ``req.fingerprint``."""
|
||||
|
||||
with self._lock:
|
||||
self._manager.select_fingerprint(req.fingerprint, password=req.password)
|
||||
|
||||
|
||||
class SyncService:
|
||||
"""Thread-safe wrapper around vault synchronization."""
|
||||
|
||||
def __init__(self, manager: PasswordManager) -> None:
|
||||
self._manager = manager
|
||||
self._lock = Lock()
|
||||
|
||||
def sync(self) -> Optional[SyncResponse]:
|
||||
"""Publish the vault to Nostr and return event info."""
|
||||
|
||||
with self._lock:
|
||||
bus.publish("sync_started")
|
||||
result = self._manager.sync_vault()
|
||||
bus.publish("sync_finished", result)
|
||||
if not result:
|
||||
return None
|
||||
return SyncResponse(**result)
|
||||
|
||||
def start_background_sync(self) -> None:
|
||||
"""Begin background synchronization if possible."""
|
||||
|
||||
with self._lock:
|
||||
self._manager.start_background_sync()
|
||||
|
||||
def start_background_vault_sync(self, summary: Optional[str] = None) -> None:
|
||||
"""Publish the vault in a background thread."""
|
||||
|
||||
with self._lock:
|
||||
self._manager.start_background_vault_sync(summary)
|
||||
|
||||
|
||||
class EntryService:
|
||||
"""Thread-safe wrapper around entry operations."""
|
||||
|
||||
def __init__(self, manager: PasswordManager) -> None:
|
||||
self._manager = manager
|
||||
self._lock = Lock()
|
||||
|
||||
def list_entries(
|
||||
self,
|
||||
sort_by: str = "index",
|
||||
filter_kind: str | None = None,
|
||||
include_archived: bool = False,
|
||||
):
|
||||
with self._lock:
|
||||
return self._manager.entry_manager.list_entries(
|
||||
sort_by=sort_by,
|
||||
filter_kind=filter_kind,
|
||||
include_archived=include_archived,
|
||||
)
|
||||
|
||||
def search_entries(
|
||||
self, query: str, kinds: list[str] | None = None
|
||||
) -> list[tuple[int, str, str | None, str | None, bool]]:
|
||||
"""Search entries optionally filtering by ``kinds``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
query:
|
||||
Search string to match against entry metadata.
|
||||
kinds:
|
||||
Optional list of entry kinds to restrict the search.
|
||||
"""
|
||||
|
||||
with self._lock:
|
||||
return self._manager.entry_manager.search_entries(query, kinds=kinds)
|
||||
|
||||
def retrieve_entry(self, entry_id: int):
|
||||
with self._lock:
|
||||
return self._manager.entry_manager.retrieve_entry(entry_id)
|
||||
|
||||
def generate_password(self, length: int, index: int) -> str:
|
||||
with self._lock:
|
||||
return self._manager.password_generator.generate_password(length, index)
|
||||
|
||||
def get_totp_code(self, entry_id: int) -> str:
|
||||
with self._lock:
|
||||
return self._manager.entry_manager.get_totp_code(
|
||||
entry_id, self._manager.parent_seed
|
||||
)
|
||||
|
||||
def add_entry(
|
||||
self,
|
||||
label: str,
|
||||
length: int,
|
||||
username: str | None = None,
|
||||
url: str | None = None,
|
||||
) -> int:
|
||||
with self._lock:
|
||||
idx = self._manager.entry_manager.add_entry(label, length, username, url)
|
||||
self._manager.start_background_vault_sync()
|
||||
return idx
|
||||
|
||||
def add_totp(
|
||||
self,
|
||||
label: str,
|
||||
*,
|
||||
index: int | None = None,
|
||||
secret: str | None = None,
|
||||
period: int = 30,
|
||||
digits: int = 6,
|
||||
) -> str:
|
||||
with self._lock:
|
||||
uri = self._manager.entry_manager.add_totp(
|
||||
label,
|
||||
self._manager.parent_seed,
|
||||
index=index,
|
||||
secret=secret,
|
||||
period=period,
|
||||
digits=digits,
|
||||
)
|
||||
self._manager.start_background_vault_sync()
|
||||
return uri
|
||||
|
||||
def add_ssh_key(
|
||||
self,
|
||||
label: str,
|
||||
*,
|
||||
index: int | None = None,
|
||||
notes: str = "",
|
||||
) -> int:
|
||||
with self._lock:
|
||||
idx = self._manager.entry_manager.add_ssh_key(
|
||||
label,
|
||||
self._manager.parent_seed,
|
||||
index=index,
|
||||
notes=notes,
|
||||
)
|
||||
self._manager.start_background_vault_sync()
|
||||
return idx
|
||||
|
||||
def add_pgp_key(
|
||||
self,
|
||||
label: str,
|
||||
*,
|
||||
index: int | None = None,
|
||||
key_type: str = "ed25519",
|
||||
user_id: str = "",
|
||||
notes: str = "",
|
||||
) -> int:
|
||||
with self._lock:
|
||||
idx = self._manager.entry_manager.add_pgp_key(
|
||||
label,
|
||||
self._manager.parent_seed,
|
||||
index=index,
|
||||
key_type=key_type,
|
||||
user_id=user_id,
|
||||
notes=notes,
|
||||
)
|
||||
self._manager.start_background_vault_sync()
|
||||
return idx
|
||||
|
||||
def add_nostr_key(
|
||||
self,
|
||||
label: str,
|
||||
*,
|
||||
index: int | None = None,
|
||||
notes: str = "",
|
||||
) -> int:
|
||||
with self._lock:
|
||||
idx = self._manager.entry_manager.add_nostr_key(
|
||||
label,
|
||||
index=index,
|
||||
notes=notes,
|
||||
)
|
||||
self._manager.start_background_vault_sync()
|
||||
return idx
|
||||
|
||||
def add_seed(
|
||||
self,
|
||||
label: str,
|
||||
*,
|
||||
index: int | None = None,
|
||||
words: int = 24,
|
||||
notes: str = "",
|
||||
) -> int:
|
||||
with self._lock:
|
||||
idx = self._manager.entry_manager.add_seed(
|
||||
label,
|
||||
self._manager.parent_seed,
|
||||
index=index,
|
||||
words_num=words,
|
||||
notes=notes,
|
||||
)
|
||||
self._manager.start_background_vault_sync()
|
||||
return idx
|
||||
|
||||
def add_key_value(self, label: str, value: str, *, notes: str = "") -> int:
|
||||
with self._lock:
|
||||
idx = self._manager.entry_manager.add_key_value(label, value, notes=notes)
|
||||
self._manager.start_background_vault_sync()
|
||||
return idx
|
||||
|
||||
def add_managed_account(
|
||||
self,
|
||||
label: str,
|
||||
*,
|
||||
index: int | None = None,
|
||||
notes: str = "",
|
||||
) -> int:
|
||||
with self._lock:
|
||||
idx = self._manager.entry_manager.add_managed_account(
|
||||
label,
|
||||
self._manager.parent_seed,
|
||||
index=index,
|
||||
notes=notes,
|
||||
)
|
||||
self._manager.start_background_vault_sync()
|
||||
return idx
|
||||
|
||||
def modify_entry(
|
||||
self,
|
||||
entry_id: int,
|
||||
*,
|
||||
username: str | None = None,
|
||||
url: str | None = None,
|
||||
notes: str | None = None,
|
||||
label: str | None = None,
|
||||
period: int | None = None,
|
||||
digits: int | None = None,
|
||||
value: str | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._manager.entry_manager.modify_entry(
|
||||
entry_id,
|
||||
username=username,
|
||||
url=url,
|
||||
notes=notes,
|
||||
label=label,
|
||||
period=period,
|
||||
digits=digits,
|
||||
value=value,
|
||||
)
|
||||
self._manager.start_background_vault_sync()
|
||||
|
||||
def archive_entry(self, entry_id: int) -> None:
|
||||
with self._lock:
|
||||
self._manager.entry_manager.archive_entry(entry_id)
|
||||
self._manager.start_background_vault_sync()
|
||||
|
||||
def restore_entry(self, entry_id: int) -> None:
|
||||
with self._lock:
|
||||
self._manager.entry_manager.restore_entry(entry_id)
|
||||
self._manager.start_background_vault_sync()
|
||||
|
||||
def export_totp_entries(self) -> dict:
|
||||
with self._lock:
|
||||
return self._manager.entry_manager.export_totp_entries(
|
||||
self._manager.parent_seed
|
||||
)
|
||||
|
||||
def display_totp_codes(self) -> None:
|
||||
with self._lock:
|
||||
self._manager.handle_display_totp_codes()
|
||||
|
||||
|
||||
class ConfigService:
|
||||
"""Thread-safe wrapper around configuration access."""
|
||||
|
||||
def __init__(self, manager: PasswordManager) -> None:
|
||||
self._manager = manager
|
||||
self._lock = Lock()
|
||||
|
||||
def get(self, key: str):
|
||||
with self._lock:
|
||||
return self._manager.config_manager.load_config(require_pin=False).get(key)
|
||||
|
||||
def set(self, key: str, value: str) -> None:
|
||||
cfg = self._manager.config_manager
|
||||
mapping = {
|
||||
"inactivity_timeout": ("set_inactivity_timeout", float),
|
||||
"secret_mode_enabled": (
|
||||
"set_secret_mode_enabled",
|
||||
lambda v: v.lower() in ("1", "true", "yes", "y", "on"),
|
||||
),
|
||||
"clipboard_clear_delay": ("set_clipboard_clear_delay", int),
|
||||
"additional_backup_path": (
|
||||
"set_additional_backup_path",
|
||||
lambda v: v or None,
|
||||
),
|
||||
"relays": ("set_relays", lambda v: (v, {"require_pin": False})),
|
||||
"kdf_iterations": ("set_kdf_iterations", int),
|
||||
"kdf_mode": ("set_kdf_mode", lambda v: v),
|
||||
"backup_interval": ("set_backup_interval", float),
|
||||
"nostr_max_retries": ("set_nostr_max_retries", int),
|
||||
"nostr_retry_delay": ("set_nostr_retry_delay", float),
|
||||
"min_uppercase": ("set_min_uppercase", int),
|
||||
"min_lowercase": ("set_min_lowercase", int),
|
||||
"min_digits": ("set_min_digits", int),
|
||||
"min_special": ("set_min_special", int),
|
||||
"quick_unlock": (
|
||||
"set_quick_unlock",
|
||||
lambda v: v.lower() in ("1", "true", "yes", "y", "on"),
|
||||
),
|
||||
}
|
||||
entry = mapping.get(key)
|
||||
if entry is None:
|
||||
raise KeyError(key)
|
||||
method_name, conv = entry
|
||||
with self._lock:
|
||||
result = conv(value)
|
||||
if (
|
||||
isinstance(result, tuple)
|
||||
and len(result) == 2
|
||||
and isinstance(result[1], dict)
|
||||
):
|
||||
arg, kwargs = result
|
||||
getattr(cfg, method_name)(arg, **kwargs)
|
||||
else:
|
||||
getattr(cfg, method_name)(result)
|
||||
|
||||
def get_secret_mode_enabled(self) -> bool:
|
||||
with self._lock:
|
||||
return self._manager.config_manager.get_secret_mode_enabled()
|
||||
|
||||
def get_clipboard_clear_delay(self) -> int:
|
||||
with self._lock:
|
||||
return self._manager.config_manager.get_clipboard_clear_delay()
|
||||
|
||||
def set_secret_mode(self, enabled: bool, delay: int) -> None:
|
||||
with self._lock:
|
||||
cfg = self._manager.config_manager
|
||||
cfg.set_secret_mode_enabled(enabled)
|
||||
cfg.set_clipboard_clear_delay(delay)
|
||||
self._manager.secret_mode_enabled = enabled
|
||||
self._manager.clipboard_clear_delay = delay
|
||||
|
||||
def get_offline_mode(self) -> bool:
|
||||
with self._lock:
|
||||
return self._manager.config_manager.get_offline_mode()
|
||||
|
||||
def set_offline_mode(self, enabled: bool) -> None:
|
||||
with self._lock:
|
||||
cfg = self._manager.config_manager
|
||||
cfg.set_offline_mode(enabled)
|
||||
self._manager.offline_mode = enabled
|
||||
|
||||
|
||||
class UtilityService:
|
||||
"""Miscellaneous helper operations."""
|
||||
|
||||
def __init__(self, manager: PasswordManager) -> None:
|
||||
self._manager = manager
|
||||
self._lock = Lock()
|
||||
|
||||
def generate_password(self, length: int) -> str:
|
||||
with self._lock:
|
||||
return self._manager.password_generator.generate_password(length)
|
||||
|
||||
def verify_checksum(self) -> None:
|
||||
with self._lock:
|
||||
self._manager.handle_verify_checksum()
|
||||
|
||||
def update_checksum(self) -> None:
|
||||
with self._lock:
|
||||
self._manager.handle_update_script_checksum()
|
||||
|
||||
|
||||
class NostrService:
|
||||
"""Nostr related helper methods."""
|
||||
|
||||
def __init__(self, manager: PasswordManager) -> None:
|
||||
self._manager = manager
|
||||
self._lock = Lock()
|
||||
|
||||
def get_pubkey(self) -> str:
|
||||
with self._lock:
|
||||
return self._manager.nostr_client.key_manager.get_npub()
|
||||
|
||||
def list_relays(self) -> list[str]:
|
||||
with self._lock:
|
||||
return self._manager.state_manager.list_relays()
|
||||
|
||||
def add_relay(self, url: str) -> None:
|
||||
with self._lock:
|
||||
self._manager.state_manager.add_relay(url)
|
||||
self._manager.nostr_client.relays = (
|
||||
self._manager.state_manager.list_relays()
|
||||
)
|
||||
|
||||
def remove_relay(self, idx: int) -> None:
|
||||
with self._lock:
|
||||
self._manager.state_manager.remove_relay(idx)
|
||||
self._manager.nostr_client.relays = (
|
||||
self._manager.state_manager.list_relays()
|
||||
)
|
@@ -1,4 +1,4 @@
|
||||
# password_manager/backup.py
|
||||
# seedpass.core/backup.py
|
||||
|
||||
"""
|
||||
Backup Manager Module
|
||||
@@ -19,7 +19,7 @@ import traceback
|
||||
from pathlib import Path
|
||||
from termcolor import colored
|
||||
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from .config_manager import ConfigManager
|
||||
|
||||
from utils.file_lock import exclusive_lock
|
||||
from constants import APP_DIR
|
@@ -10,10 +10,10 @@ from utils.seed_prompt import masked_input
|
||||
|
||||
import bcrypt
|
||||
|
||||
from password_manager.vault import Vault
|
||||
from .vault import Vault
|
||||
from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS
|
||||
|
||||
from constants import INACTIVITY_TIMEOUT
|
||||
from constants import INACTIVITY_TIMEOUT, MAX_RETRIES, RETRY_DELAY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,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)
|
@@ -1,4 +1,4 @@
|
||||
# /src/password_manager/encryption.py
|
||||
# /src/seedpass.core/encryption.py
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
@@ -228,6 +228,7 @@ class EncryptionManager:
|
||||
relative_path: Optional[Path] = None,
|
||||
*,
|
||||
strict: bool = True,
|
||||
merge: bool = False,
|
||||
) -> bool:
|
||||
"""Decrypts data from Nostr and saves it.
|
||||
|
||||
@@ -249,6 +250,20 @@ class EncryptionManager:
|
||||
data = json_lib.loads(decrypted_data)
|
||||
else:
|
||||
data = json_lib.loads(decrypted_data.decode("utf-8"))
|
||||
if merge and (self.fingerprint_dir / relative_path).exists():
|
||||
current = self.load_json_data(relative_path)
|
||||
current_entries = current.get("entries", {})
|
||||
for idx, entry in data.get("entries", {}).items():
|
||||
cur_ts = current_entries.get(idx, {}).get("modified_ts", 0)
|
||||
new_ts = entry.get("modified_ts", 0)
|
||||
if idx not in current_entries or new_ts >= cur_ts:
|
||||
current_entries[idx] = entry
|
||||
current["entries"] = current_entries
|
||||
if "schema_version" in data:
|
||||
current["schema_version"] = max(
|
||||
current.get("schema_version", 0), data.get("schema_version", 0)
|
||||
)
|
||||
data = current
|
||||
self.save_json_data(data, relative_path) # This always saves in V2 format
|
||||
self.update_checksum(relative_path)
|
||||
logger.info("Index file from Nostr was processed and saved successfully.")
|
@@ -1,4 +1,4 @@
|
||||
# password_manager/entry_management.py
|
||||
# seedpass.core/entry_management.py
|
||||
|
||||
"""
|
||||
Entry Management Module
|
||||
@@ -27,18 +27,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
|
||||
|
@@ -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
|
@@ -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
|
@@ -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__)
|
@@ -12,14 +12,14 @@ import asyncio
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from password_manager.vault import Vault
|
||||
from password_manager.backup import BackupManager
|
||||
from .vault import Vault
|
||||
from .backup import BackupManager
|
||||
from nostr.client import NostrClient
|
||||
from utils.key_derivation import (
|
||||
derive_index_key,
|
||||
EncryptionMode,
|
||||
)
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from .encryption import EncryptionManager
|
||||
from utils.checksum import json_checksum, canonical_json_dumps
|
||||
|
||||
logger = logging.getLogger(__name__)
|
27
src/seedpass/core/pubsub.py
Normal file
27
src/seedpass/core/pubsub.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from collections import defaultdict
|
||||
from typing import Callable, Dict, List, Any
|
||||
|
||||
|
||||
class PubSub:
|
||||
"""Simple in-process event bus using the observer pattern."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._subscribers: Dict[str, List[Callable[..., None]]] = defaultdict(list)
|
||||
|
||||
def subscribe(self, event: str, callback: Callable[..., None]) -> None:
|
||||
"""Register ``callback`` to be invoked when ``event`` is published."""
|
||||
self._subscribers[event].append(callback)
|
||||
|
||||
def unsubscribe(self, event: str, callback: Callable[..., None]) -> None:
|
||||
"""Unregister ``callback`` from ``event`` notifications."""
|
||||
if callback in self._subscribers.get(event, []):
|
||||
self._subscribers[event].remove(callback)
|
||||
|
||||
def publish(self, event: str, *args: Any, **kwargs: Any) -> None:
|
||||
"""Notify all subscribers of ``event`` passing ``*args`` and ``**kwargs``."""
|
||||
for callback in list(self._subscribers.get(event, [])):
|
||||
callback(*args, **kwargs)
|
||||
|
||||
|
||||
# Global bus instance for convenience
|
||||
bus = PubSub()
|
91
src/seedpass/core/state_manager.py
Normal file
91
src/seedpass/core/state_manager.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from utils.file_lock import exclusive_lock, shared_lock
|
||||
from nostr.client import DEFAULT_RELAYS
|
||||
|
||||
|
||||
class StateManager:
|
||||
"""Persist simple state values per profile."""
|
||||
|
||||
STATE_FILENAME = "seedpass_state.json"
|
||||
|
||||
def __init__(self, fingerprint_dir: Path) -> None:
|
||||
self.fingerprint_dir = Path(fingerprint_dir)
|
||||
self.state_path = self.fingerprint_dir / self.STATE_FILENAME
|
||||
|
||||
def _load(self) -> dict:
|
||||
if not self.state_path.exists():
|
||||
return {
|
||||
"last_bip85_idx": 0,
|
||||
"last_sync_ts": 0,
|
||||
"manifest_id": None,
|
||||
"delta_since": 0,
|
||||
"relays": list(DEFAULT_RELAYS),
|
||||
}
|
||||
with shared_lock(self.state_path) as fh:
|
||||
fh.seek(0)
|
||||
data = fh.read()
|
||||
if not data:
|
||||
return {
|
||||
"last_bip85_idx": 0,
|
||||
"last_sync_ts": 0,
|
||||
"manifest_id": None,
|
||||
"delta_since": 0,
|
||||
"relays": list(DEFAULT_RELAYS),
|
||||
}
|
||||
try:
|
||||
obj = json.loads(data.decode())
|
||||
except Exception:
|
||||
obj = {}
|
||||
obj.setdefault("last_bip85_idx", 0)
|
||||
obj.setdefault("last_sync_ts", 0)
|
||||
obj.setdefault("manifest_id", None)
|
||||
obj.setdefault("delta_since", 0)
|
||||
obj.setdefault("relays", list(DEFAULT_RELAYS))
|
||||
return obj
|
||||
|
||||
def _save(self, data: dict) -> None:
|
||||
with exclusive_lock(self.state_path) as fh:
|
||||
fh.seek(0)
|
||||
fh.truncate()
|
||||
fh.write(json.dumps(data, separators=(",", ":")).encode())
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
|
||||
@property
|
||||
def state(self) -> dict:
|
||||
return self._load()
|
||||
|
||||
def update_state(self, **kwargs) -> None:
|
||||
data = self._load()
|
||||
data.update(kwargs)
|
||||
self._save(data)
|
||||
|
||||
# Relay helpers
|
||||
def list_relays(self) -> List[str]:
|
||||
return self._load().get("relays", [])
|
||||
|
||||
def add_relay(self, url: str) -> None:
|
||||
data = self._load()
|
||||
relays = data.get("relays", [])
|
||||
if url in relays:
|
||||
raise ValueError("Relay already present")
|
||||
relays.append(url)
|
||||
data["relays"] = relays
|
||||
self._save(data)
|
||||
|
||||
def remove_relay(self, idx: int) -> None:
|
||||
data = self._load()
|
||||
relays = data.get("relays", [])
|
||||
if not 1 <= idx <= len(relays):
|
||||
raise ValueError("Invalid index")
|
||||
if len(relays) == 1:
|
||||
raise ValueError("At least one relay required")
|
||||
relays.pop(idx - 1)
|
||||
data["relays"] = relays
|
||||
self._save(data)
|
@@ -61,11 +61,11 @@ class Vault:
|
||||
return self.encryption_manager.get_encrypted_index()
|
||||
|
||||
def decrypt_and_save_index_from_nostr(
|
||||
self, encrypted_data: bytes, *, strict: bool = True
|
||||
self, encrypted_data: bytes, *, strict: bool = True, merge: bool = False
|
||||
) -> bool:
|
||||
"""Decrypt Nostr payload and overwrite the local index."""
|
||||
"""Decrypt Nostr payload and update the local index."""
|
||||
return self.encryption_manager.decrypt_and_save_index_from_nostr(
|
||||
encrypted_data, strict=strict
|
||||
encrypted_data, strict=strict, merge=merge
|
||||
)
|
||||
|
||||
# ----- Config helpers -----
|
11
src/seedpass_gui/__init__.py
Normal file
11
src/seedpass_gui/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Graphical user interface for SeedPass."""
|
||||
|
||||
from .app import SeedPassApp, build
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Launch the GUI application."""
|
||||
build().main_loop()
|
||||
|
||||
|
||||
__all__ = ["SeedPassApp", "main"]
|
4
src/seedpass_gui/__main__.py
Normal file
4
src/seedpass_gui/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
476
src/seedpass_gui/app.py
Normal file
476
src/seedpass_gui/app.py
Normal file
@@ -0,0 +1,476 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import toga
|
||||
from toga.style import Pack
|
||||
from toga.sources import ListSource
|
||||
from toga.style.pack import COLUMN, ROW
|
||||
|
||||
from seedpass.core.entry_types import EntryType
|
||||
from seedpass.core.manager import PasswordManager
|
||||
from seedpass.core.totp import TotpManager
|
||||
|
||||
from seedpass.core.api import (
|
||||
VaultService,
|
||||
EntryService,
|
||||
NostrService,
|
||||
UnlockRequest,
|
||||
)
|
||||
from seedpass.core.pubsub import bus
|
||||
|
||||
|
||||
class LockScreenWindow(toga.Window):
|
||||
"""Window prompting for the master password."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller: SeedPassApp,
|
||||
vault: VaultService,
|
||||
entries: EntryService,
|
||||
) -> None:
|
||||
super().__init__("Unlock Vault")
|
||||
# Store a reference to the SeedPass application instance separately from
|
||||
# the ``toga`` ``Window.app`` attribute to avoid conflicts.
|
||||
self.controller = controller
|
||||
self.vault = vault
|
||||
self.entries = entries
|
||||
|
||||
self.password_input = toga.PasswordInput(style=Pack(flex=1))
|
||||
self.message = toga.Label("", style=Pack(color="red"))
|
||||
unlock_button = toga.Button(
|
||||
"Unlock", on_press=self.handle_unlock, style=Pack(padding_top=10)
|
||||
)
|
||||
|
||||
box = toga.Box(style=Pack(direction=COLUMN, padding=20))
|
||||
box.add(toga.Label("Master Password:"))
|
||||
box.add(self.password_input)
|
||||
box.add(unlock_button)
|
||||
box.add(self.message)
|
||||
self.content = box
|
||||
|
||||
def handle_unlock(self, widget: toga.Widget) -> None:
|
||||
password = self.password_input.value or ""
|
||||
try:
|
||||
self.vault.unlock(UnlockRequest(password=password))
|
||||
except Exception as exc: # pragma: no cover - GUI error handling
|
||||
self.message.text = str(exc)
|
||||
return
|
||||
main = MainWindow(
|
||||
self.controller,
|
||||
self.vault,
|
||||
self.entries,
|
||||
self.controller.nostr_service,
|
||||
)
|
||||
self.controller.main_window = main
|
||||
main.show()
|
||||
self.close()
|
||||
|
||||
|
||||
class MainWindow(toga.Window):
|
||||
"""Main application window showing vault entries."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller: SeedPassApp,
|
||||
vault: VaultService,
|
||||
entries: EntryService,
|
||||
nostr: NostrService,
|
||||
) -> None:
|
||||
super().__init__("SeedPass", on_close=self.cleanup)
|
||||
# ``Window.app`` is reserved for the Toga ``App`` instance. Store the
|
||||
# SeedPass application reference separately.
|
||||
self.controller = controller
|
||||
self.vault = vault
|
||||
self.entries = entries
|
||||
self.nostr = nostr
|
||||
bus.subscribe("sync_started", self.sync_started)
|
||||
bus.subscribe("sync_finished", self.sync_finished)
|
||||
bus.subscribe("vault_locked", self.vault_locked)
|
||||
self.last_sync = None
|
||||
|
||||
self.entry_source = ListSource(["id", "label", "kind", "info1", "info2"])
|
||||
self.table = toga.Table(
|
||||
headings=["ID", "Label", "Kind", "Info 1", "Info 2"],
|
||||
data=self.entry_source,
|
||||
style=Pack(flex=1),
|
||||
)
|
||||
|
||||
add_button = toga.Button("Add", on_press=self.add_entry)
|
||||
edit_button = toga.Button("Edit", on_press=self.edit_entry)
|
||||
search_button = toga.Button("Search", on_press=self.search_entries)
|
||||
relay_button = toga.Button("Relays", on_press=self.manage_relays)
|
||||
totp_button = toga.Button("TOTP", on_press=self.show_totp_codes)
|
||||
sync_button = toga.Button("Sync", on_press=self.start_vault_sync)
|
||||
|
||||
button_box = toga.Box(style=Pack(direction=ROW, padding_top=5))
|
||||
button_box.add(add_button)
|
||||
button_box.add(edit_button)
|
||||
button_box.add(search_button)
|
||||
button_box.add(relay_button)
|
||||
button_box.add(totp_button)
|
||||
button_box.add(sync_button)
|
||||
|
||||
self.status = toga.Label("Last sync: never", style=Pack(padding_top=5))
|
||||
|
||||
box = toga.Box(style=Pack(direction=COLUMN, padding=10))
|
||||
box.add(self.table)
|
||||
box.add(button_box)
|
||||
box.add(self.status)
|
||||
self.content = box
|
||||
|
||||
self.refresh_entries()
|
||||
|
||||
def refresh_entries(self) -> None:
|
||||
self.entry_source.clear()
|
||||
for idx, label, username, url, _arch in self.entries.list_entries():
|
||||
entry = self.entries.retrieve_entry(idx)
|
||||
kind = (entry or {}).get("kind", (entry or {}).get("type", ""))
|
||||
info1 = ""
|
||||
info2 = ""
|
||||
if kind == EntryType.PASSWORD.value:
|
||||
info1 = username or ""
|
||||
info2 = url or ""
|
||||
elif kind == EntryType.KEY_VALUE.value:
|
||||
info1 = entry.get("value", "") if entry else ""
|
||||
else:
|
||||
info1 = str(entry.get("index", "")) if entry else ""
|
||||
self.entry_source.append(
|
||||
{
|
||||
"id": idx,
|
||||
"label": label,
|
||||
"kind": kind,
|
||||
"info1": info1,
|
||||
"info2": info2,
|
||||
}
|
||||
)
|
||||
|
||||
# --- Button handlers -------------------------------------------------
|
||||
def add_entry(self, widget: toga.Widget) -> None:
|
||||
dlg = EntryDialog(self, None)
|
||||
dlg.show()
|
||||
|
||||
def edit_entry(self, widget: toga.Widget) -> None:
|
||||
if self.table.selection is None:
|
||||
return
|
||||
entry_id = int(self.table.selection[0])
|
||||
dlg = EntryDialog(self, entry_id)
|
||||
dlg.show()
|
||||
|
||||
def search_entries(self, widget: toga.Widget) -> None:
|
||||
dlg = SearchDialog(self)
|
||||
dlg.show()
|
||||
|
||||
def manage_relays(self, widget: toga.Widget) -> None:
|
||||
dlg = RelayManagerDialog(self, self.nostr)
|
||||
dlg.show()
|
||||
|
||||
def show_totp_codes(self, widget: toga.Widget) -> None:
|
||||
win = TotpViewerWindow(self.controller, self.entries)
|
||||
win.show()
|
||||
|
||||
def start_vault_sync(self, widget: toga.Widget | None = None) -> None:
|
||||
"""Schedule a background vault synchronization."""
|
||||
|
||||
async def _runner() -> None:
|
||||
self.nostr.start_background_vault_sync()
|
||||
|
||||
self.controller.loop.create_task(_runner())
|
||||
|
||||
# --- PubSub callbacks -------------------------------------------------
|
||||
def sync_started(self, *args: object, **kwargs: object) -> None:
|
||||
self.status.text = "Syncing..."
|
||||
|
||||
def sync_finished(self, *args: object, **kwargs: object) -> None:
|
||||
self.last_sync = time.strftime("%H:%M:%S")
|
||||
self.status.text = f"Last sync: {self.last_sync}"
|
||||
|
||||
def vault_locked(self, *args: object, **kwargs: object) -> None:
|
||||
self.close()
|
||||
self.controller.main_window = None
|
||||
self.controller.lock_window.show()
|
||||
|
||||
def cleanup(self, *args: object, **kwargs: object) -> None:
|
||||
bus.unsubscribe("sync_started", self.sync_started)
|
||||
bus.unsubscribe("sync_finished", self.sync_finished)
|
||||
bus.unsubscribe("vault_locked", self.vault_locked)
|
||||
|
||||
|
||||
class EntryDialog(toga.Window):
|
||||
"""Dialog for adding or editing an entry."""
|
||||
|
||||
def __init__(self, main: MainWindow, entry_id: int | None) -> None:
|
||||
title = "Add Entry" if entry_id is None else "Edit Entry"
|
||||
super().__init__(title)
|
||||
self.main = main
|
||||
self.entry_id = entry_id
|
||||
|
||||
self.label_input = toga.TextInput(style=Pack(flex=1))
|
||||
self.kind_input = toga.Selection(
|
||||
items=[e.value for e in EntryType],
|
||||
style=Pack(flex=1),
|
||||
)
|
||||
self.kind_input.value = EntryType.PASSWORD.value
|
||||
self.username_input = toga.TextInput(style=Pack(flex=1))
|
||||
self.url_input = toga.TextInput(style=Pack(flex=1))
|
||||
self.length_input = toga.NumberInput(
|
||||
min=8, max=128, style=Pack(width=80), value=16
|
||||
)
|
||||
self.value_input = toga.TextInput(style=Pack(flex=1))
|
||||
|
||||
save_button = toga.Button(
|
||||
"Save", on_press=self.save, style=Pack(padding_top=10)
|
||||
)
|
||||
|
||||
box = toga.Box(style=Pack(direction=COLUMN, padding=20))
|
||||
box.add(toga.Label("Label"))
|
||||
box.add(self.label_input)
|
||||
box.add(toga.Label("Kind"))
|
||||
box.add(self.kind_input)
|
||||
box.add(toga.Label("Username"))
|
||||
box.add(self.username_input)
|
||||
box.add(toga.Label("URL"))
|
||||
box.add(self.url_input)
|
||||
box.add(toga.Label("Length"))
|
||||
box.add(self.length_input)
|
||||
box.add(toga.Label("Value"))
|
||||
box.add(self.value_input)
|
||||
box.add(save_button)
|
||||
self.content = box
|
||||
|
||||
if entry_id is not None:
|
||||
entry = self.main.entries.retrieve_entry(entry_id)
|
||||
if entry:
|
||||
self.label_input.value = entry.get("label", "")
|
||||
kind = entry.get("kind", entry.get("type", EntryType.PASSWORD.value))
|
||||
self.kind_input.value = kind
|
||||
self.kind_input.enabled = False
|
||||
self.username_input.value = entry.get("username", "") or ""
|
||||
self.url_input.value = entry.get("url", "") or ""
|
||||
self.length_input.value = entry.get("length", 16)
|
||||
self.value_input.value = entry.get("value", "")
|
||||
|
||||
def save(self, widget: toga.Widget) -> None:
|
||||
label = self.label_input.value or ""
|
||||
username = self.username_input.value or None
|
||||
url = self.url_input.value or None
|
||||
length = int(self.length_input.value or 16)
|
||||
kind = self.kind_input.value
|
||||
value = self.value_input.value or None
|
||||
|
||||
if self.entry_id is None:
|
||||
if kind == EntryType.PASSWORD.value:
|
||||
entry_id = self.main.entries.add_entry(
|
||||
label, length, username=username, url=url
|
||||
)
|
||||
elif kind == EntryType.TOTP.value:
|
||||
entry_id = self.main.entries.add_totp(label)
|
||||
elif kind == EntryType.SSH.value:
|
||||
entry_id = self.main.entries.add_ssh_key(label)
|
||||
elif kind == EntryType.SEED.value:
|
||||
entry_id = self.main.entries.add_seed(label)
|
||||
elif kind == EntryType.PGP.value:
|
||||
entry_id = self.main.entries.add_pgp_key(label)
|
||||
elif kind == EntryType.NOSTR.value:
|
||||
entry_id = self.main.entries.add_nostr_key(label)
|
||||
elif kind == EntryType.KEY_VALUE.value:
|
||||
entry_id = self.main.entries.add_key_value(label, value or "")
|
||||
elif kind == EntryType.MANAGED_ACCOUNT.value:
|
||||
entry_id = self.main.entries.add_managed_account(label)
|
||||
else:
|
||||
entry_id = self.entry_id
|
||||
kwargs = {"label": label}
|
||||
if kind == EntryType.PASSWORD.value:
|
||||
kwargs.update({"username": username, "url": url})
|
||||
elif kind == EntryType.KEY_VALUE.value:
|
||||
kwargs.update({"value": value})
|
||||
self.main.entries.modify_entry(entry_id, **kwargs)
|
||||
|
||||
entry = self.main.entries.retrieve_entry(entry_id) or {}
|
||||
kind = entry.get("kind", entry.get("type", kind))
|
||||
info1 = ""
|
||||
info2 = ""
|
||||
if kind == EntryType.PASSWORD.value:
|
||||
info1 = username or ""
|
||||
info2 = url or ""
|
||||
elif kind == EntryType.KEY_VALUE.value:
|
||||
info1 = entry.get("value", value or "")
|
||||
else:
|
||||
info1 = str(entry.get("index", ""))
|
||||
|
||||
row = {
|
||||
"id": entry_id,
|
||||
"label": label,
|
||||
"kind": kind,
|
||||
"info1": info1,
|
||||
"info2": info2,
|
||||
}
|
||||
|
||||
if self.entry_id is None:
|
||||
self.main.entry_source.append(row)
|
||||
else:
|
||||
for existing in self.main.entry_source:
|
||||
if getattr(existing, "id", None) == entry_id:
|
||||
for key, value in row.items():
|
||||
setattr(existing, key, value)
|
||||
break
|
||||
|
||||
self.close()
|
||||
# schedule vault sync after saving
|
||||
getattr(self.main, "start_vault_sync", lambda *_: None)()
|
||||
|
||||
|
||||
class SearchDialog(toga.Window):
|
||||
"""Dialog for searching entries."""
|
||||
|
||||
def __init__(self, main: MainWindow) -> None:
|
||||
super().__init__("Search Entries")
|
||||
self.main = main
|
||||
self.query_input = toga.TextInput(style=Pack(flex=1))
|
||||
search_button = toga.Button(
|
||||
"Search", on_press=self.do_search, style=Pack(padding_top=10)
|
||||
)
|
||||
box = toga.Box(style=Pack(direction=COLUMN, padding=20))
|
||||
box.add(toga.Label("Query"))
|
||||
box.add(self.query_input)
|
||||
box.add(search_button)
|
||||
self.content = box
|
||||
|
||||
def do_search(self, widget: toga.Widget) -> None:
|
||||
query = self.query_input.value or ""
|
||||
results = self.main.entries.search_entries(query)
|
||||
self.main.entry_source.clear()
|
||||
for idx, label, username, url, _arch in results:
|
||||
self.main.entry_source.append(
|
||||
{
|
||||
"id": idx,
|
||||
"label": label,
|
||||
"kind": "",
|
||||
"info1": username or "",
|
||||
"info2": url or "",
|
||||
}
|
||||
)
|
||||
self.close()
|
||||
|
||||
|
||||
class TotpViewerWindow(toga.Window):
|
||||
"""Window displaying active TOTP codes."""
|
||||
|
||||
def __init__(self, controller: SeedPassApp, entries: EntryService) -> None:
|
||||
super().__init__("TOTP Codes", on_close=self.cleanup)
|
||||
self.controller = controller
|
||||
self.entries = entries
|
||||
|
||||
self.table = toga.Table(
|
||||
headings=["Label", "Code", "Seconds"],
|
||||
style=Pack(flex=1),
|
||||
)
|
||||
|
||||
box = toga.Box(style=Pack(direction=COLUMN, padding=20))
|
||||
box.add(self.table)
|
||||
self.content = box
|
||||
|
||||
self._running = True
|
||||
self.controller.loop.create_task(self._update_loop())
|
||||
self.refresh_codes()
|
||||
|
||||
async def _update_loop(self) -> None:
|
||||
while self._running:
|
||||
self.refresh_codes()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
def refresh_codes(self) -> None:
|
||||
self.table.data = []
|
||||
for idx, label, *_rest in self.entries.list_entries(
|
||||
filter_kind=EntryType.TOTP.value
|
||||
):
|
||||
entry = self.entries.retrieve_entry(idx)
|
||||
code = self.entries.get_totp_code(idx)
|
||||
period = int(entry.get("period", 30)) if entry else 30
|
||||
remaining = TotpManager.time_remaining(period)
|
||||
self.table.data.append((label, code, remaining))
|
||||
|
||||
def cleanup(self, *args: object, **kwargs: object) -> None:
|
||||
self._running = False
|
||||
|
||||
|
||||
class RelayManagerDialog(toga.Window):
|
||||
"""Dialog for managing relay URLs."""
|
||||
|
||||
def __init__(self, main: MainWindow, nostr: NostrService) -> None:
|
||||
super().__init__("Relays")
|
||||
self.main = main
|
||||
self.nostr = nostr
|
||||
|
||||
self.table = toga.Table(headings=["Index", "URL"], style=Pack(flex=1))
|
||||
self.new_input = toga.TextInput(style=Pack(flex=1))
|
||||
add_btn = toga.Button("Add", on_press=self.add_relay)
|
||||
remove_btn = toga.Button("Remove", on_press=self.remove_relay)
|
||||
self.message = toga.Label("", style=Pack(color="red"))
|
||||
|
||||
box = toga.Box(style=Pack(direction=COLUMN, padding=20))
|
||||
box.add(self.table)
|
||||
form = toga.Box(style=Pack(direction=ROW, padding_top=5))
|
||||
form.add(self.new_input)
|
||||
form.add(add_btn)
|
||||
form.add(remove_btn)
|
||||
box.add(form)
|
||||
box.add(self.message)
|
||||
self.content = box
|
||||
|
||||
self.refresh()
|
||||
|
||||
def refresh(self) -> None:
|
||||
self.table.data = []
|
||||
for i, url in enumerate(self.nostr.list_relays(), start=1):
|
||||
self.table.data.append((i, url))
|
||||
|
||||
def add_relay(self, widget: toga.Widget) -> None:
|
||||
url = self.new_input.value or ""
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
self.nostr.add_relay(url)
|
||||
except Exception as exc: # pragma: no cover - pass errors
|
||||
self.message.text = str(exc)
|
||||
return
|
||||
self.new_input.value = ""
|
||||
self.refresh()
|
||||
|
||||
def remove_relay(self, widget: toga.Widget, *, index: int | None = None) -> None:
|
||||
if index is None:
|
||||
if self.table.selection is None:
|
||||
return
|
||||
index = int(self.table.selection[0])
|
||||
try:
|
||||
self.nostr.remove_relay(index)
|
||||
except Exception as exc: # pragma: no cover - pass errors
|
||||
self.message.text = str(exc)
|
||||
return
|
||||
self.refresh()
|
||||
|
||||
|
||||
def build() -> SeedPassApp:
|
||||
"""Return a configured :class:`SeedPassApp` instance."""
|
||||
return SeedPassApp(formal_name="SeedPass", app_id="org.seedpass.gui")
|
||||
|
||||
|
||||
class SeedPassApp(toga.App):
|
||||
def startup(self) -> None: # pragma: no cover - GUI bootstrap
|
||||
pm = PasswordManager()
|
||||
self.vault_service = VaultService(pm)
|
||||
self.entry_service = EntryService(pm)
|
||||
self.nostr_service = NostrService(pm)
|
||||
self.lock_window = LockScreenWindow(
|
||||
self,
|
||||
self.vault_service,
|
||||
self.entry_service,
|
||||
)
|
||||
self.main_window = None
|
||||
self.lock_window.show()
|
||||
|
||||
|
||||
def main() -> None: # pragma: no cover - GUI bootstrap
|
||||
"""Run the BeeWare application."""
|
||||
build().main_loop()
|
@@ -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,
|
||||
|
@@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.manager import PasswordManager, EncryptionMode
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.manager import PasswordManager, EncryptionMode
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
class FakePasswordGenerator:
|
||||
|
@@ -4,9 +4,9 @@ from tempfile import TemporaryDirectory
|
||||
|
||||
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
def test_entry_manager_additional_backup(monkeypatch):
|
||||
|
@@ -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"
|
||||
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
||||
|
@@ -8,10 +8,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.manager import PasswordManager, EncryptionMode
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.manager import PasswordManager, EncryptionMode
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
class FakePasswordGenerator:
|
||||
|
@@ -6,9 +6,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:
|
||||
|
@@ -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:
|
||||
|
@@ -6,7 +6,7 @@ import sys
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.manager import PasswordManager
|
||||
from seedpass.core.manager import PasswordManager
|
||||
from constants import MIN_HEALTHY_RELAYS
|
||||
|
||||
|
||||
|
@@ -4,8 +4,8 @@ from pathlib import Path
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.manager import PasswordManager
|
||||
import password_manager.manager as manager_module
|
||||
from seedpass.core.manager import PasswordManager
|
||||
import seedpass.core.manager as manager_module
|
||||
|
||||
|
||||
def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path):
|
||||
@@ -22,17 +22,12 @@ def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path):
|
||||
pm.config_manager = SimpleNamespace(get_quick_unlock=lambda: False)
|
||||
|
||||
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "1")
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.prompt_existing_password", lambda *_a, **_k: "pw"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
PasswordManager, "setup_encryption_manager", lambda *a, **k: True
|
||||
)
|
||||
monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda *a, **k: None)
|
||||
monkeypatch.setattr(PasswordManager, "initialize_managers", lambda *a, **k: None)
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.NostrClient", lambda *a, **kw: object()
|
||||
)
|
||||
monkeypatch.setattr("seedpass.core.manager.NostrClient", lambda *a, **kw: object())
|
||||
|
||||
calls = {"count": 0}
|
||||
|
||||
@@ -41,7 +36,7 @@ def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path):
|
||||
|
||||
monkeypatch.setattr(PasswordManager, "start_background_sync", fake_bg)
|
||||
|
||||
assert pm.handle_switch_fingerprint()
|
||||
assert pm.handle_switch_fingerprint(password="pw")
|
||||
assert calls["count"] == 1
|
||||
|
||||
|
||||
|
@@ -4,8 +4,8 @@ from tempfile import TemporaryDirectory
|
||||
|
||||
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
def test_backup_interval(monkeypatch):
|
||||
|
@@ -8,8 +8,8 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
def test_backup_restore_workflow(monkeypatch):
|
||||
|
@@ -5,7 +5,7 @@ import pytest
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from local_bip85.bip85 import BIP85, Bip85Error
|
||||
from password_manager.password_generation import (
|
||||
from seedpass.core.password_generation import (
|
||||
derive_ssh_key,
|
||||
derive_seed_phrase,
|
||||
)
|
||||
|
72
src/tests/test_cli_core_services.py
Normal file
72
src/tests/test_cli_core_services.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import typer
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from seedpass import cli
|
||||
from seedpass.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_cli_vault_unlock(monkeypatch):
|
||||
called = {}
|
||||
|
||||
def unlock_vault(pw):
|
||||
called["pw"] = pw
|
||||
return 0.5
|
||||
|
||||
pm = SimpleNamespace(unlock_vault=unlock_vault, select_fingerprint=lambda fp: None)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
monkeypatch.setattr(cli.typer, "prompt", lambda *a, **k: "pw")
|
||||
result = runner.invoke(app, ["vault", "unlock"])
|
||||
assert result.exit_code == 0
|
||||
assert "Unlocked in" in result.stdout
|
||||
assert called["pw"] == "pw"
|
||||
|
||||
|
||||
def test_cli_entry_add_search_sync(monkeypatch):
|
||||
calls = {}
|
||||
|
||||
def add_entry(label, length, username=None, url=None):
|
||||
calls["add"] = (label, length, username, url)
|
||||
return 1
|
||||
|
||||
def search_entries(q, kinds=None):
|
||||
calls["search"] = (q, kinds)
|
||||
return [(1, "Label", None, None, False)]
|
||||
|
||||
def start_background_vault_sync():
|
||||
calls["sync"] = True
|
||||
return {"manifest_id": "m", "chunk_ids": [], "delta_ids": []}
|
||||
|
||||
pm = SimpleNamespace(
|
||||
entry_manager=SimpleNamespace(
|
||||
add_entry=add_entry, search_entries=search_entries
|
||||
),
|
||||
start_background_vault_sync=start_background_vault_sync,
|
||||
sync_vault=lambda: {"manifest_id": "m", "chunk_ids": [], "delta_ids": []},
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
|
||||
# entry add
|
||||
result = runner.invoke(app, ["entry", "add", "Label"])
|
||||
assert result.exit_code == 0
|
||||
assert "1" in result.stdout
|
||||
assert calls["add"] == ("Label", 12, None, None)
|
||||
assert calls.get("sync") is True
|
||||
|
||||
# entry search
|
||||
result = runner.invoke(
|
||||
app, ["entry", "search", "lab", "--kind", "password", "--kind", "totp"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Label" in result.stdout
|
||||
assert calls["search"] == ("lab", ["password", "totp"])
|
||||
|
||||
# nostr sync
|
||||
result = runner.invoke(app, ["nostr", "sync"])
|
||||
assert result.exit_code == 0
|
||||
assert "manifest" in result.stdout.lower()
|
||||
assert calls.get("sync") is True
|
@@ -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)
|
||||
|
||||
|
||||
|
@@ -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)
|
||||
|
@@ -6,9 +6,9 @@ import sys
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import main
|
||||
from password_manager.portable_backup import export_backup, import_backup
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from password_manager.backup import BackupManager
|
||||
from seedpass.core.portable_backup import export_backup, import_backup
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from helpers import create_vault, TEST_SEED
|
||||
|
||||
|
||||
|
93
src/tests/test_cli_integration.py
Normal file
93
src/tests/test_cli_integration.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import importlib
|
||||
import shutil
|
||||
from contextlib import redirect_stdout
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from tests.helpers import TEST_PASSWORD, TEST_SEED
|
||||
|
||||
import colorama
|
||||
import constants
|
||||
import seedpass.cli as cli_module
|
||||
import seedpass.core.manager as manager_module
|
||||
import utils.password_prompt as pwd_prompt
|
||||
|
||||
|
||||
def test_cli_integration(monkeypatch, tmp_path):
|
||||
"""Exercise basic CLI flows without interactive prompts."""
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setattr(colorama, "init", lambda *a, **k: None)
|
||||
monkeypatch.setattr(pwd_prompt, "colorama_init", lambda: None)
|
||||
importlib.reload(constants)
|
||||
importlib.reload(manager_module)
|
||||
importlib.reload(pwd_prompt)
|
||||
importlib.reload(cli_module)
|
||||
|
||||
# Bypass user prompts and background threads
|
||||
monkeypatch.setattr(manager_module, "prompt_seed_words", lambda *a, **k: TEST_SEED)
|
||||
monkeypatch.setattr(manager_module, "prompt_new_password", lambda: TEST_PASSWORD)
|
||||
monkeypatch.setattr(manager_module, "prompt_for_password", lambda: TEST_PASSWORD)
|
||||
monkeypatch.setattr(
|
||||
manager_module, "prompt_existing_password", lambda *a, **k: TEST_PASSWORD
|
||||
)
|
||||
monkeypatch.setattr(manager_module, "confirm_action", lambda *a, **k: True)
|
||||
monkeypatch.setattr(manager_module, "masked_input", lambda *_: TEST_SEED)
|
||||
monkeypatch.setattr(
|
||||
manager_module.PasswordManager, "start_background_sync", lambda *a, **k: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager_module.PasswordManager,
|
||||
"start_background_vault_sync",
|
||||
lambda *a, **k: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager_module.PasswordManager,
|
||||
"start_background_relay_check",
|
||||
lambda *a, **k: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager_module, "NostrClient", lambda *a, **k: SimpleNamespace()
|
||||
)
|
||||
|
||||
def auto_add(self):
|
||||
return self.setup_existing_seed(
|
||||
method="paste", seed=TEST_SEED, password=TEST_PASSWORD
|
||||
)
|
||||
|
||||
monkeypatch.setattr(manager_module.PasswordManager, "add_new_fingerprint", auto_add)
|
||||
monkeypatch.setattr("builtins.input", lambda *a, **k: "1")
|
||||
|
||||
buf = StringIO()
|
||||
with redirect_stdout(buf):
|
||||
try:
|
||||
cli_module.app(["fingerprint", "add"])
|
||||
except SystemExit as e:
|
||||
assert e.code == 0
|
||||
buf.truncate(0)
|
||||
buf.seek(0)
|
||||
|
||||
with redirect_stdout(buf):
|
||||
try:
|
||||
cli_module.app(["entry", "add", "Example", "--length", "8"])
|
||||
except SystemExit as e:
|
||||
assert e.code == 0
|
||||
buf.truncate(0)
|
||||
buf.seek(0)
|
||||
|
||||
with redirect_stdout(buf):
|
||||
try:
|
||||
cli_module.app(["entry", "get", "Example"])
|
||||
except SystemExit as e:
|
||||
assert e.code == 0
|
||||
lines = [line for line in buf.getvalue().splitlines() if line.strip()]
|
||||
password = lines[-1]
|
||||
assert len(password.strip()) >= 8
|
||||
|
||||
fm = manager_module.FingerprintManager(constants.APP_DIR)
|
||||
fp = fm.current_fingerprint
|
||||
assert fp is not None
|
||||
index_file = constants.APP_DIR / fp / "seedpass_entries_db.json.enc"
|
||||
assert index_file.exists()
|
||||
|
||||
shutil.rmtree(constants.APP_DIR, ignore_errors=True)
|
53
src/tests/test_cli_relays.py
Normal file
53
src/tests/test_cli_relays.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from types import SimpleNamespace
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from seedpass.cli import app
|
||||
from seedpass import cli
|
||||
|
||||
|
||||
class DummyService:
|
||||
def __init__(self, relays):
|
||||
self.relays = relays
|
||||
|
||||
def get_pubkey(self):
|
||||
return "npub"
|
||||
|
||||
def list_relays(self):
|
||||
return self.relays
|
||||
|
||||
def add_relay(self, url):
|
||||
if url in self.relays:
|
||||
raise ValueError("exists")
|
||||
self.relays.append(url)
|
||||
|
||||
def remove_relay(self, idx):
|
||||
if not 1 <= idx <= len(self.relays):
|
||||
raise ValueError("bad")
|
||||
if len(self.relays) == 1:
|
||||
raise ValueError("min")
|
||||
self.relays.pop(idx - 1)
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_cli_relay_crud(monkeypatch):
|
||||
relays = ["wss://a"]
|
||||
|
||||
def pm_factory(*a, **k):
|
||||
return SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr(cli, "PasswordManager", pm_factory)
|
||||
monkeypatch.setattr(cli, "NostrService", lambda pm: DummyService(relays))
|
||||
|
||||
result = runner.invoke(app, ["nostr", "list-relays"])
|
||||
assert "1: wss://a" in result.stdout
|
||||
|
||||
result = runner.invoke(app, ["nostr", "add-relay", "wss://b"])
|
||||
assert result.exit_code == 0
|
||||
assert "Added" in result.stdout
|
||||
assert relays == ["wss://a", "wss://b"]
|
||||
|
||||
result = runner.invoke(app, ["nostr", "remove-relay", "1"])
|
||||
assert result.exit_code == 0
|
||||
assert relays == ["wss://b"]
|
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import main
|
||||
from password_manager.entry_types import EntryType
|
||||
from seedpass.core.entry_types import EntryType
|
||||
|
||||
|
||||
def make_pm(search_results, entry=None, totp_code="123456"):
|
||||
|
@@ -6,10 +6,10 @@ from helpers import TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from password_manager.vault import Vault
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
from seedpass.core.vault import Vault
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from utils.key_derivation import derive_index_key, derive_key_from_password
|
||||
|
||||
|
||||
|
@@ -7,8 +7,8 @@ import sys
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from password_manager.vault import Vault
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.vault import Vault
|
||||
from nostr.client import DEFAULT_RELAYS
|
||||
from constants import INACTIVITY_TIMEOUT
|
||||
|
||||
|
71
src/tests/test_core_services.py
Normal file
71
src/tests/test_core_services.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
from seedpass.core.api import VaultService, EntryService, SyncService, UnlockRequest
|
||||
|
||||
|
||||
def test_vault_service_unlock():
|
||||
called = {}
|
||||
|
||||
def unlock_vault(pw: str) -> float:
|
||||
called["pw"] = pw
|
||||
return 0.42
|
||||
|
||||
pm = SimpleNamespace(unlock_vault=unlock_vault)
|
||||
service = VaultService(pm)
|
||||
resp = service.unlock(UnlockRequest(password="secret"))
|
||||
assert called["pw"] == "secret"
|
||||
assert resp.duration == 0.42
|
||||
|
||||
|
||||
def test_entry_service_add_entry_and_search():
|
||||
called = {}
|
||||
|
||||
def add_entry(label, length, username=None, url=None):
|
||||
called["add"] = (label, length, username, url)
|
||||
return 5
|
||||
|
||||
def search_entries(q, kinds=None):
|
||||
called["search"] = (q, kinds)
|
||||
return [(5, "Example", username, url, False)]
|
||||
|
||||
def start_background_vault_sync():
|
||||
called["sync"] = True
|
||||
|
||||
username = "user"
|
||||
url = "https://ex.com"
|
||||
pm = SimpleNamespace(
|
||||
entry_manager=SimpleNamespace(
|
||||
add_entry=add_entry, search_entries=search_entries
|
||||
),
|
||||
start_background_vault_sync=start_background_vault_sync,
|
||||
)
|
||||
service = EntryService(pm)
|
||||
idx = service.add_entry("Example", 12, username, url)
|
||||
assert idx == 5
|
||||
assert called["add"] == ("Example", 12, username, url)
|
||||
assert called.get("sync") is True
|
||||
|
||||
results = service.search_entries("ex", kinds=["password"])
|
||||
assert results == [(5, "Example", username, url, False)]
|
||||
assert called["search"] == ("ex", ["password"])
|
||||
|
||||
|
||||
def test_sync_service_sync():
|
||||
called = {}
|
||||
|
||||
def sync_vault():
|
||||
called["sync"] = True
|
||||
return {
|
||||
"manifest_id": "m1",
|
||||
"chunk_ids": ["c1"],
|
||||
"delta_ids": ["d1"],
|
||||
}
|
||||
|
||||
pm = SimpleNamespace(sync_vault=sync_vault)
|
||||
service = SyncService(pm)
|
||||
resp = service.sync()
|
||||
assert called["sync"] is True
|
||||
assert resp.manifest_id == "m1"
|
||||
assert resp.chunk_ids == ["c1"]
|
||||
assert resp.delta_ids == ["d1"]
|
@@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.manager import PasswordManager, EncryptionMode
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.manager import PasswordManager, EncryptionMode
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
def test_retrieve_entry_shows_custom_fields(monkeypatch, capsys):
|
||||
|
@@ -6,7 +6,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
|
||||
from password_manager.manager import PasswordManager
|
||||
from seedpass.core.manager import PasswordManager
|
||||
from utils.key_derivation import EncryptionMode
|
||||
|
||||
|
||||
|
49
src/tests/test_delta_merge.py
Normal file
49
src/tests/test_delta_merge.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import pytest
|
||||
|
||||
from helpers import create_vault
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
def _setup_mgr(path: Path):
|
||||
vault, _ = create_vault(path)
|
||||
cfg = ConfigManager(vault, path)
|
||||
backup = BackupManager(path, cfg)
|
||||
return vault, EntryManager(vault, backup)
|
||||
|
||||
|
||||
def test_merge_modified_ts():
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
base = Path(tmpdir)
|
||||
va, ema = _setup_mgr(base / "A")
|
||||
vb, emb = _setup_mgr(base / "B")
|
||||
|
||||
idx0 = ema.add_entry("a", 8)
|
||||
idx1 = ema.add_entry("b", 8)
|
||||
|
||||
# B starts from A's snapshot
|
||||
enc = va.get_encrypted_index() or b""
|
||||
vb.decrypt_and_save_index_from_nostr(enc, merge=False)
|
||||
emb.clear_cache()
|
||||
assert emb.retrieve_entry(idx0)["username"] == ""
|
||||
|
||||
ema.modify_entry(idx0, username="ua")
|
||||
delta_a = va.get_encrypted_index() or b""
|
||||
vb.decrypt_and_save_index_from_nostr(delta_a, merge=True)
|
||||
emb.clear_cache()
|
||||
assert emb.retrieve_entry(idx0)["username"] == "ua"
|
||||
|
||||
emb.modify_entry(idx1, username="ub")
|
||||
delta_b = vb.get_encrypted_index() or b""
|
||||
va.decrypt_and_save_index_from_nostr(delta_b, merge=True)
|
||||
ema.clear_cache()
|
||||
assert ema.retrieve_entry(idx1)["username"] == "ub"
|
||||
|
||||
assert ema.retrieve_entry(idx0)["username"] == "ua"
|
||||
assert ema.retrieve_entry(idx1)["username"] == "ub"
|
||||
assert emb.retrieve_entry(idx0)["username"] == "ua"
|
||||
assert emb.retrieve_entry(idx1)["username"] == "ub"
|
@@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.manager import PasswordManager, EncryptionMode
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.manager import PasswordManager, EncryptionMode
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
class FakePasswordGenerator:
|
||||
|
@@ -8,7 +8,7 @@ import base64
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
from utils.checksum import verify_and_update_checksum
|
||||
|
||||
|
||||
|
@@ -8,7 +8,7 @@ import base64
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
|
||||
|
||||
def test_json_save_and_load_round_trip():
|
||||
|
@@ -5,10 +5,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.vault import Vault
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.vault import Vault
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
def test_list_entries_empty():
|
||||
|
@@ -8,10 +8,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.vault import Vault
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.vault import Vault
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
def test_add_and_retrieve_entry():
|
||||
@@ -44,7 +44,9 @@ def test_add_and_retrieve_entry():
|
||||
|
||||
data = enc_mgr.load_json_data(entry_mgr.index_file)
|
||||
assert str(index) in data.get("entries", {})
|
||||
assert data["entries"][str(index)] == entry
|
||||
stored = data["entries"][str(index)]
|
||||
stored.pop("modified_ts", None)
|
||||
assert stored == entry
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
@@ -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():
|
||||
|
@@ -8,11 +8,11 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.manager import PasswordManager, EncryptionMode
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from password_manager.totp import TotpManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.manager import PasswordManager, EncryptionMode
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.totp import TotpManager
|
||||
|
||||
|
||||
class FakeNostrClient:
|
||||
@@ -42,9 +42,7 @@ def test_handle_export_totp_codes(monkeypatch, tmp_path):
|
||||
|
||||
export_path = tmp_path / "out.json"
|
||||
monkeypatch.setattr("builtins.input", lambda *a, **k: str(export_path))
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.confirm_action", lambda *_a, **_k: False
|
||||
)
|
||||
monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: False)
|
||||
|
||||
pm.handle_export_totp_codes()
|
||||
|
||||
|
@@ -9,7 +9,7 @@ import base64
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from utils.fingerprint import generate_fingerprint
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
|
||||
|
||||
def test_generate_fingerprint_deterministic():
|
||||
|
@@ -4,10 +4,10 @@ from tempfile import TemporaryDirectory
|
||||
|
||||
from helpers import create_vault, dummy_nostr_client
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from password_manager.manager import PasswordManager, EncryptionMode
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.manager import PasswordManager, EncryptionMode
|
||||
|
||||
|
||||
def _init_pm(dir_path: Path, client) -> PasswordManager:
|
||||
@@ -44,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()
|
||||
|
@@ -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()
|
||||
|
@@ -9,7 +9,7 @@ from utils.key_derivation import (
|
||||
derive_key_from_password_argon2,
|
||||
derive_index_key,
|
||||
)
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
|
||||
|
||||
cfg_values = st.one_of(
|
||||
|
115
src/tests/test_gui_features.py
Normal file
115
src/tests/test_gui_features.py
Normal file
@@ -0,0 +1,115 @@
|
||||
import os
|
||||
import toga
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.desktop
|
||||
|
||||
from seedpass.core.pubsub import bus
|
||||
from seedpass_gui.app import MainWindow, RelayManagerDialog
|
||||
import seedpass_gui.app
|
||||
|
||||
|
||||
class DummyNostr:
|
||||
def __init__(self):
|
||||
self.relays = ["wss://a"]
|
||||
|
||||
def list_relays(self):
|
||||
return list(self.relays)
|
||||
|
||||
def add_relay(self, url):
|
||||
self.relays.append(url)
|
||||
|
||||
def remove_relay(self, idx):
|
||||
self.relays.pop(idx - 1)
|
||||
|
||||
|
||||
class DummyEntries:
|
||||
def __init__(self):
|
||||
self.data = [(1, "Example", None, None, False)]
|
||||
self.code = "111111"
|
||||
|
||||
def list_entries(self, sort_by="index", filter_kind=None, include_archived=False):
|
||||
if filter_kind:
|
||||
return [(idx, label, None, None, False) for idx, label, *_ in self.data]
|
||||
return self.data
|
||||
|
||||
def search_entries(self, q):
|
||||
return []
|
||||
|
||||
def retrieve_entry(self, idx):
|
||||
return {"period": 30}
|
||||
|
||||
def get_totp_code(self, idx):
|
||||
return self.code
|
||||
|
||||
|
||||
class DummyController:
|
||||
def __init__(self):
|
||||
self.lock_window = types.SimpleNamespace(show=lambda: None)
|
||||
self.main_window = None
|
||||
self.vault_service = None
|
||||
self.entry_service = None
|
||||
self.nostr_service = None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def set_backend():
|
||||
os.environ["TOGA_BACKEND"] = "toga_dummy"
|
||||
import asyncio
|
||||
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
|
||||
|
||||
def test_relay_manager_add_remove():
|
||||
toga.App("T", "o")
|
||||
ctrl = DummyController()
|
||||
nostr = DummyNostr()
|
||||
win = MainWindow(ctrl, None, DummyEntries(), nostr)
|
||||
dlg = RelayManagerDialog(win, nostr)
|
||||
dlg.new_input.value = "wss://b"
|
||||
dlg.add_relay(None)
|
||||
assert nostr.relays == ["wss://a", "wss://b"]
|
||||
dlg.remove_relay(None, index=1)
|
||||
assert nostr.relays == ["wss://b"]
|
||||
|
||||
|
||||
def test_status_bar_updates_and_lock():
|
||||
toga.App("T2", "o2")
|
||||
ctrl = DummyController()
|
||||
nostr = DummyNostr()
|
||||
ctrl.lock_window = types.SimpleNamespace(show=lambda: setattr(ctrl, "locked", True))
|
||||
win = MainWindow(ctrl, None, DummyEntries(), nostr)
|
||||
ctrl.main_window = win
|
||||
bus.publish("sync_started")
|
||||
assert win.status.text == "Syncing..."
|
||||
bus.publish("sync_finished")
|
||||
assert "Last sync:" in win.status.text
|
||||
bus.publish("vault_locked")
|
||||
assert getattr(ctrl, "locked", False)
|
||||
assert ctrl.main_window is None
|
||||
|
||||
|
||||
def test_totp_viewer_refresh_on_sync(monkeypatch):
|
||||
toga.App("T3", "o3")
|
||||
ctrl = DummyController()
|
||||
nostr = DummyNostr()
|
||||
entries = DummyEntries()
|
||||
win = MainWindow(ctrl, None, entries, nostr)
|
||||
ctrl.main_window = win
|
||||
ctrl.loop = types.SimpleNamespace(create_task=lambda c: None)
|
||||
|
||||
# prevent background loop from running
|
||||
monkeypatch.setattr(
|
||||
seedpass_gui.app.TotpViewerWindow, "_update_loop", lambda self: None
|
||||
)
|
||||
|
||||
viewer = seedpass_gui.app.TotpViewerWindow(ctrl, entries)
|
||||
bus.subscribe("sync_finished", viewer.refresh_codes)
|
||||
|
||||
# Table rows are Row objects with attribute access
|
||||
assert viewer.table.data[0].code == "111111"
|
||||
entries.code = "222222"
|
||||
bus.publish("sync_finished")
|
||||
assert viewer.table.data[0].code == "222222"
|
164
src/tests/test_gui_headless.py
Normal file
164
src/tests/test_gui_headless.py
Normal file
@@ -0,0 +1,164 @@
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from toga.sources import ListSource
|
||||
|
||||
import toga
|
||||
import pytest
|
||||
|
||||
from seedpass.core.entry_types import EntryType
|
||||
|
||||
from seedpass_gui.app import LockScreenWindow, MainWindow, EntryDialog
|
||||
|
||||
|
||||
class FakeVault:
|
||||
def __init__(self):
|
||||
self.called = False
|
||||
|
||||
def unlock(self, request):
|
||||
self.called = True
|
||||
|
||||
|
||||
class FakeEntries:
|
||||
def __init__(self):
|
||||
self.added = []
|
||||
self.modified = []
|
||||
|
||||
def list_entries(self):
|
||||
return []
|
||||
|
||||
def search_entries(self, query, kinds=None):
|
||||
return []
|
||||
|
||||
def add_entry(self, label, length, username=None, url=None):
|
||||
self.added.append(("password", label, length, username, url))
|
||||
return 1
|
||||
|
||||
def add_totp(self, label):
|
||||
self.added.append(("totp", label))
|
||||
return 1
|
||||
|
||||
def add_ssh_key(self, label):
|
||||
self.added.append(("ssh", label))
|
||||
return 1
|
||||
|
||||
def add_seed(self, label):
|
||||
self.added.append(("seed", label))
|
||||
return 1
|
||||
|
||||
def add_pgp_key(self, label):
|
||||
self.added.append(("pgp", label))
|
||||
return 1
|
||||
|
||||
def add_nostr_key(self, label):
|
||||
self.added.append(("nostr", label))
|
||||
return 1
|
||||
|
||||
def add_key_value(self, label, value):
|
||||
self.added.append(("key_value", label, value))
|
||||
return 1
|
||||
|
||||
def add_managed_account(self, label):
|
||||
self.added.append(("managed_account", label))
|
||||
return 1
|
||||
|
||||
def modify_entry(self, entry_id, username=None, url=None, label=None, value=None):
|
||||
self.modified.append((entry_id, username, url, label, value))
|
||||
|
||||
|
||||
def setup_module(module):
|
||||
os.environ["TOGA_BACKEND"] = "toga_dummy"
|
||||
import asyncio
|
||||
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
|
||||
|
||||
class FakeNostr:
|
||||
def list_relays(self):
|
||||
return []
|
||||
|
||||
def add_relay(self, url):
|
||||
pass
|
||||
|
||||
def remove_relay(self, idx):
|
||||
pass
|
||||
|
||||
|
||||
def test_unlock_creates_main_window():
|
||||
app = toga.App("Test", "org.example")
|
||||
controller = SimpleNamespace(main_window=None, nostr_service=FakeNostr())
|
||||
vault = FakeVault()
|
||||
entries = FakeEntries()
|
||||
win = LockScreenWindow(controller, vault, entries)
|
||||
win.password_input.value = "pw"
|
||||
win.handle_unlock(None)
|
||||
|
||||
assert vault.called
|
||||
assert isinstance(controller.main_window, MainWindow)
|
||||
controller.main_window.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind,expect",
|
||||
[
|
||||
(EntryType.PASSWORD.value, ("password", "L", 12, "u", "x")),
|
||||
(EntryType.TOTP.value, ("totp", "L")),
|
||||
(EntryType.SSH.value, ("ssh", "L")),
|
||||
(EntryType.SEED.value, ("seed", "L")),
|
||||
(EntryType.PGP.value, ("pgp", "L")),
|
||||
(EntryType.NOSTR.value, ("nostr", "L")),
|
||||
(EntryType.KEY_VALUE.value, ("key_value", "L", "val")),
|
||||
(EntryType.MANAGED_ACCOUNT.value, ("managed_account", "L")),
|
||||
],
|
||||
)
|
||||
def test_entrydialog_add_calls_service(kind, expect):
|
||||
toga.App("Test2", "org.example2")
|
||||
entries = FakeEntries()
|
||||
entries.retrieve_entry = lambda _id: {"kind": kind}
|
||||
source = ListSource(["id", "label", "kind", "info1", "info2"])
|
||||
main = SimpleNamespace(entries=entries, entry_source=source)
|
||||
|
||||
dlg = EntryDialog(main, None)
|
||||
dlg.label_input.value = "L"
|
||||
dlg.kind_input.value = kind
|
||||
dlg.username_input.value = "u"
|
||||
dlg.url_input.value = "x"
|
||||
dlg.length_input.value = 12
|
||||
dlg.value_input.value = "val"
|
||||
dlg.save(None)
|
||||
|
||||
assert entries.added[-1] == expect
|
||||
assert len(main.entry_source) == 1
|
||||
row = main.entry_source[0]
|
||||
assert row.label == "L"
|
||||
assert row.kind == kind
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind,expected",
|
||||
[
|
||||
(EntryType.PASSWORD.value, (1, "newu", "newx", "New", None)),
|
||||
(EntryType.KEY_VALUE.value, (1, None, None, "New", "val2")),
|
||||
(EntryType.TOTP.value, (1, None, None, "New", None)),
|
||||
],
|
||||
)
|
||||
def test_entrydialog_edit_calls_service(kind, expected):
|
||||
toga.App("Edit", "org.edit")
|
||||
entries = FakeEntries()
|
||||
|
||||
def retrieve(_id):
|
||||
return {"kind": kind}
|
||||
|
||||
entries.retrieve_entry = retrieve
|
||||
source = ListSource(["id", "label", "kind", "info1", "info2"])
|
||||
source.append({"id": 1, "label": "Old", "kind": kind, "info1": "", "info2": ""})
|
||||
main = SimpleNamespace(entries=entries, entry_source=source)
|
||||
dlg = EntryDialog(main, 1)
|
||||
dlg.label_input.value = "New"
|
||||
dlg.kind_input.value = kind
|
||||
dlg.username_input.value = "newu"
|
||||
dlg.url_input.value = "newx"
|
||||
dlg.value_input.value = "val2"
|
||||
dlg.save(None)
|
||||
|
||||
assert entries.modified[-1] == expected
|
||||
assert source[0].label == "New"
|
77
src/tests/test_gui_sync.py
Normal file
77
src/tests/test_gui_sync.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import os
|
||||
import types
|
||||
import asyncio
|
||||
import toga
|
||||
import pytest
|
||||
|
||||
from seedpass.core.pubsub import bus
|
||||
from seedpass_gui.app import MainWindow
|
||||
|
||||
|
||||
class DummyEntries:
|
||||
def list_entries(self, sort_by="index", filter_kind=None, include_archived=False):
|
||||
return []
|
||||
|
||||
def search_entries(self, q):
|
||||
return []
|
||||
|
||||
|
||||
class DummyNostr:
|
||||
def __init__(self):
|
||||
self.called = False
|
||||
|
||||
def start_background_vault_sync(self):
|
||||
self.called = True
|
||||
|
||||
def list_relays(self):
|
||||
return []
|
||||
|
||||
|
||||
class DummyController:
|
||||
def __init__(self, loop):
|
||||
self.loop = loop
|
||||
self.lock_window = types.SimpleNamespace(show=lambda: None)
|
||||
self.main_window = None
|
||||
self.vault_service = None
|
||||
self.entry_service = None
|
||||
self.nostr_service = None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def set_backend():
|
||||
os.environ["TOGA_BACKEND"] = "toga_dummy"
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
|
||||
|
||||
def test_start_vault_sync_schedules_task():
|
||||
toga.App("T", "o")
|
||||
|
||||
tasks = []
|
||||
|
||||
def create_task(coro):
|
||||
tasks.append(coro)
|
||||
|
||||
loop = types.SimpleNamespace(create_task=create_task)
|
||||
ctrl = DummyController(loop)
|
||||
nostr = DummyNostr()
|
||||
win = MainWindow(ctrl, None, DummyEntries(), nostr)
|
||||
|
||||
win.start_vault_sync()
|
||||
assert tasks
|
||||
asyncio.get_event_loop().run_until_complete(tasks[0])
|
||||
assert nostr.called
|
||||
win.cleanup()
|
||||
|
||||
|
||||
def test_status_updates_on_bus_events():
|
||||
toga.App("T2", "o2")
|
||||
loop = types.SimpleNamespace(create_task=lambda c: None)
|
||||
ctrl = DummyController(loop)
|
||||
nostr = DummyNostr()
|
||||
win = MainWindow(ctrl, None, DummyEntries(), nostr)
|
||||
|
||||
bus.publish("sync_started")
|
||||
assert win.status.text == "Syncing..."
|
||||
bus.publish("sync_finished")
|
||||
assert "Last sync:" in win.status.text
|
||||
win.cleanup()
|
@@ -91,3 +91,32 @@ def test_input_timeout_triggers_lock(monkeypatch):
|
||||
|
||||
assert locked["locked"] == 1
|
||||
assert locked["unlocked"] == 1
|
||||
|
||||
|
||||
def test_update_activity_checks_timeout(monkeypatch):
|
||||
"""AuthGuard in update_activity locks the vault after inactivity."""
|
||||
import seedpass.core.manager as manager
|
||||
|
||||
now = {"val": 0.0}
|
||||
monkeypatch.setattr(manager.time, "time", lambda: now["val"])
|
||||
|
||||
pm = manager.PasswordManager.__new__(manager.PasswordManager)
|
||||
pm.inactivity_timeout = 0.5
|
||||
pm.last_activity = 0.0
|
||||
pm.locked = False
|
||||
called = {}
|
||||
|
||||
def lock():
|
||||
called["locked"] = True
|
||||
pm.locked = True
|
||||
|
||||
pm.lock_vault = lock
|
||||
pm.auth_guard = manager.AuthGuard(pm, time_fn=lambda: now["val"])
|
||||
|
||||
now["val"] = 0.4
|
||||
pm.update_activity()
|
||||
assert not called
|
||||
|
||||
now["val"] = 1.1
|
||||
pm.update_activity()
|
||||
assert called["locked"] is True
|
||||
|
@@ -3,9 +3,9 @@ from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
def test_index_caching():
|
||||
|
@@ -7,8 +7,8 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from password_manager.vault import Vault
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
from seedpass.core.vault import Vault
|
||||
from utils.key_derivation import derive_index_key, derive_key_from_password
|
||||
|
||||
SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
@@ -8,10 +8,10 @@ from utils.key_derivation import (
|
||||
derive_key_from_password_argon2,
|
||||
derive_index_key,
|
||||
)
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from password_manager.vault import Vault
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from password_manager.manager import PasswordManager, EncryptionMode
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
from seedpass.core.vault import Vault
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.manager import PasswordManager, EncryptionMode
|
||||
|
||||
TEST_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
TEST_PASSWORD = "pw"
|
||||
@@ -59,12 +59,12 @@ def test_setup_encryption_manager_kdf_modes(monkeypatch):
|
||||
cfg = _setup_profile(path, mode)
|
||||
pm = _make_pm(path, cfg)
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.prompt_existing_password",
|
||||
"seedpass.core.manager.prompt_existing_password",
|
||||
lambda *_: TEST_PASSWORD,
|
||||
)
|
||||
if mode == "argon2":
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.derive_key_from_password_argon2",
|
||||
"seedpass.core.manager.derive_key_from_password_argon2",
|
||||
lambda pw: derive_key_from_password_argon2(pw, **argon_kwargs),
|
||||
)
|
||||
monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None)
|
||||
|
@@ -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 == []
|
||||
|
@@ -3,9 +3,9 @@ from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import constants
|
||||
import password_manager.manager as manager_module
|
||||
import seedpass.core.manager as manager_module
|
||||
from utils.fingerprint_manager import FingerprintManager
|
||||
from password_manager.manager import EncryptionMode
|
||||
from seedpass.core.manager import EncryptionMode
|
||||
|
||||
from helpers import TEST_SEED
|
||||
|
||||
|
@@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from password_manager.entry_types import EntryType
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.entry_types import EntryType
|
||||
|
||||
|
||||
def setup_entry_manager(tmp_path: Path) -> EntryManager:
|
||||
@@ -19,29 +19,35 @@ def setup_entry_manager(tmp_path: Path) -> EntryManager:
|
||||
return EntryManager(vault, backup_mgr)
|
||||
|
||||
|
||||
def test_sort_by_website():
|
||||
def test_sort_by_label():
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
em = setup_entry_manager(tmp_path)
|
||||
idx0 = em.add_entry("b.com", 8, "user1")
|
||||
idx1 = em.add_entry("A.com", 8, "user2")
|
||||
result = em.list_entries(sort_by="website")
|
||||
result = em.list_entries(sort_by="label")
|
||||
assert result == [
|
||||
(idx1, "A.com", "user2", "", False),
|
||||
(idx0, "b.com", "user1", "", False),
|
||||
]
|
||||
|
||||
|
||||
def test_sort_by_username():
|
||||
def test_sort_by_updated():
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
em = setup_entry_manager(tmp_path)
|
||||
idx0 = em.add_entry("alpha.com", 8, "Charlie")
|
||||
idx1 = em.add_entry("beta.com", 8, "alice")
|
||||
result = em.list_entries(sort_by="username")
|
||||
idx0 = em.add_entry("alpha.com", 8, "u0")
|
||||
idx1 = em.add_entry("beta.com", 8, "u1")
|
||||
|
||||
data = em._load_index(force_reload=True)
|
||||
data["entries"][str(idx0)]["updated"] = 1
|
||||
data["entries"][str(idx1)]["updated"] = 2
|
||||
em._save_index(data)
|
||||
|
||||
result = em.list_entries(sort_by="updated")
|
||||
assert result == [
|
||||
(idx1, "beta.com", "alice", "", False),
|
||||
(idx0, "alpha.com", "Charlie", "", False),
|
||||
(idx1, "beta.com", "u1", "", False),
|
||||
(idx0, "alpha.com", "u0", "", False),
|
||||
]
|
||||
|
||||
|
||||
|
@@ -4,14 +4,14 @@ from tempfile import TemporaryDirectory
|
||||
|
||||
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
from utils.fingerprint import generate_fingerprint
|
||||
import password_manager.manager as manager_module
|
||||
from password_manager.manager import EncryptionMode
|
||||
import seedpass.core.manager as manager_module
|
||||
from seedpass.core.manager import EncryptionMode
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
def setup_entry_manager(tmp_path: Path) -> EntryManager:
|
||||
|
@@ -4,15 +4,15 @@ from tempfile import TemporaryDirectory
|
||||
|
||||
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
from utils.fingerprint import generate_fingerprint
|
||||
import password_manager.manager as manager_module
|
||||
from password_manager.manager import EncryptionMode
|
||||
import seedpass.core.manager as manager_module
|
||||
from seedpass.core.manager import EncryptionMode
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from password_manager.password_generation import derive_seed_phrase
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.password_generation import derive_seed_phrase
|
||||
from local_bip85.bip85 import BIP85
|
||||
from bip_utils import Bip39SeedGenerator
|
||||
|
||||
|
133
src/tests/test_manager_add_password.py
Normal file
133
src/tests/test_manager_add_password.py
Normal file
@@ -0,0 +1,133 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from helpers import create_vault, TEST_SEED, TEST_PASSWORD, dummy_nostr_client
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.manager import PasswordManager, EncryptionMode
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from constants import DEFAULT_PASSWORD_LENGTH
|
||||
|
||||
|
||||
class FakePasswordGenerator:
|
||||
def generate_password(self, length: int, index: int) -> str: # noqa: D401
|
||||
return f"pw-{index}-{length}"
|
||||
|
||||
|
||||
def test_handle_add_password(monkeypatch, dummy_nostr_client, capsys):
|
||||
client, _relay = dummy_nostr_client
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
|
||||
cfg_mgr = ConfigManager(vault, tmp_path)
|
||||
backup_mgr = BackupManager(tmp_path, cfg_mgr)
|
||||
entry_mgr = EntryManager(vault, backup_mgr)
|
||||
|
||||
pm = PasswordManager.__new__(PasswordManager)
|
||||
pm.encryption_mode = EncryptionMode.SEED_ONLY
|
||||
pm.encryption_manager = enc_mgr
|
||||
pm.vault = vault
|
||||
pm.entry_manager = entry_mgr
|
||||
pm.backup_manager = backup_mgr
|
||||
pm.password_generator = FakePasswordGenerator()
|
||||
pm.parent_seed = TEST_SEED
|
||||
pm.nostr_client = client
|
||||
pm.fingerprint_dir = tmp_path
|
||||
pm.secret_mode_enabled = False
|
||||
pm.is_dirty = False
|
||||
|
||||
inputs = iter(
|
||||
[
|
||||
"Example", # label
|
||||
"", # username
|
||||
"", # url
|
||||
"", # notes
|
||||
"", # tags
|
||||
"n", # add custom field
|
||||
"", # length (default)
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
|
||||
monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None)
|
||||
monkeypatch.setattr(pm, "start_background_vault_sync", lambda *a, **k: None)
|
||||
|
||||
pm.handle_add_password()
|
||||
out = capsys.readouterr().out
|
||||
|
||||
entries = entry_mgr.list_entries(verbose=False)
|
||||
assert entries == [(0, "Example", "", "", False)]
|
||||
|
||||
entry = entry_mgr.retrieve_entry(0)
|
||||
assert entry == {
|
||||
"label": "Example",
|
||||
"length": DEFAULT_PASSWORD_LENGTH,
|
||||
"username": "",
|
||||
"url": "",
|
||||
"archived": False,
|
||||
"type": "password",
|
||||
"kind": "password",
|
||||
"notes": "",
|
||||
"custom_fields": [],
|
||||
"tags": [],
|
||||
}
|
||||
|
||||
assert f"pw-0-{DEFAULT_PASSWORD_LENGTH}" in out
|
||||
|
||||
|
||||
def test_handle_add_password_secret_mode(monkeypatch, dummy_nostr_client, capsys):
|
||||
client, _relay = dummy_nostr_client
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
|
||||
cfg_mgr = ConfigManager(vault, tmp_path)
|
||||
backup_mgr = BackupManager(tmp_path, cfg_mgr)
|
||||
entry_mgr = EntryManager(vault, backup_mgr)
|
||||
|
||||
pm = PasswordManager.__new__(PasswordManager)
|
||||
pm.encryption_mode = EncryptionMode.SEED_ONLY
|
||||
pm.encryption_manager = enc_mgr
|
||||
pm.vault = vault
|
||||
pm.entry_manager = entry_mgr
|
||||
pm.backup_manager = backup_mgr
|
||||
pm.password_generator = FakePasswordGenerator()
|
||||
pm.parent_seed = TEST_SEED
|
||||
pm.nostr_client = client
|
||||
pm.fingerprint_dir = tmp_path
|
||||
pm.secret_mode_enabled = True
|
||||
pm.clipboard_clear_delay = 5
|
||||
pm.is_dirty = False
|
||||
|
||||
inputs = iter(
|
||||
[
|
||||
"Example", # label
|
||||
"", # username
|
||||
"", # url
|
||||
"", # notes
|
||||
"", # tags
|
||||
"n", # add custom field
|
||||
"", # length (default)
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
|
||||
monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None)
|
||||
monkeypatch.setattr(pm, "start_background_vault_sync", lambda *a, **k: None)
|
||||
|
||||
called = []
|
||||
monkeypatch.setattr(
|
||||
"seedpass.core.manager.copy_to_clipboard",
|
||||
lambda text, delay: called.append((text, delay)),
|
||||
)
|
||||
|
||||
pm.handle_add_password()
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert f"pw-0-{DEFAULT_PASSWORD_LENGTH}" not in out
|
||||
assert "copied to clipboard" in out
|
||||
assert called == [(f"pw-0-{DEFAULT_PASSWORD_LENGTH}", 5)]
|
@@ -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:
|
||||
|
@@ -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"
|
||||
|
@@ -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"
|
||||
|
@@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.manager import PasswordManager, EncryptionMode
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.manager import PasswordManager, EncryptionMode
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
class FakeNostrClient:
|
||||
@@ -50,7 +50,7 @@ def test_handle_display_totp_codes(monkeypatch, capsys):
|
||||
|
||||
# interrupt the loop after first iteration
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.timed_input",
|
||||
"seedpass.core.manager.timed_input",
|
||||
lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()),
|
||||
)
|
||||
|
||||
@@ -91,7 +91,7 @@ def test_display_totp_codes_excludes_archived(monkeypatch, capsys):
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.timed_input",
|
||||
"seedpass.core.manager.timed_input",
|
||||
lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()),
|
||||
)
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user