mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-09 15:58:48 +00:00
Compare commits
213 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
0d3d972abb | ||
![]() |
aeb146f862 | ||
![]() |
7f503f0787 | ||
![]() |
7a8c0aef86 | ||
![]() |
dcd095d1af | ||
![]() |
b4f792ad67 | ||
![]() |
cc8fba9f12 | ||
![]() |
20896812a4 | ||
![]() |
144751ef02 | ||
![]() |
05d4bd94b6 | ||
![]() |
9adb539b84 | ||
![]() |
64339b0c20 | ||
![]() |
4ccdd2b3df | ||
![]() |
61f1f5c02f | ||
![]() |
77757152d7 | ||
![]() |
265817b67d | ||
![]() |
e3bd669668 | ||
![]() |
f58ed03e6f | ||
![]() |
6d82ef1815 | ||
![]() |
a4ddd120c8 | ||
![]() |
47da67b37c | ||
![]() |
27977612de | ||
![]() |
ceb2eb28ad | ||
![]() |
f83234c568 | ||
![]() |
37fc2779ad | ||
![]() |
73f5a80b33 | ||
![]() |
78bf5a4c64 | ||
![]() |
db3a60fc5c | ||
![]() |
365a098d70 | ||
![]() |
7cdb67e82e | ||
![]() |
0a078bbcf4 | ||
![]() |
8a5d1717f8 | ||
![]() |
05eae9bddd | ||
![]() |
3c1f6991a4 | ||
![]() |
a7a2e70321 | ||
![]() |
3020b681b8 | ||
![]() |
d3bd3b9e0a | ||
![]() |
4e787e362e | ||
![]() |
6d6a284096 | ||
![]() |
b76112f11b | ||
![]() |
b74c0993ca | ||
![]() |
d8cb21e527 | ||
![]() |
b57e19b657 | ||
![]() |
3f169747d1 | ||
![]() |
b4d60782af | ||
![]() |
f664c4099c | ||
![]() |
7dba8e138d | ||
![]() |
cfc7e455d5 | ||
![]() |
dcb5c6e805 | ||
![]() |
d4d475438f | ||
![]() |
627d69cf30 | ||
![]() |
c15776e37e | ||
![]() |
2f7b41c5dd | ||
![]() |
bb1d692798 | ||
![]() |
b621cffae6 | ||
![]() |
3937ccfb75 | ||
![]() |
0dd5b1f301 | ||
![]() |
715952fba6 | ||
![]() |
acb1126287 | ||
![]() |
bcd8002e1d | ||
![]() |
3ad7c1ab94 | ||
![]() |
4a20817094 | ||
![]() |
d3f2cb8256 | ||
![]() |
a3d45a117c | ||
![]() |
2d39d7a5bd | ||
![]() |
0396e99e0f | ||
![]() |
f677f4b445 | ||
![]() |
5b0051f76f | ||
![]() |
0490583fee | ||
![]() |
0edf4e5c83 | ||
![]() |
01fa1f4997 | ||
![]() |
ea2451f4a0 | ||
![]() |
5eb5e29094 | ||
![]() |
747e2be5a9 | ||
![]() |
db90d9caf0 | ||
![]() |
cb1e18c8ba | ||
![]() |
cf3803c422 | ||
![]() |
24a606fb70 | ||
![]() |
5850b68c9a | ||
![]() |
529eb5a0a6 | ||
![]() |
e0318c7850 | ||
![]() |
64a84c59d7 | ||
![]() |
aaf7b79e59 | ||
![]() |
8e7224dfd2 | ||
![]() |
61ffb073b5 | ||
![]() |
3e83616a4f | ||
![]() |
85ce777333 | ||
![]() |
b70585c55e | ||
![]() |
17e5d48fdf | ||
![]() |
8b180b8d9a | ||
![]() |
08f496e1e6 | ||
![]() |
93587a7502 | ||
![]() |
39114b0b8a | ||
![]() |
98f8bfa679 | ||
![]() |
b7042a70db | ||
![]() |
ed3f0da731 | ||
![]() |
f5653c9bb1 | ||
![]() |
e542248778 | ||
![]() |
3b0825633c | ||
![]() |
375fd571fa | ||
![]() |
59dbb885aa | ||
![]() |
160a8fac51 | ||
![]() |
bf5281804e | ||
![]() |
557af9745a | ||
![]() |
ec52d2eda0 | ||
![]() |
49544c0200 | ||
![]() |
e6ca36b8b7 | ||
![]() |
398f8fa59f | ||
![]() |
ff1f8bb4e1 | ||
![]() |
47f26292b1 | ||
![]() |
bdf6a038c2 | ||
![]() |
411c5df4b4 | ||
![]() |
9fc117b105 | ||
![]() |
63a5cd3190 | ||
![]() |
c80495eca6 | ||
![]() |
1507ba9553 | ||
![]() |
a01e0f0037 | ||
![]() |
a9d1d2f242 | ||
![]() |
64c174c385 | ||
![]() |
04862bce48 | ||
![]() |
2832b0150e | ||
![]() |
bfe65c9707 | ||
![]() |
c1f1adbb8f | ||
![]() |
d7df6679bd | ||
![]() |
856ef3dc32 | ||
![]() |
ad3f1bc80c | ||
![]() |
78c398ba4b | ||
![]() |
a94762e8a1 | ||
![]() |
70e05970f0 | ||
![]() |
ade572815a | ||
![]() |
c6a87e000d | ||
![]() |
6855c85329 | ||
![]() |
c3e2ff4b4b | ||
![]() |
c2512319ab | ||
![]() |
fedd0c352a | ||
![]() |
4dbfde4bf5 | ||
![]() |
c2809032fd | ||
![]() |
e508ff900a | ||
![]() |
ea579aaa5d | ||
![]() |
3aa944bb49 | ||
![]() |
f31e2663b6 | ||
![]() |
a6e18ae9c5 | ||
![]() |
c6d4b937cb | ||
![]() |
615be7d325 | ||
![]() |
8561e68d36 | ||
![]() |
5fce7836d9 | ||
![]() |
3502e34428 | ||
![]() |
724c0b883f | ||
![]() |
20dfc35f7e | ||
![]() |
29690d7c7b | ||
![]() |
876d98a785 | ||
![]() |
11b3707087 | ||
![]() |
d529877888 | ||
![]() |
b42ad0561c | ||
![]() |
64664cb0bb | ||
![]() |
b0ba723bdd | ||
![]() |
5eab7f879c | ||
![]() |
ddfe17b77b | ||
![]() |
8fe79a012b | ||
![]() |
d71a4912bd | ||
![]() |
98f841790a | ||
![]() |
d89fa7f707 | ||
![]() |
3d754b50f1 | ||
![]() |
4491dd35df | ||
![]() |
e175e29813 | ||
![]() |
a83001a799 | ||
![]() |
c1f195d74f | ||
![]() |
edea2d3a6f | ||
![]() |
462cef20c3 | ||
![]() |
726e88de19 | ||
![]() |
29243a37b8 | ||
![]() |
66dfb9d205 | ||
![]() |
c83dab7793 | ||
![]() |
7ef7246361 | ||
![]() |
b8a2e29f21 | ||
![]() |
37c78f608a | ||
![]() |
c8d09d6294 | ||
![]() |
dcda452da8 | ||
![]() |
eb1ab7662d | ||
![]() |
2874bf0f82 | ||
![]() |
d93f47e3ee | ||
![]() |
bc2c22ac10 | ||
![]() |
d679d52b66 | ||
![]() |
ae26190928 | ||
![]() |
474f2d134b | ||
![]() |
9976e5473f | ||
![]() |
df0279ac03 | ||
![]() |
6512b2f501 | ||
![]() |
beb690ba72 | ||
![]() |
9ee1bfad17 | ||
![]() |
3ff9843750 | ||
![]() |
450ff06d4e | ||
![]() |
728c4be6e1 | ||
![]() |
9f3a65b5b4 | ||
![]() |
47ea11b533 | ||
![]() |
d14385c4d2 | ||
![]() |
cd0f9624ae | ||
![]() |
51233dff9b | ||
![]() |
fac31cd99f | ||
![]() |
ecf8f4e722 | ||
![]() |
6c22e28512 | ||
![]() |
025fee3045 | ||
![]() |
5019c99c11 | ||
![]() |
e7021f480f | ||
![]() |
6c54d4d8e3 | ||
![]() |
f68adf9170 | ||
![]() |
72faee02b6 | ||
![]() |
6fca250856 | ||
![]() |
8bd9a75629 | ||
![]() |
7af92195c4 | ||
![]() |
c23b2e4913 | ||
![]() |
87149517d8 | ||
![]() |
c4bb8dfa64 |
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/**
|
14
.github/workflows/python-ci.yml
vendored
14
.github/workflows/python-ci.yml
vendored
@@ -70,7 +70,7 @@ jobs:
|
|||||||
- name: Run pip-audit
|
- name: Run pip-audit
|
||||||
run: |
|
run: |
|
||||||
pip install pip-audit
|
pip install pip-audit
|
||||||
pip-audit -r requirements.lock
|
pip-audit -r requirements.lock --ignore-vuln GHSA-wj6h-64fc-37mp
|
||||||
- name: Determine stress args
|
- name: Determine stress args
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -84,12 +84,24 @@ jobs:
|
|||||||
timeout-minutes: 16
|
timeout-minutes: 16
|
||||||
shell: bash
|
shell: bash
|
||||||
run: scripts/run_ci_tests.sh
|
run: scripts/run_ci_tests.sh
|
||||||
|
- name: Run desktop tests
|
||||||
|
timeout-minutes: 10
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
TOGA_BACKEND: toga_dummy
|
||||||
|
run: scripts/run_gui_tests.sh
|
||||||
- name: Upload pytest log
|
- name: Upload pytest log
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: pytest-log-${{ matrix.os }}
|
name: pytest-log-${{ matrix.os }}
|
||||||
path: pytest.log
|
path: pytest.log
|
||||||
|
- name: Upload GUI pytest log
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: gui-pytest-log-${{ matrix.os }}
|
||||||
|
path: pytest_gui.log
|
||||||
- name: Upload coverage report
|
- name: Upload coverage report
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
168
README.md
168
README.md
@@ -21,6 +21,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
|||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
|
- [Architecture Overview](#architecture-overview)
|
||||||
- [Prerequisites](#prerequisites)
|
- [Prerequisites](#prerequisites)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [1. Clone the Repository](#1-clone-the-repository)
|
- [1. Clone the Repository](#1-clone-the-repository)
|
||||||
@@ -31,7 +32,9 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
|||||||
- [Running the Application](#running-the-application)
|
- [Running the Application](#running-the-application)
|
||||||
- [Managing Multiple Seeds](#managing-multiple-seeds)
|
- [Managing Multiple Seeds](#managing-multiple-seeds)
|
||||||
- [Additional Entry Types](#additional-entry-types)
|
- [Additional Entry Types](#additional-entry-types)
|
||||||
|
- [Recovery](#recovery)
|
||||||
- [Building a standalone executable](#building-a-standalone-executable)
|
- [Building a standalone executable](#building-a-standalone-executable)
|
||||||
|
- [Packaging with Briefcase](#packaging-with-briefcase)
|
||||||
- [Security Considerations](#security-considerations)
|
- [Security Considerations](#security-considerations)
|
||||||
- [Contributing](#contributing)
|
- [Contributing](#contributing)
|
||||||
- [License](#license)
|
- [License](#license)
|
||||||
@@ -54,8 +57,9 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
|||||||
- **Optional External Backup Location:** Configure a second directory where backups are automatically copied.
|
- **Optional External Backup Location:** Configure a second directory where backups are automatically copied.
|
||||||
- **Auto-Lock on Inactivity:** Vault locks after a configurable timeout for additional security.
|
- **Auto-Lock on Inactivity:** Vault locks after a configurable timeout for additional security.
|
||||||
- **Quick Unlock:** Optionally skip the password prompt after verifying once.
|
- **Quick Unlock:** Optionally skip the password prompt after verifying once.
|
||||||
- **Secret Mode:** Copy retrieved passwords directly to your clipboard and automatically clear it after a delay.
|
- **Secret Mode:** When enabled, newly generated and retrieved passwords are copied to your clipboard and automatically cleared after a delay.
|
||||||
- **Tagging Support:** Organize entries with optional tags and find them quickly via search.
|
- **Tagging Support:** Organize entries with optional tags and find them quickly via search.
|
||||||
|
- **Typed Search Results:** Results now display each entry's type for quicker identification.
|
||||||
- **Manual Vault Export/Import:** Create encrypted backups or restore them using the CLI or API.
|
- **Manual Vault Export/Import:** Create encrypted backups or restore them using the CLI or API.
|
||||||
- **Parent Seed Backup:** Securely save an encrypted copy of the master seed.
|
- **Parent Seed Backup:** Securely save an encrypted copy of the master seed.
|
||||||
- **Manual Vault Locking:** Instantly clear keys from memory when needed.
|
- **Manual Vault Locking:** Instantly clear keys from memory when needed.
|
||||||
@@ -65,9 +69,41 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
|||||||
- **Relay Management:** List, add, remove or reset configured Nostr relays.
|
- **Relay Management:** List, add, remove or reset configured Nostr relays.
|
||||||
- **Offline Mode:** Disable all Nostr communication for local-only operation.
|
- **Offline Mode:** Disable all Nostr communication for local-only operation.
|
||||||
|
|
||||||
|
|
||||||
A small on-screen notification area now shows queued messages for 10 seconds
|
A small on-screen notification area now shows queued messages for 10 seconds
|
||||||
before fading.
|
before fading.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
SeedPass follows a layered design. The **`seedpass.core`** package exposes the
|
||||||
|
`PasswordManager` along with service classes (e.g. `VaultService` and
|
||||||
|
`EntryService`) that implement the main API used across interfaces.
|
||||||
|
The command line tool in **`seedpass.cli`** is a thin adapter built with Typer
|
||||||
|
that delegates operations to this API layer.
|
||||||
|
|
||||||
|
The BeeWare desktop interface lives in **`seedpass_gui.app`** and can be
|
||||||
|
started with either `seedpass-gui` or `python -m seedpass_gui`. It reuses the
|
||||||
|
same service objects to unlock the vault, list entries and search through them.
|
||||||
|
|
||||||
|
An optional browser extension can communicate with the FastAPI server exposed by
|
||||||
|
`seedpass.api` to manage entries from within the browser.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
core["seedpass.core"]
|
||||||
|
cli["CLI"]
|
||||||
|
api["FastAPI server"]
|
||||||
|
gui["BeeWare GUI"]
|
||||||
|
ext["Browser Extension"]
|
||||||
|
|
||||||
|
cli --> core
|
||||||
|
gui --> core
|
||||||
|
api --> core
|
||||||
|
ext --> api
|
||||||
|
```
|
||||||
|
|
||||||
|
See `docs/ARCHITECTURE.md` for details.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Python 3.8+** (3.11 or 3.12 recommended): Install Python from [python.org](https://www.python.org/downloads/) and be sure to check **"Add Python to PATH"** during setup. Using Python 3.13 is currently discouraged because some dependencies do not ship wheels for it yet, which can cause build failures on Windows unless you install the Visual C++ Build Tools.
|
- **Python 3.8+** (3.11 or 3.12 recommended): Install Python from [python.org](https://www.python.org/downloads/) and be sure to check **"Add Python to PATH"** during setup. Using Python 3.13 is currently discouraged because some dependencies do not ship wheels for it yet, which can cause build failures on Windows unless you install the Visual C++ Build Tools.
|
||||||
@@ -78,6 +114,9 @@ before fading.
|
|||||||
### Quick Installer
|
### Quick Installer
|
||||||
|
|
||||||
Use the automated installer to download SeedPass and its dependencies in one step.
|
Use the automated installer to download SeedPass and its dependencies in one step.
|
||||||
|
The scripts also install the correct BeeWare backend for your platform automatically.
|
||||||
|
If the GTK `gi` bindings are missing, the installer attempts to install the
|
||||||
|
necessary system packages using `apt`, `yum`, `pacman`, or Homebrew.
|
||||||
|
|
||||||
**Linux and macOS:**
|
**Linux and macOS:**
|
||||||
```bash
|
```bash
|
||||||
@@ -87,6 +126,7 @@ bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/
|
|||||||
```bash
|
```bash
|
||||||
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)" _ -b beta
|
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)" _ -b beta
|
||||||
```
|
```
|
||||||
|
Make sure the command ends right after `-b beta` with **no trailing parenthesis**.
|
||||||
|
|
||||||
**Windows (PowerShell):**
|
**Windows (PowerShell):**
|
||||||
```powershell
|
```powershell
|
||||||
@@ -96,6 +136,20 @@ Before running the script, install **Python 3.11** or **3.12** from [python.org]
|
|||||||
The Windows installer will attempt to install Git automatically if it is not already available. It also tries to install Python 3 using `winget`, `choco`, or `scoop` when Python is missing and recognizes the `py` launcher if `python` isn't on your PATH. If these tools are unavailable you'll see a link to download Python directly from <https://www.python.org/downloads/windows/>. When Python 3.13 or newer is detected without the Microsoft C++ build tools, the installer now attempts to download Python 3.12 automatically so you don't have to compile packages from source.
|
The Windows installer will attempt to install Git automatically if it is not already available. It also tries to install Python 3 using `winget`, `choco`, or `scoop` when Python is missing and recognizes the `py` launcher if `python` isn't on your PATH. If these tools are unavailable you'll see a link to download Python directly from <https://www.python.org/downloads/windows/>. When Python 3.13 or newer is detected without the Microsoft C++ build tools, the installer now attempts to download Python 3.12 automatically so you don't have to compile packages from source.
|
||||||
|
|
||||||
**Note:** If this fallback fails, install Python 3.12 manually or install the [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and rerun the installer.
|
**Note:** If this fallback fails, install Python 3.12 manually or install the [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and rerun the installer.
|
||||||
|
|
||||||
|
#### Windows Nostr Sync Troubleshooting
|
||||||
|
|
||||||
|
When backing up or restoring from Nostr on Windows, a few issues are common:
|
||||||
|
|
||||||
|
* **Event loop errors** – Messages like `RuntimeError: Event loop is closed` usually mean the async runtime failed to initialize. Running SeedPass with `--verbose` provides more detail about which coroutine failed.
|
||||||
|
* **Permission problems** – If you see `Access is denied` when writing to `~/.seedpass`, launch your terminal with "Run as administrator" so the app can create files in your profile directory.
|
||||||
|
* **Missing dependencies** – Ensure `websockets` and other requirements are installed inside your virtual environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install websockets
|
||||||
|
```
|
||||||
|
|
||||||
|
Using increased log verbosity helps diagnose sync issues and confirm that the WebSocket connections to your configured relays succeed.
|
||||||
### Uninstall
|
### Uninstall
|
||||||
|
|
||||||
Run the matching uninstaller if you need to remove a previous installation or clean up an old `seedpass` command:
|
Run the matching uninstaller if you need to remove a previous installation or clean up an old `seedpass` command:
|
||||||
@@ -182,6 +236,7 @@ seedpass import --file "~/seedpass_backup.json"
|
|||||||
seedpass search "github"
|
seedpass search "github"
|
||||||
seedpass search --tags "work,personal"
|
seedpass search --tags "work,personal"
|
||||||
seedpass get "github"
|
seedpass get "github"
|
||||||
|
# Search results show the entry type, e.g. "1: Password - GitHub"
|
||||||
# Retrieve a TOTP entry
|
# Retrieve a TOTP entry
|
||||||
seedpass entry get "email"
|
seedpass entry get "email"
|
||||||
# The code is printed and copied to your clipboard
|
# The code is printed and copied to your clipboard
|
||||||
@@ -189,6 +244,8 @@ seedpass entry get "email"
|
|||||||
# Sort or filter the list view
|
# Sort or filter the list view
|
||||||
seedpass list --sort label
|
seedpass list --sort label
|
||||||
seedpass list --filter totp
|
seedpass list --filter totp
|
||||||
|
# Generate a password with the safe character set defined by `SAFE_SPECIAL_CHARS`
|
||||||
|
seedpass util generate-password --length 20 --special-mode safe --exclude-ambiguous
|
||||||
|
|
||||||
# Use the **Settings** menu to configure an extra backup directory
|
# Use the **Settings** menu to configure an extra backup directory
|
||||||
# on an external drive.
|
# on an external drive.
|
||||||
@@ -196,6 +253,53 @@ seedpass list --filter totp
|
|||||||
|
|
||||||
For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md). Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md).
|
For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md). Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md).
|
||||||
|
|
||||||
|
### Getting Started with the GUI
|
||||||
|
|
||||||
|
SeedPass also ships with a simple BeeWare desktop interface. Launch it from
|
||||||
|
your virtual environment using any of the following commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
seedpass gui
|
||||||
|
python -m seedpass_gui
|
||||||
|
seedpass-gui
|
||||||
|
```
|
||||||
|
|
||||||
|
Only `toga-core` and the headless `toga-dummy` backend are included by default.
|
||||||
|
The quick installer automatically installs the correct BeeWare backend so the
|
||||||
|
GUI works out of the box. If you set up SeedPass manually, install the backend
|
||||||
|
for your platform:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux
|
||||||
|
pip install toga-gtk
|
||||||
|
|
||||||
|
# If you see build errors about "cairo" on Linux, install the cairo
|
||||||
|
# development headers using your package manager, e.g.:
|
||||||
|
sudo apt-get install libcairo2 libcairo2-dev
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
pip install toga-winforms
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
pip install toga-cocoa
|
||||||
|
```
|
||||||
|
|
||||||
|
The GUI works with the same vault and configuration files as the CLI.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
core["seedpass.core"]
|
||||||
|
cli["CLI"]
|
||||||
|
api["FastAPI server"]
|
||||||
|
gui["BeeWare GUI"]
|
||||||
|
ext["Browser Extension"]
|
||||||
|
|
||||||
|
cli --> core
|
||||||
|
gui --> core
|
||||||
|
api --> core
|
||||||
|
ext --> api
|
||||||
|
```
|
||||||
|
|
||||||
### Vault JSON Layout
|
### Vault JSON Layout
|
||||||
|
|
||||||
The encrypted index file `seedpass_entries_db.json.enc` begins with `schema_version` `2` and stores an `entries` map keyed by entry numbers.
|
The encrypted index file `seedpass_entries_db.json.enc` begins with `schema_version` `2` and stores an `entries` map keyed by entry numbers.
|
||||||
@@ -305,6 +409,15 @@ When choosing **Add Entry**, you can now select from:
|
|||||||
- **Key/Value**
|
- **Key/Value**
|
||||||
- **Managed Account**
|
- **Managed Account**
|
||||||
|
|
||||||
|
### Adding a Password Entry
|
||||||
|
|
||||||
|
After selecting **Password**, SeedPass asks you to pick a mode:
|
||||||
|
|
||||||
|
1. **Quick** – prompts only for a label, username, URL, desired length, and whether to include special characters. Default values are used for notes, tags, and policy settings.
|
||||||
|
2. **Advanced** – walks through the full set of prompts for notes, tags, custom fields, and detailed password policy options.
|
||||||
|
|
||||||
|
Both modes generate the password, display it (or copy it to the clipboard in Secret Mode), and save the entry to your encrypted vault.
|
||||||
|
|
||||||
### Adding a 2FA Entry
|
### Adding a 2FA Entry
|
||||||
|
|
||||||
1. From the main menu choose **Add Entry** and select **2FA (TOTP)**.
|
1. From the main menu choose **Add Entry** and select **2FA (TOTP)**.
|
||||||
@@ -326,11 +439,11 @@ When choosing **Add Entry**, you can now select from:
|
|||||||
|
|
||||||
### Using Secret Mode
|
### Using Secret Mode
|
||||||
|
|
||||||
When **Secret Mode** is enabled, SeedPass copies retrieved passwords directly to your clipboard instead of displaying them on screen. The clipboard clears automatically after the delay you choose.
|
When **Secret Mode** is enabled, SeedPass copies newly generated and retrieved passwords directly to your clipboard instead of displaying them on screen. The clipboard clears automatically after the delay you choose.
|
||||||
|
|
||||||
1. From the main menu open **Settings** and select **Toggle Secret Mode**.
|
1. From the main menu open **Settings** and select **Toggle Secret Mode**.
|
||||||
2. Choose how many seconds to keep passwords on the clipboard.
|
2. Choose how many seconds to keep passwords on the clipboard.
|
||||||
3. Retrieve an entry and SeedPass will confirm the password was copied.
|
3. Generate or retrieve an entry and SeedPass will confirm the password was copied.
|
||||||
|
|
||||||
### Viewing Entry Details
|
### Viewing Entry Details
|
||||||
|
|
||||||
@@ -361,7 +474,7 @@ The table below summarizes the extra fields stored for each entry type. Every en
|
|||||||
| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes`, optional `tags` |
|
| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes`, optional `tags` |
|
||||||
| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes`, optional `tags` |
|
| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes`, optional `tags` |
|
||||||
| Nostr Key Pair | `index`, `archived`, optional `notes`, optional `tags` |
|
| Nostr Key Pair | `index`, `archived`, optional `notes`, optional `tags` |
|
||||||
| Key/Value | `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` |
|
| Key/Value | `key`, `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` |
|
||||||
| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes`, optional `tags` |
|
| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes`, optional `tags` |
|
||||||
|
|
||||||
### Managing Multiple Seeds
|
### Managing Multiple Seeds
|
||||||
@@ -442,6 +555,18 @@ seedpass config set nostr_retry_delay 1
|
|||||||
|
|
||||||
The default configuration uses **50,000** PBKDF2 iterations. Increase this value for stronger password hashing or lower it for faster startup (not recommended). Offline Mode skips all Nostr communication, keeping your data local until you re-enable syncing. Quick Unlock stores a hashed copy of your password in the encrypted config so that after the initial unlock, subsequent operations won't prompt for the password until you exit the program. Avoid enabling Quick Unlock on shared machines.
|
The default configuration uses **50,000** PBKDF2 iterations. Increase this value for stronger password hashing or lower it for faster startup (not recommended). Offline Mode skips all Nostr communication, keeping your data local until you re-enable syncing. Quick Unlock stores a hashed copy of your password in the encrypted config so that after the initial unlock, subsequent operations won't prompt for the password until you exit the program. Avoid enabling Quick Unlock on shared machines.
|
||||||
|
|
||||||
|
### Recovery
|
||||||
|
|
||||||
|
If you previously backed up your vault to Nostr you can restore it during the
|
||||||
|
initial setup:
|
||||||
|
|
||||||
|
1. Start SeedPass and choose option **4** when prompted to set up a seed.
|
||||||
|
2. Paste your BIP-85 seed phrase when asked.
|
||||||
|
3. SeedPass initializes the profile and attempts to download the encrypted vault
|
||||||
|
from the configured relays.
|
||||||
|
4. A success message confirms the vault was restored. If no data is found a
|
||||||
|
failure message is shown and a new empty vault is created.
|
||||||
|
|
||||||
## Running Tests
|
## Running Tests
|
||||||
|
|
||||||
SeedPass includes a small suite of unit tests located under `src/tests`. **Before running `pytest`, be sure to install the test requirements.** Activate your virtual environment and run `pip install -r src/requirements.txt` to ensure all testing dependencies are available. Then run the tests with **pytest**. Use `-vv` to see INFO-level log messages from each passing test:
|
SeedPass includes a small suite of unit tests located under `src/tests`. **Before running `pytest`, be sure to install the test requirements.** Activate your virtual environment and run `pip install -r src/requirements.txt` to ensure all testing dependencies are available. Then run the tests with **pytest**. Use `-vv` to see INFO-level log messages from each passing test:
|
||||||
@@ -494,6 +619,10 @@ If the checksum file is missing, generate it manually:
|
|||||||
python scripts/update_checksum.py
|
python scripts/update_checksum.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If SeedPass prints a "script checksum mismatch" warning on startup, regenerate
|
||||||
|
the checksum with `seedpass util update-checksum` or select "Generate Script
|
||||||
|
Checksum" from the Settings menu.
|
||||||
|
|
||||||
To run mutation tests locally, generate coverage data first and then execute `mutmut`:
|
To run mutation tests locally, generate coverage data first and then execute `mutmut`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -537,8 +666,39 @@ scripts/vendor_dependencies.sh
|
|||||||
pyinstaller SeedPass.spec
|
pyinstaller SeedPass.spec
|
||||||
```
|
```
|
||||||
|
|
||||||
|
You can also produce packaged installers for the GUI with BeeWare's Briefcase:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
briefcase build
|
||||||
|
```
|
||||||
|
|
||||||
|
Pre-built installers are published for each `seedpass-gui` tag. Visit the
|
||||||
|
project's **Actions** or **Releases** page on GitHub to download the latest
|
||||||
|
package for your platform.
|
||||||
|
|
||||||
The standalone executable will appear in the `dist/` directory. This process works on Windows, macOS and Linux but you must build on each platform for a native binary.
|
The standalone executable will appear in the `dist/` directory. This process works on Windows, macOS and Linux but you must build on each platform for a native binary.
|
||||||
|
|
||||||
|
## Packaging with Briefcase
|
||||||
|
|
||||||
|
For step-by-step instructions see [docs/docs/content/01-getting-started/05-briefcase.md](docs/docs/content/01-getting-started/05-briefcase.md).
|
||||||
|
|
||||||
|
Install Briefcase and create a platform-specific scaffold:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install briefcase
|
||||||
|
briefcase create
|
||||||
|
```
|
||||||
|
|
||||||
|
Build and run the packaged GUI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
briefcase build
|
||||||
|
briefcase run
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also launch the GUI directly with `seedpass gui` or `seedpass-gui`.
|
||||||
|
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
**Important:** The password you use to encrypt your parent seed is also required to decrypt the seed index data retrieved from Nostr. **It is imperative to remember this password** and be sure to use it with the same seed, as losing it means you won't be able to access your stored index. Secure your 12-word seed **and** your master password.
|
**Important:** The password you use to encrypt your parent seed is also required to decrypt the seed index data retrieved from Nostr. **It is imperative to remember this password** and be sure to use it with the same seed, as losing it means you won't be able to access your stored index. Secure your 12-word seed **and** your master password.
|
||||||
|
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.
|
@@ -49,15 +49,15 @@ Manage individual entries within a vault.
|
|||||||
| List entries | `entry list` | `seedpass entry list --sort label` |
|
| List entries | `entry list` | `seedpass entry list --sort label` |
|
||||||
| Search for entries | `entry search` | `seedpass entry search "GitHub"` |
|
| Search for entries | `entry search` | `seedpass entry search "GitHub"` |
|
||||||
| Retrieve an entry's secret (password or TOTP code) | `entry get` | `seedpass entry get "GitHub"` |
|
| Retrieve an entry's secret (password or TOTP code) | `entry get` | `seedpass entry get "GitHub"` |
|
||||||
| Add a password entry | `entry add` | `seedpass entry add Example --length 16` |
|
| Add a password entry | `entry add` | `seedpass entry add Example --length 16 --no-special --exclude-ambiguous` |
|
||||||
| Add a TOTP entry | `entry add-totp` | `seedpass entry add-totp Email --secret JBSW...` |
|
| Add a TOTP entry | `entry add-totp` | `seedpass entry add-totp Email --secret JBSW...` |
|
||||||
| Add an SSH key entry | `entry add-ssh` | `seedpass entry add-ssh Server --index 0` |
|
| Add an SSH key entry | `entry add-ssh` | `seedpass entry add-ssh Server --index 0` |
|
||||||
| Add a PGP key entry | `entry add-pgp` | `seedpass entry add-pgp Personal --user-id me@example.com` |
|
| Add a PGP key entry | `entry add-pgp` | `seedpass entry add-pgp Personal --user-id me@example.com` |
|
||||||
| Add a Nostr key entry | `entry add-nostr` | `seedpass entry add-nostr Chat` |
|
| Add a Nostr key entry | `entry add-nostr` | `seedpass entry add-nostr Chat` |
|
||||||
| Add a seed phrase entry | `entry add-seed` | `seedpass entry add-seed Backup --words 24` |
|
| Add a seed phrase entry | `entry add-seed` | `seedpass entry add-seed Backup --words 24` |
|
||||||
| Add a key/value entry | `entry add-key-value` | `seedpass entry add-key-value "API Token" --value abc123` |
|
| Add a key/value entry | `entry add-key-value` | `seedpass entry add-key-value "API Token" --key api --value abc123` |
|
||||||
| Add a managed account entry | `entry add-managed-account` | `seedpass entry add-managed-account Trading` |
|
| Add a managed account entry | `entry add-managed-account` | `seedpass entry add-managed-account Trading` |
|
||||||
| Modify an entry | `entry modify` | `seedpass entry modify 1 --username alice` |
|
| Modify an entry | `entry modify` | `seedpass entry modify 1 --key new --value updated` |
|
||||||
| Archive an entry | `entry archive` | `seedpass entry archive 1` |
|
| Archive an entry | `entry archive` | `seedpass entry archive 1` |
|
||||||
| Unarchive an entry | `entry unarchive` | `seedpass entry unarchive 1` |
|
| Unarchive an entry | `entry unarchive` | `seedpass entry unarchive 1` |
|
||||||
| Export all TOTP secrets | `entry export-totp` | `seedpass entry export-totp --file totp.json` |
|
| Export all TOTP secrets | `entry export-totp` | `seedpass entry export-totp --file totp.json` |
|
||||||
@@ -112,10 +112,14 @@ Miscellaneous helper commands.
|
|||||||
|
|
||||||
| Action | Command | Examples |
|
| Action | Command | Examples |
|
||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| Generate a password | `util generate-password` | `seedpass util generate-password --length 24` |
|
| Generate a password | `util generate-password` | `seedpass util generate-password --length 24 --special-mode safe --exclude-ambiguous` |
|
||||||
| Verify script checksum | `util verify-checksum` | `seedpass util verify-checksum` |
|
| Verify script checksum | `util verify-checksum` | `seedpass util verify-checksum` |
|
||||||
| Update script checksum | `util update-checksum` | `seedpass util update-checksum` |
|
| Update script checksum | `util update-checksum` | `seedpass util update-checksum` |
|
||||||
|
|
||||||
|
If you see a startup warning about a script checksum mismatch,
|
||||||
|
run `seedpass util update-checksum` or choose "Generate Script Checksum"
|
||||||
|
from the Settings menu to update the stored value.
|
||||||
|
|
||||||
### API Commands
|
### API Commands
|
||||||
|
|
||||||
Run or stop the local HTTP API.
|
Run or stop the local HTTP API.
|
||||||
@@ -132,17 +136,17 @@ Run or stop the local HTTP API.
|
|||||||
### `entry` Commands
|
### `entry` Commands
|
||||||
|
|
||||||
- **`seedpass entry list`** – List entries in the vault, optionally sorted or filtered.
|
- **`seedpass entry list`** – List entries in the vault, optionally sorted or filtered.
|
||||||
- **`seedpass entry search <query>`** – Search across labels, usernames, URLs and notes.
|
- **`seedpass entry search <query>`** – Search across labels, usernames, URLs and notes. Results show the entry type before each label.
|
||||||
- **`seedpass entry get <query>`** – Retrieve the password or TOTP code for one matching entry, depending on the entry's type.
|
- **`seedpass entry get <query>`** – Retrieve the password or TOTP code for one matching entry, depending on the entry's type.
|
||||||
- **`seedpass entry add <label>`** – Create a new password entry. Use `--length` to set the password length and optional `--username`/`--url` values.
|
- **`seedpass entry add <label>`** – Create a new password entry. Use `--length` and flags like `--no-special`, `--special-mode safe`, or `--exclude-ambiguous` to override the global policy.
|
||||||
- **`seedpass entry add-totp <label>`** – Create a TOTP entry. Use `--secret` to import an existing secret or `--index` to derive from the seed.
|
- **`seedpass entry add-totp <label>`** – Create a TOTP entry. Use `--secret` to import an existing secret or `--index` to derive from the seed.
|
||||||
- **`seedpass entry add-ssh <label>`** – Create an SSH key entry derived from the seed.
|
- **`seedpass entry add-ssh <label>`** – Create an SSH key entry derived from the seed.
|
||||||
- **`seedpass entry add-pgp <label>`** – Create a PGP key entry. Provide `--user-id` and `--key-type` as needed.
|
- **`seedpass entry add-pgp <label>`** – Create a PGP key entry. Provide `--user-id` and `--key-type` as needed.
|
||||||
- **`seedpass entry add-nostr <label>`** – Create a Nostr key entry for decentralised chat.
|
- **`seedpass entry add-nostr <label>`** – Create a Nostr key entry for decentralised chat.
|
||||||
- **`seedpass entry add-seed <label>`** – Store a derived seed phrase. Use `--words` to set the word count.
|
- **`seedpass entry add-seed <label>`** – Store a derived seed phrase. Use `--words` to set the word count.
|
||||||
- **`seedpass entry add-key-value <label>`** – Store arbitrary data with `--value`.
|
- **`seedpass entry add-key-value <label>`** – Store arbitrary data with `--key` and `--value`.
|
||||||
- **`seedpass entry add-managed-account <label>`** – Store a BIP‑85 derived account seed.
|
- **`seedpass entry add-managed-account <label>`** – Store a BIP‑85 derived account seed.
|
||||||
- **`seedpass entry modify <id>`** – Update an entry's label, username, URL or notes.
|
- **`seedpass entry modify <id>`** – Update an entry's fields. For key/value entries you can change the label, key and value.
|
||||||
- **`seedpass entry archive <id>`** – Mark an entry as archived so it is hidden from normal lists.
|
- **`seedpass entry archive <id>`** – Mark an entry as archived so it is hidden from normal lists.
|
||||||
- **`seedpass entry unarchive <id>`** – Restore an archived entry.
|
- **`seedpass entry unarchive <id>`** – Restore an archived entry.
|
||||||
- **`seedpass entry export-totp --file <path>`** – Export all stored TOTP secrets to a JSON file.
|
- **`seedpass entry export-totp --file <path>`** – Export all stored TOTP secrets to a JSON file.
|
||||||
@@ -181,7 +185,7 @@ QR codes for supported types.
|
|||||||
### `config` Commands
|
### `config` Commands
|
||||||
|
|
||||||
- **`seedpass config get <key>`** – Retrieve a configuration value such as `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `clipboard_clear_delay`, `additional_backup_path`, `relays`, `quick_unlock`, `nostr_max_retries`, `nostr_retry_delay`, or password policy fields like `min_uppercase`.
|
- **`seedpass config get <key>`** – Retrieve a configuration value such as `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `clipboard_clear_delay`, `additional_backup_path`, `relays`, `quick_unlock`, `nostr_max_retries`, `nostr_retry_delay`, or password policy fields like `min_uppercase`.
|
||||||
- **`seedpass config set <key> <value>`** – Update a configuration option. Example: `seedpass config set kdf_iterations 200000`. Use keys like `min_uppercase`, `min_lowercase`, `min_digits`, `min_special`, `nostr_max_retries`, `nostr_retry_delay`, or `quick_unlock` to adjust settings.
|
- **`seedpass config set <key> <value>`** – Update a configuration option. Example: `seedpass config set kdf_iterations 200000`. Use keys like `min_uppercase`, `min_lowercase`, `min_digits`, `min_special`, `include_special_chars`, `allowed_special_chars`, `special_mode`, `exclude_ambiguous`, `nostr_max_retries`, `nostr_retry_delay`, or `quick_unlock` to adjust settings.
|
||||||
- **`seedpass config toggle-secret-mode`** – Interactively enable or disable Secret Mode and set the clipboard delay.
|
- **`seedpass config toggle-secret-mode`** – Interactively enable or disable Secret Mode and set the clipboard delay.
|
||||||
- **`seedpass config toggle-offline`** – Enable or disable offline mode to skip Nostr operations.
|
- **`seedpass config toggle-offline`** – Enable or disable offline mode to skip Nostr operations.
|
||||||
|
|
||||||
@@ -219,5 +223,5 @@ Shut down the server with `seedpass api stop`.
|
|||||||
- Use the `--help` flag for details on any command.
|
- Use the `--help` flag for details on any command.
|
||||||
- Set a strong master password and regularly export encrypted backups.
|
- Set a strong master password and regularly export encrypted backups.
|
||||||
- Adjust configuration values like `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `nostr_max_retries`, `nostr_retry_delay`, or `quick_unlock` through the `config` commands.
|
- Adjust configuration values like `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `nostr_max_retries`, `nostr_retry_delay`, or `quick_unlock` through the `config` commands.
|
||||||
- Customize password complexity with `config set min_uppercase 3`, `config set min_digits 4`, and similar commands.
|
- Customize the global password policy with commands like `config set min_uppercase 3` or `config set special_mode safe`. When adding a password interactively you can override these values, choose a safe special-character set, and exclude ambiguous characters.
|
||||||
- `entry get` is script‑friendly and can be piped into other commands.
|
- `entry get` is script‑friendly and can be piped into other commands.
|
||||||
|
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
This guide covers how to start the SeedPass API, authenticate requests, and interact with the available endpoints.
|
This guide covers how to start the SeedPass API, authenticate requests, and interact with the available endpoints.
|
||||||
|
|
||||||
|
**Note:** All UI layers, including the CLI, BeeWare GUI, and future adapters, consume this REST API through service classes in `seedpass.core`. See [docs/gui_adapter.md](docs/gui_adapter.md) for more details on the GUI integration.
|
||||||
|
|
||||||
|
|
||||||
## Starting the API
|
## Starting the API
|
||||||
|
|
||||||
Run `seedpass api start` from your terminal. The command prints a one‑time token used for authentication:
|
Run `seedpass api start` from your terminal. The command prints a one‑time token used for authentication:
|
||||||
|
@@ -95,10 +95,22 @@ Each entry is stored within `seedpass_entries_db.json.enc` under the `entries` d
|
|||||||
- **custom_fields** (`array`, optional): Additional user-defined fields.
|
- **custom_fields** (`array`, optional): Additional user-defined fields.
|
||||||
- **origin** (`string`, optional): Source identifier for imported data.
|
- **origin** (`string`, optional): Source identifier for imported data.
|
||||||
- **value** (`string`, optional): For `key_value` entries, stores the secret value.
|
- **value** (`string`, optional): For `key_value` entries, stores the secret value.
|
||||||
|
- **key** (`string`, optional): Name of the key for `key_value` entries.
|
||||||
- **index** (`integer`, optional): BIP-85 derivation index for entries that derive material from a seed.
|
- **index** (`integer`, optional): BIP-85 derivation index for entries that derive material from a seed.
|
||||||
- **word_count** (`integer`, managed_account only): Number of words in the child seed. Managed accounts always use `12`.
|
- **word_count** (`integer`, managed_account only): Number of words in the child seed. Managed accounts always use `12`.
|
||||||
- **fingerprint** (`string`, managed_account only): Identifier of the child profile, used for its directory name.
|
- **fingerprint** (`string`, managed_account only): Identifier of the child profile, used for its directory name.
|
||||||
- **tags** (`array`, optional): Category labels to aid in organization and search.
|
- **tags** (`array`, optional): Category labels to aid in organization and search.
|
||||||
|
|
||||||
|
#### Password Policy Fields
|
||||||
|
|
||||||
|
- **min_uppercase** (`integer`, default `2`): Minimum required uppercase letters.
|
||||||
|
- **min_lowercase** (`integer`, default `2`): Minimum required lowercase letters.
|
||||||
|
- **min_digits** (`integer`, default `2`): Minimum required digits.
|
||||||
|
- **min_special** (`integer`, default `2`): Minimum required special characters.
|
||||||
|
- **include_special_chars** (`boolean`, default `true`): Enable or disable any punctuation in generated passwords.
|
||||||
|
- **allowed_special_chars** (`string`, optional): Restrict punctuation to this exact set.
|
||||||
|
- **special_mode** (`string`, default `"standard"`): Choose `"safe"` for the `SAFE_SPECIAL_CHARS` set (`!@#$%^*-_+=?`), otherwise the full `string.punctuation` is used.
|
||||||
|
- **exclude_ambiguous** (`boolean`, default `false`): Omit confusing characters like `O0Il1`.
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -160,6 +172,17 @@ Each entry is stored within `seedpass_entries_db.json.enc` under the `entries` d
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Password Entry with Policy Overrides
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"label": "Custom Policy",
|
||||||
|
"length": 16,
|
||||||
|
"include_special_chars": false,
|
||||||
|
"exclude_ambiguous": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### 3. Managed User
|
#### 3. Managed User
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
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
|
✔ Windows 10/11 • macOS 12+ • Any modern Linux
|
||||||
SeedPass now uses the `portalocker` library for cross-platform file locking. No WSL or Cygwin required.
|
SeedPass now uses the `portalocker` library for cross-platform file locking. No WSL or Cygwin required.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
core(seedpass.core)
|
||||||
|
cli(CLI/TUI)
|
||||||
|
gui(BeeWare GUI)
|
||||||
|
ext(Browser extension)
|
||||||
|
cli --> core
|
||||||
|
gui --> core
|
||||||
|
ext --> core
|
||||||
|
```
|
||||||
|
|
||||||
|
SeedPass uses a modular design with a single core library that handles all
|
||||||
|
security-critical logic. The current CLI/TUI adapter communicates with
|
||||||
|
`seedpass.core`, and future interfaces like a BeeWare GUI and a browser
|
||||||
|
extension can hook into the same layer. This architecture keeps the codebase
|
||||||
|
maintainable while enabling a consistent experience on multiple platforms.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
@@ -54,6 +70,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
|||||||
- **Quick Unlock:** Optionally skip the password prompt after verifying once. Startup delay is unaffected.
|
- **Quick Unlock:** Optionally skip the password prompt after verifying once. Startup delay is unaffected.
|
||||||
- **Secret Mode:** Copy retrieved passwords directly to your clipboard and automatically clear it after a delay.
|
- **Secret Mode:** Copy retrieved passwords directly to your clipboard and automatically clear it after a delay.
|
||||||
- **Tagging Support:** Organize entries with optional tags and find them quickly via search.
|
- **Tagging Support:** Organize entries with optional tags and find them quickly via search.
|
||||||
|
- **Typed Search Results:** Searches display each entry's type for easier scanning.
|
||||||
- **Manual Vault Export/Import:** Create encrypted backups or restore them using the CLI or API.
|
- **Manual Vault Export/Import:** Create encrypted backups or restore them using the CLI or API.
|
||||||
- **Parent Seed Backup:** Securely save an encrypted copy of the master seed.
|
- **Parent Seed Backup:** Securely save an encrypted copy of the master seed.
|
||||||
- **Manual Vault Locking:** Instantly clear keys from memory when needed.
|
- **Manual Vault Locking:** Instantly clear keys from memory when needed.
|
||||||
@@ -74,6 +91,8 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
|||||||
### Quick Installer
|
### Quick Installer
|
||||||
|
|
||||||
Use the automated installer to download SeedPass and its dependencies in one step.
|
Use the automated installer to download SeedPass and its dependencies in one step.
|
||||||
|
If GTK packages are missing, the installer will try to install them using your
|
||||||
|
system's package manager (`apt`, `yum`, `pacman`, or Homebrew).
|
||||||
|
|
||||||
**Linux and macOS:**
|
**Linux and macOS:**
|
||||||
```bash
|
```bash
|
||||||
@@ -191,16 +210,17 @@ create a backup:
|
|||||||
seedpass
|
seedpass
|
||||||
|
|
||||||
# Export your index
|
# Export your index
|
||||||
seedpass export --file "~/seedpass_backup.json"
|
seedpass vault export --file "~/seedpass_backup.json"
|
||||||
|
|
||||||
# Later you can restore it
|
# Later you can restore it
|
||||||
seedpass import --file "~/seedpass_backup.json"
|
seedpass vault import --file "~/seedpass_backup.json"
|
||||||
# Import also performs a Nostr sync to pull any changes
|
# Import also performs a Nostr sync to pull any changes
|
||||||
|
|
||||||
# Quickly find or retrieve entries
|
# Quickly find or retrieve entries
|
||||||
seedpass search "github"
|
seedpass search "github"
|
||||||
seedpass search --tags "work,personal"
|
seedpass search --tags "work,personal"
|
||||||
seedpass get "github"
|
seedpass get "github"
|
||||||
|
# Search results show the entry type, e.g. "1: Password - GitHub"
|
||||||
# Retrieve a TOTP entry
|
# Retrieve a TOTP entry
|
||||||
seedpass entry get "email"
|
seedpass entry get "email"
|
||||||
# The code is printed and copied to your clipboard
|
# The code is printed and copied to your clipboard
|
||||||
@@ -296,6 +316,15 @@ When choosing **Add Entry**, you can now select from:
|
|||||||
- **Key/Value**
|
- **Key/Value**
|
||||||
- **Managed Account**
|
- **Managed Account**
|
||||||
|
|
||||||
|
### Adding a Password Entry
|
||||||
|
|
||||||
|
After selecting **Password**, SeedPass asks you to choose a mode:
|
||||||
|
|
||||||
|
1. **Quick** – enter only a label, username, URL, desired length, and whether to include special characters. All other fields use defaults.
|
||||||
|
2. **Advanced** – continue through prompts for notes, tags, custom fields, and detailed password policy settings.
|
||||||
|
|
||||||
|
Both modes generate the password, display it (or copy it to the clipboard in Secret Mode), and save the entry to your encrypted vault.
|
||||||
|
|
||||||
### Adding a 2FA Entry
|
### Adding a 2FA Entry
|
||||||
|
|
||||||
1. From the main menu choose **Add Entry** and select **2FA (TOTP)**.
|
1. From the main menu choose **Add Entry** and select **2FA (TOTP)**.
|
||||||
@@ -347,7 +376,7 @@ entry includes a `label`, while only password entries track a `url`.
|
|||||||
| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes`, optional `tags` |
|
| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes`, optional `tags` |
|
||||||
| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes`, optional `tags` |
|
| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes`, optional `tags` |
|
||||||
| Nostr Key Pair| `index`, `archived`, optional `notes`, optional `tags` |
|
| Nostr Key Pair| `index`, `archived`, optional `notes`, optional `tags` |
|
||||||
| Key/Value | `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` |
|
| Key/Value | `key`, `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` |
|
||||||
| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes`, optional `tags` |
|
| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes`, optional `tags` |
|
||||||
|
|
||||||
|
|
||||||
@@ -481,6 +510,10 @@ If the checksum file is missing, generate it manually:
|
|||||||
python scripts/update_checksum.py
|
python scripts/update_checksum.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If SeedPass reports a "script checksum mismatch" warning on startup,
|
||||||
|
regenerate the checksum with `seedpass util update-checksum` or select
|
||||||
|
"Generate Script Checksum" from the Settings menu.
|
||||||
|
|
||||||
To run mutation tests locally, generate coverage data first and then execute `mutmut`:
|
To run mutation tests locally, generate coverage data first and then execute `mutmut`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
@@ -84,6 +84,26 @@ flowchart TB
|
|||||||
<h2 class="section-title" id="architecture-heading">Architecture Overview</h2>
|
<h2 class="section-title" id="architecture-heading">Architecture Overview</h2>
|
||||||
<pre class="mermaid">
|
<pre class="mermaid">
|
||||||
---
|
---
|
||||||
|
config:
|
||||||
|
layout: fixed
|
||||||
|
theme: base
|
||||||
|
themeVariables:
|
||||||
|
primaryColor: '#e94a39'
|
||||||
|
primaryBorderColor: '#e94a39'
|
||||||
|
lineColor: '#e94a39'
|
||||||
|
look: classic
|
||||||
|
---
|
||||||
|
graph TD
|
||||||
|
core(seedpass.core)
|
||||||
|
cli(CLI/TUI)
|
||||||
|
gui(BeeWare GUI)
|
||||||
|
ext(Browser extension)
|
||||||
|
cli --> core
|
||||||
|
gui --> core
|
||||||
|
ext --> core
|
||||||
|
</pre>
|
||||||
|
<pre class="mermaid">
|
||||||
|
---
|
||||||
config:
|
config:
|
||||||
layout: fixed
|
layout: fixed
|
||||||
theme: base
|
theme: base
|
||||||
|
@@ -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"
|
name = "seedpass"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
seedpass = "seedpass.cli:app"
|
seedpass = "seedpass.cli:app"
|
||||||
|
seedpass-gui = "seedpass_gui.app:main"
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.11"
|
python_version = "3.11"
|
||||||
strict = true
|
strict = true
|
||||||
mypy_path = "src"
|
mypy_path = "src"
|
||||||
|
|
||||||
|
[tool.briefcase.app.seedpass-gui]
|
||||||
|
formal-name = "SeedPass"
|
||||||
|
description = "Deterministic password manager with a BeeWare GUI"
|
||||||
|
sources = ["src"]
|
||||||
|
requires = [
|
||||||
|
"toga-core>=0.5.2",
|
||||||
|
"colorama>=0.4.6",
|
||||||
|
"termcolor>=1.1.0",
|
||||||
|
"cryptography>=40.0.2",
|
||||||
|
"bip-utils>=2.5.0",
|
||||||
|
"bech32==1.2.0",
|
||||||
|
"coincurve>=18.0.0",
|
||||||
|
"mnemonic",
|
||||||
|
"aiohttp>=3.12.14",
|
||||||
|
"bcrypt",
|
||||||
|
"portalocker>=2.8",
|
||||||
|
"nostr-sdk>=0.43",
|
||||||
|
"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
|
ecdsa==0.19.1
|
||||||
ed25519-blake2b==1.4.1
|
ed25519-blake2b==1.4.1
|
||||||
execnet==2.1.1
|
execnet==2.1.1
|
||||||
fastapi==0.116.0
|
fastapi==0.116.1
|
||||||
frozenlist==1.7.0
|
frozenlist==1.7.0
|
||||||
glob2==0.7
|
glob2==0.7
|
||||||
hypothesis==6.135.20
|
hypothesis==6.135.20
|
||||||
@@ -32,7 +32,7 @@ mnemonic==0.21
|
|||||||
monero==1.1.1
|
monero==1.1.1
|
||||||
multidict==6.6.3
|
multidict==6.6.3
|
||||||
mutmut==2.4.4
|
mutmut==2.4.4
|
||||||
nostr-sdk==0.42.1
|
nostr-sdk==0.43.0
|
||||||
orjson==3.10.18
|
orjson==3.10.18
|
||||||
packaging==25.0
|
packaging==25.0
|
||||||
parso==0.8.4
|
parso==0.8.4
|
||||||
@@ -61,6 +61,7 @@ toml==0.10.2
|
|||||||
tomli==2.2.1
|
tomli==2.2.1
|
||||||
urllib3==2.5.0
|
urllib3==2.5.0
|
||||||
uvicorn==0.35.0
|
uvicorn==0.35.0
|
||||||
|
starlette==0.47.2
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
varint==1.0.2
|
varint==1.0.2
|
||||||
websocket-client==1.7.0
|
websocket-client==1.7.0
|
||||||
|
@@ -38,11 +38,11 @@ consts.SCRIPT_CHECKSUM_FILE = consts.APP_DIR / "seedpass_script_checksum.txt"
|
|||||||
|
|
||||||
from constants import APP_DIR, initialize_app
|
from constants import APP_DIR, initialize_app
|
||||||
from utils.key_derivation import derive_key_from_password, derive_index_key
|
from utils.key_derivation import derive_key_from_password, derive_index_key
|
||||||
from password_manager.encryption import EncryptionManager
|
from seedpass.core.encryption import EncryptionManager
|
||||||
from password_manager.vault import Vault
|
from seedpass.core.vault import Vault
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.entry_management import EntryManager
|
from seedpass.core.entry_management import EntryManager
|
||||||
from nostr.client import NostrClient
|
from nostr.client import NostrClient
|
||||||
from utils.fingerprint import generate_fingerprint
|
from utils.fingerprint import generate_fingerprint
|
||||||
from utils.fingerprint_manager import FingerprintManager
|
from utils.fingerprint_manager import FingerprintManager
|
||||||
|
@@ -260,6 +260,10 @@ if ($LASTEXITCODE -ne 0) {
|
|||||||
Write-Error "Failed to install SeedPass package"
|
Write-Error "Failed to install SeedPass package"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Write-Info "Installing BeeWare GUI backend..."
|
||||||
|
& "$VenvDir\Scripts\python.exe" -m pip install toga-winforms
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Warning "Failed to install GUI backend" }
|
||||||
|
|
||||||
# 5. Create launcher script
|
# 5. Create launcher script
|
||||||
Write-Info "Creating launcher script..."
|
Write-Info "Creating launcher script..."
|
||||||
if (-not (Test-Path $LauncherDir)) { New-Item -ItemType Directory -Path $LauncherDir | Out-Null }
|
if (-not (Test-Path $LauncherDir)) { New-Item -ItemType Directory -Path $LauncherDir | Out-Null }
|
||||||
@@ -279,6 +283,18 @@ if ($existingSeedpass -and $existingSeedpass.Source -ne $LauncherPath) {
|
|||||||
Write-Warning "Ensure '$LauncherDir' comes first in your PATH or remove the old installation."
|
Write-Warning "Ensure '$LauncherDir' comes first in your PATH or remove the old installation."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Detect additional seedpass executables on PATH that are not our launcher
|
||||||
|
$allSeedpass = Get-Command seedpass -All -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source
|
||||||
|
$stale = @()
|
||||||
|
foreach ($cmd in $allSeedpass) {
|
||||||
|
if ($cmd -ne $LauncherPath) { $stale += $cmd }
|
||||||
|
}
|
||||||
|
if ($stale.Count -gt 0) {
|
||||||
|
Write-Warning "Stale 'seedpass' executables detected:"
|
||||||
|
foreach ($cmd in $stale) { Write-Warning " - $cmd" }
|
||||||
|
Write-Warning "Remove or rename these to avoid launching outdated code."
|
||||||
|
}
|
||||||
|
|
||||||
# 6. Add launcher directory to User's PATH if needed
|
# 6. Add launcher directory to User's PATH if needed
|
||||||
Write-Info "Checking if '$LauncherDir' is in your PATH..."
|
Write-Info "Checking if '$LauncherDir' is in your PATH..."
|
||||||
$UserPath = [System.Environment]::GetEnvironmentVariable("Path", "User")
|
$UserPath = [System.Environment]::GetEnvironmentVariable("Path", "User")
|
||||||
|
@@ -21,6 +21,32 @@ print_info() { echo -e "\033[1;34m[INFO]\033[0m $1"; }
|
|||||||
print_success() { echo -e "\033[1;32m[SUCCESS]\033[0m $1"; }
|
print_success() { echo -e "\033[1;32m[SUCCESS]\033[0m $1"; }
|
||||||
print_warning() { echo -e "\033[1;33m[WARNING]\033[0m $1"; }
|
print_warning() { echo -e "\033[1;33m[WARNING]\033[0m $1"; }
|
||||||
print_error() { echo -e "\033[1;31m[ERROR]\033[0m $1" >&2; exit 1; }
|
print_error() { echo -e "\033[1;31m[ERROR]\033[0m $1" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Install build dependencies for Gtk/GObject if available via the system package manager
|
||||||
|
install_dependencies() {
|
||||||
|
print_info "Installing system packages required for Gtk bindings..."
|
||||||
|
if command -v apt-get &>/dev/null; then
|
||||||
|
sudo apt-get update && sudo apt-get install -y \
|
||||||
|
build-essential pkg-config libcairo2 libcairo2-dev \
|
||||||
|
libgirepository1.0-dev gobject-introspection \
|
||||||
|
gir1.2-gtk-3.0 python3-dev libffi-dev libssl-dev xclip
|
||||||
|
elif command -v yum &>/dev/null; then
|
||||||
|
sudo yum install -y @'Development Tools' cairo cairo-devel \
|
||||||
|
gobject-introspection-devel gtk3-devel python3-devel \
|
||||||
|
libffi-devel openssl-devel xclip
|
||||||
|
elif command -v dnf &>/dev/null; then
|
||||||
|
sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y \
|
||||||
|
cairo cairo-devel gobject-introspection-devel gtk3-devel \
|
||||||
|
python3-devel libffi-devel openssl-devel xclip
|
||||||
|
elif command -v pacman &>/dev/null; then
|
||||||
|
sudo pacman -Syu --noconfirm base-devel pkgconf cairo \
|
||||||
|
gobject-introspection gtk3 python xclip
|
||||||
|
elif command -v brew &>/dev/null; then
|
||||||
|
brew install pkg-config cairo gobject-introspection gtk+3
|
||||||
|
else
|
||||||
|
print_warning "Unsupported package manager. Please install Gtk/GObject dependencies manually."
|
||||||
|
fi
|
||||||
|
}
|
||||||
usage() {
|
usage() {
|
||||||
echo "Usage: $0 [-b | --branch <branch_name>] [-h | --help]"
|
echo "Usage: $0 [-b | --branch <branch_name>] [-h | --help]"
|
||||||
echo " -b, --branch Specify the git branch to install (default: main)"
|
echo " -b, --branch Specify the git branch to install (default: main)"
|
||||||
@@ -84,15 +110,12 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Install OS-specific dependencies
|
# 3. Install OS-specific dependencies
|
||||||
print_info "Checking for build dependencies..."
|
print_info "Checking for Gtk development libraries..."
|
||||||
if [ "$OS_NAME" = "Linux" ]; then
|
if ! python3 -c "import gi" &>/dev/null; then
|
||||||
if command -v apt-get &> /dev/null; then sudo apt-get update && sudo apt-get install -y build-essential pkg-config xclip;
|
print_warning "Gtk introspection bindings not found. Installing dependencies..."
|
||||||
elif command -v dnf &> /dev/null; then sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y pkg-config xclip;
|
install_dependencies
|
||||||
elif command -v pacman &> /dev/null; then sudo pacman -Syu --noconfirm base-devel pkg-config xclip;
|
else
|
||||||
else print_warning "Could not detect package manager. Ensure build tools and pkg-config are installed."; fi
|
print_info "Gtk bindings already available."
|
||||||
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
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 4. Clone or update the repository
|
# 4. Clone or update the repository
|
||||||
@@ -120,6 +143,14 @@ main() {
|
|||||||
pip install --upgrade pip
|
pip install --upgrade pip
|
||||||
pip install -r src/requirements.txt
|
pip install -r src/requirements.txt
|
||||||
pip install -e .
|
pip install -e .
|
||||||
|
print_info "Installing platform-specific Toga backend..."
|
||||||
|
if [ "$OS_NAME" = "Linux" ]; then
|
||||||
|
print_info "Installing toga-gtk for Linux..."
|
||||||
|
pip install toga-gtk
|
||||||
|
elif [ "$OS_NAME" = "Darwin" ]; then
|
||||||
|
print_info "Installing toga-cocoa for macOS..."
|
||||||
|
pip install toga-cocoa
|
||||||
|
fi
|
||||||
deactivate
|
deactivate
|
||||||
|
|
||||||
# 7. Create launcher script
|
# 7. Create launcher script
|
||||||
@@ -138,6 +169,23 @@ EOF2
|
|||||||
print_warning "Ensure '$LAUNCHER_DIR' comes first in your PATH or remove the old installation."
|
print_warning "Ensure '$LAUNCHER_DIR' comes first in your PATH or remove the old installation."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Detect any additional seedpass executables on PATH that are not our launcher
|
||||||
|
IFS=':' read -ra _sp_paths <<< "$PATH"
|
||||||
|
stale_cmds=()
|
||||||
|
for _dir in "${_sp_paths[@]}"; do
|
||||||
|
_candidate="$_dir/seedpass"
|
||||||
|
if [ -x "$_candidate" ] && [ "$_candidate" != "$LAUNCHER_PATH" ]; then
|
||||||
|
stale_cmds+=("$_candidate")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ ${#stale_cmds[@]} -gt 0 ]; then
|
||||||
|
print_warning "Stale 'seedpass' executables detected:"
|
||||||
|
for cmd in "${stale_cmds[@]}"; do
|
||||||
|
print_warning " - $cmd"
|
||||||
|
done
|
||||||
|
print_warning "Remove or rename these to avoid launching outdated code."
|
||||||
|
fi
|
||||||
|
|
||||||
# 8. Final instructions
|
# 8. Final instructions
|
||||||
print_success "Installation/update complete!"
|
print_success "Installation/update complete!"
|
||||||
print_info "You can now launch the interactive TUI by typing: seedpass"
|
print_info "You can now launch the interactive TUI by typing: seedpass"
|
||||||
|
32
scripts/run_gui_tests.sh
Executable file
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:
|
def main() -> None:
|
||||||
"""Calculate checksum for the main script and write it to SCRIPT_CHECKSUM_FILE."""
|
"""Calculate checksum for the main script and write it to SCRIPT_CHECKSUM_FILE."""
|
||||||
initialize_app()
|
initialize_app()
|
||||||
script_path = SRC_DIR / "password_manager" / "manager.py"
|
script_path = SRC_DIR / "seedpass/core" / "manager.py"
|
||||||
if not update_checksum_file(str(script_path), str(SCRIPT_CHECKSUM_FILE)):
|
if not update_checksum_file(str(script_path), str(SCRIPT_CHECKSUM_FILE)):
|
||||||
raise SystemExit(f"Failed to update checksum for {script_path}")
|
raise SystemExit(f"Failed to update checksum for {script_path}")
|
||||||
print(f"Updated checksum written to {SCRIPT_CHECKSUM_FILE}")
|
print(f"Updated checksum written to {SCRIPT_CHECKSUM_FILE}")
|
||||||
|
@@ -9,9 +9,11 @@ logger = logging.getLogger(__name__)
|
|||||||
# -----------------------------------
|
# -----------------------------------
|
||||||
# Nostr Relay Connection Settings
|
# Nostr Relay Connection Settings
|
||||||
# -----------------------------------
|
# -----------------------------------
|
||||||
# Retry fewer times with a shorter wait by default
|
# Retry fewer times with a shorter wait by default. These values
|
||||||
MAX_RETRIES = 2 # Maximum number of retries for relay connections
|
# act as defaults that can be overridden via ``ConfigManager``
|
||||||
RETRY_DELAY = 1 # Seconds to wait before retrying a failed connection
|
# entries ``nostr_max_retries`` and ``nostr_retry_delay``.
|
||||||
|
MAX_RETRIES = 2 # Default maximum number of retry attempts
|
||||||
|
RETRY_DELAY = 1 # Default seconds to wait before retrying
|
||||||
MIN_HEALTHY_RELAYS = 2 # Minimum relays that should return data on startup
|
MIN_HEALTHY_RELAYS = 2 # Minimum relays that should return data on startup
|
||||||
|
|
||||||
# -----------------------------------
|
# -----------------------------------
|
||||||
@@ -48,6 +50,9 @@ DEFAULT_PASSWORD_LENGTH = 16 # Default length for generated passwords
|
|||||||
MIN_PASSWORD_LENGTH = 8 # Minimum allowed password length
|
MIN_PASSWORD_LENGTH = 8 # Minimum allowed password length
|
||||||
MAX_PASSWORD_LENGTH = 128 # Maximum allowed password length
|
MAX_PASSWORD_LENGTH = 128 # Maximum allowed password length
|
||||||
|
|
||||||
|
# Characters considered safe for passwords when limiting punctuation
|
||||||
|
SAFE_SPECIAL_CHARS = "!@#$%^*-_+=?"
|
||||||
|
|
||||||
# Timeout in seconds before the vault locks due to inactivity
|
# Timeout in seconds before the vault locks due to inactivity
|
||||||
INACTIVITY_TIMEOUT = 15 * 60 # 15 minutes
|
INACTIVITY_TIMEOUT = 15 * 60 # 15 minutes
|
||||||
|
|
||||||
|
91
src/main.py
91
src/main.py
@@ -20,9 +20,9 @@ from termcolor import colored
|
|||||||
from utils.color_scheme import color_text
|
from utils.color_scheme import color_text
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
from password_manager.manager import PasswordManager
|
from seedpass.core.manager import PasswordManager
|
||||||
from nostr.client import NostrClient
|
from nostr.client import NostrClient
|
||||||
from password_manager.entry_types import EntryType
|
from seedpass.core.entry_types import EntryType
|
||||||
from constants import INACTIVITY_TIMEOUT, initialize_app
|
from constants import INACTIVITY_TIMEOUT, initialize_app
|
||||||
from utils.password_prompt import PasswordPromptError
|
from utils.password_prompt import PasswordPromptError
|
||||||
from utils import (
|
from utils import (
|
||||||
@@ -275,12 +275,24 @@ def handle_display_npub(password_manager: PasswordManager):
|
|||||||
def _display_live_stats(
|
def _display_live_stats(
|
||||||
password_manager: PasswordManager, interval: float = 1.0
|
password_manager: PasswordManager, interval: float = 1.0
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Continuously refresh stats until the user presses Enter."""
|
"""Continuously refresh stats until the user presses Enter.
|
||||||
|
|
||||||
|
Each refresh also triggers a background sync so the latest stats are
|
||||||
|
displayed if newer data exists on Nostr.
|
||||||
|
"""
|
||||||
|
|
||||||
|
stats_mgr = getattr(password_manager, "stats_manager", None)
|
||||||
display_fn = getattr(password_manager, "display_stats", None)
|
display_fn = getattr(password_manager, "display_stats", None)
|
||||||
|
sync_fn = getattr(password_manager, "start_background_sync", None)
|
||||||
if not callable(display_fn):
|
if not callable(display_fn):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if callable(sync_fn):
|
||||||
|
try:
|
||||||
|
sync_fn()
|
||||||
|
except Exception as exc: # pragma: no cover - sync best effort
|
||||||
|
logging.debug("Background sync failed during stats display: %s", exc)
|
||||||
|
|
||||||
if not sys.stdin or not sys.stdin.isatty():
|
if not sys.stdin or not sys.stdin.isatty():
|
||||||
clear_screen()
|
clear_screen()
|
||||||
display_fn()
|
display_fn()
|
||||||
@@ -289,9 +301,16 @@ def _display_live_stats(
|
|||||||
print(note)
|
print(note)
|
||||||
print(colored("Press Enter to continue.", "cyan"))
|
print(colored("Press Enter to continue.", "cyan"))
|
||||||
pause()
|
pause()
|
||||||
|
if stats_mgr is not None:
|
||||||
|
stats_mgr.reset()
|
||||||
return
|
return
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
|
if callable(sync_fn):
|
||||||
|
try:
|
||||||
|
sync_fn()
|
||||||
|
except Exception: # pragma: no cover - sync best effort
|
||||||
|
logging.debug("Background sync failed during stats display")
|
||||||
clear_screen()
|
clear_screen()
|
||||||
display_fn()
|
display_fn()
|
||||||
note = get_notification_text(password_manager)
|
note = get_notification_text(password_manager)
|
||||||
@@ -308,6 +327,8 @@ def _display_live_stats(
|
|||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print()
|
print()
|
||||||
break
|
break
|
||||||
|
if stats_mgr is not None:
|
||||||
|
stats_mgr.reset()
|
||||||
|
|
||||||
|
|
||||||
def handle_display_stats(password_manager: PasswordManager) -> None:
|
def handle_display_stats(password_manager: PasswordManager) -> None:
|
||||||
@@ -321,31 +342,28 @@ def handle_display_stats(password_manager: PasswordManager) -> None:
|
|||||||
|
|
||||||
def print_matches(
|
def print_matches(
|
||||||
password_manager: PasswordManager,
|
password_manager: PasswordManager,
|
||||||
matches: list[tuple[int, str, str | None, str | None, bool]],
|
matches: list[tuple[int, str, str | None, str | None, bool, EntryType]],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Print a list of search matches."""
|
"""Print a list of search matches."""
|
||||||
print(colored("\n[+] Matches:\n", "green"))
|
print(colored("\n[+] Matches:\n", "green"))
|
||||||
for entry in matches:
|
for entry in matches:
|
||||||
idx, website, username, url, blacklisted = entry
|
idx, website, username, url, blacklisted, etype = entry
|
||||||
data = password_manager.entry_manager.retrieve_entry(idx)
|
data = password_manager.entry_manager.retrieve_entry(idx)
|
||||||
etype = (
|
|
||||||
data.get("type", data.get("kind", EntryType.PASSWORD.value))
|
|
||||||
if data
|
|
||||||
else EntryType.PASSWORD.value
|
|
||||||
)
|
|
||||||
print(color_text(f"Index: {idx}", "index"))
|
print(color_text(f"Index: {idx}", "index"))
|
||||||
if etype == EntryType.TOTP.value:
|
if etype == EntryType.TOTP:
|
||||||
print(color_text(f" Label: {data.get('label', website)}", "index"))
|
label = data.get("label", website) if data else website
|
||||||
print(color_text(f" Derivation Index: {data.get('index', idx)}", "index"))
|
deriv = data.get("index", idx) if data else idx
|
||||||
elif etype == EntryType.SEED.value:
|
print(color_text(f" Label: {label}", "index"))
|
||||||
|
print(color_text(f" Derivation Index: {deriv}", "index"))
|
||||||
|
elif etype == EntryType.SEED:
|
||||||
print(color_text(" Type: Seed Phrase", "index"))
|
print(color_text(" Type: Seed Phrase", "index"))
|
||||||
elif etype == EntryType.SSH.value:
|
elif etype == EntryType.SSH:
|
||||||
print(color_text(" Type: SSH Key", "index"))
|
print(color_text(" Type: SSH Key", "index"))
|
||||||
elif etype == EntryType.PGP.value:
|
elif etype == EntryType.PGP:
|
||||||
print(color_text(" Type: PGP Key", "index"))
|
print(color_text(" Type: PGP Key", "index"))
|
||||||
elif etype == EntryType.NOSTR.value:
|
elif etype == EntryType.NOSTR:
|
||||||
print(color_text(" Type: Nostr Key", "index"))
|
print(color_text(" Type: Nostr Key", "index"))
|
||||||
elif etype == EntryType.KEY_VALUE.value:
|
elif etype == EntryType.KEY_VALUE:
|
||||||
print(color_text(" Type: Key/Value", "index"))
|
print(color_text(" Type: Key/Value", "index"))
|
||||||
else:
|
else:
|
||||||
if website:
|
if website:
|
||||||
@@ -390,6 +408,7 @@ def handle_retrieve_from_nostr(password_manager: PasswordManager):
|
|||||||
Handles the action of retrieving the encrypted password index from Nostr.
|
Handles the action of retrieving the encrypted password index from Nostr.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
password_manager.nostr_client.fingerprint = password_manager.current_fingerprint
|
||||||
result = asyncio.run(password_manager.nostr_client.fetch_latest_snapshot())
|
result = asyncio.run(password_manager.nostr_client.fetch_latest_snapshot())
|
||||||
if result:
|
if result:
|
||||||
manifest, chunks = result
|
manifest, chunks = result
|
||||||
@@ -407,8 +426,12 @@ def handle_retrieve_from_nostr(password_manager: PasswordManager):
|
|||||||
print(colored("Encrypted index retrieved and saved successfully.", "green"))
|
print(colored("Encrypted index retrieved and saved successfully.", "green"))
|
||||||
logging.info("Encrypted index retrieved and saved successfully from Nostr.")
|
logging.info("Encrypted index retrieved and saved successfully from Nostr.")
|
||||||
else:
|
else:
|
||||||
print(colored("Failed to retrieve data from Nostr.", "red"))
|
msg = (
|
||||||
logging.error("Failed to retrieve data from Nostr.")
|
f"No Nostr events found for fingerprint"
|
||||||
|
f" {password_manager.current_fingerprint}."
|
||||||
|
)
|
||||||
|
print(colored(msg, "red"))
|
||||||
|
logging.error(msg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to retrieve from Nostr: {e}", exc_info=True)
|
logging.error(f"Failed to retrieve from Nostr: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to retrieve from Nostr: {e}", "red"))
|
print(colored(f"Error: Failed to retrieve from Nostr: {e}", "red"))
|
||||||
@@ -433,10 +456,21 @@ def handle_view_relays(cfg_mgr: "ConfigManager") -> None:
|
|||||||
print(colored(f"Error: {e}", "red"))
|
print(colored(f"Error: {e}", "red"))
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_close_client_pool(pm: PasswordManager) -> None:
|
||||||
|
"""Close the Nostr client pool if the client exists."""
|
||||||
|
client = getattr(pm, "nostr_client", None)
|
||||||
|
if client is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
client.close_client_pool()
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error(f"Error during NostrClient shutdown: {exc}")
|
||||||
|
|
||||||
|
|
||||||
def _reload_relays(password_manager: PasswordManager, relays: list) -> None:
|
def _reload_relays(password_manager: PasswordManager, relays: list) -> None:
|
||||||
"""Reload NostrClient with the updated relay list."""
|
"""Reload NostrClient with the updated relay list."""
|
||||||
try:
|
try:
|
||||||
password_manager.nostr_client.close_client_pool()
|
_safe_close_client_pool(password_manager)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logging.warning(f"Failed to close client pool: {exc}")
|
logging.warning(f"Failed to close client pool: {exc}")
|
||||||
try:
|
try:
|
||||||
@@ -1024,7 +1058,8 @@ def display_menu(
|
|||||||
continue
|
continue
|
||||||
logging.info("Exiting the program.")
|
logging.info("Exiting the program.")
|
||||||
print(colored("Exiting the program.", "green"))
|
print(colored("Exiting the program.", "green"))
|
||||||
password_manager.nostr_client.close_client_pool()
|
getattr(password_manager, "cleanup", lambda: None)()
|
||||||
|
_safe_close_client_pool(password_manager)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
if choice == "1":
|
if choice == "1":
|
||||||
while True:
|
while True:
|
||||||
@@ -1227,7 +1262,8 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
|
|||||||
print(colored("\nReceived shutdown signal. Exiting gracefully...", "yellow"))
|
print(colored("\nReceived shutdown signal. Exiting gracefully...", "yellow"))
|
||||||
logging.info(f"Received shutdown signal: {sig}. Initiating graceful shutdown.")
|
logging.info(f"Received shutdown signal: {sig}. Initiating graceful shutdown.")
|
||||||
try:
|
try:
|
||||||
password_manager.nostr_client.close_client_pool()
|
getattr(password_manager, "cleanup", lambda: None)()
|
||||||
|
_safe_close_client_pool(password_manager)
|
||||||
logging.info("NostrClient closed successfully.")
|
logging.info("NostrClient closed successfully.")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logging.error(f"Error during shutdown: {exc}")
|
logging.error(f"Error during shutdown: {exc}")
|
||||||
@@ -1245,7 +1281,8 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
|
|||||||
logger.info("Program terminated by user via KeyboardInterrupt.")
|
logger.info("Program terminated by user via KeyboardInterrupt.")
|
||||||
print(colored("\nProgram terminated by user.", "yellow"))
|
print(colored("\nProgram terminated by user.", "yellow"))
|
||||||
try:
|
try:
|
||||||
password_manager.nostr_client.close_client_pool()
|
getattr(password_manager, "cleanup", lambda: None)()
|
||||||
|
_safe_close_client_pool(password_manager)
|
||||||
logging.info("NostrClient closed successfully.")
|
logging.info("NostrClient closed successfully.")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logging.error(f"Error during shutdown: {exc}")
|
logging.error(f"Error during shutdown: {exc}")
|
||||||
@@ -1255,7 +1292,8 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
|
|||||||
logger.error(f"A user-related error occurred: {e}", exc_info=True)
|
logger.error(f"A user-related error occurred: {e}", exc_info=True)
|
||||||
print(colored(f"Error: {e}", "red"))
|
print(colored(f"Error: {e}", "red"))
|
||||||
try:
|
try:
|
||||||
password_manager.nostr_client.close_client_pool()
|
getattr(password_manager, "cleanup", lambda: None)()
|
||||||
|
_safe_close_client_pool(password_manager)
|
||||||
logging.info("NostrClient closed successfully.")
|
logging.info("NostrClient closed successfully.")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logging.error(f"Error during shutdown: {exc}")
|
logging.error(f"Error during shutdown: {exc}")
|
||||||
@@ -1265,7 +1303,8 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
|
|||||||
logger.error(f"An unexpected error occurred: {e}", exc_info=True)
|
logger.error(f"An unexpected error occurred: {e}", exc_info=True)
|
||||||
print(colored(f"Error: An unexpected error occurred: {e}", "red"))
|
print(colored(f"Error: An unexpected error occurred: {e}", "red"))
|
||||||
try:
|
try:
|
||||||
password_manager.nostr_client.close_client_pool()
|
getattr(password_manager, "cleanup", lambda: None)()
|
||||||
|
_safe_close_client_pool(password_manager)
|
||||||
logging.info("NostrClient closed successfully.")
|
logging.info("NostrClient closed successfully.")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logging.error(f"Error during shutdown: {exc}")
|
logging.error(f"Error during shutdown: {exc}")
|
||||||
|
@@ -8,6 +8,7 @@ from typing import List, Optional, Tuple, TYPE_CHECKING
|
|||||||
import hashlib
|
import hashlib
|
||||||
import asyncio
|
import asyncio
|
||||||
import gzip
|
import gzip
|
||||||
|
import threading
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
# Imports from the nostr-sdk library
|
# Imports from the nostr-sdk library
|
||||||
@@ -20,18 +21,19 @@ from nostr_sdk import (
|
|||||||
Kind,
|
Kind,
|
||||||
KindStandard,
|
KindStandard,
|
||||||
Tag,
|
Tag,
|
||||||
|
RelayUrl,
|
||||||
)
|
)
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from nostr_sdk import EventId, Timestamp
|
from nostr_sdk import EventId, Timestamp
|
||||||
|
|
||||||
from .key_manager import KeyManager as SeedPassKeyManager
|
from .key_manager import KeyManager as SeedPassKeyManager
|
||||||
from .backup_models import Manifest, ChunkMeta, KIND_MANIFEST, KIND_SNAPSHOT_CHUNK
|
from .backup_models import Manifest, ChunkMeta, KIND_MANIFEST, KIND_SNAPSHOT_CHUNK
|
||||||
from password_manager.encryption import EncryptionManager
|
from seedpass.core.encryption import EncryptionManager
|
||||||
from constants import MAX_RETRIES, RETRY_DELAY
|
from constants import MAX_RETRIES, RETRY_DELAY
|
||||||
from utils.file_lock import exclusive_lock
|
from utils.file_lock import exclusive_lock
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover - imported for type hints
|
if TYPE_CHECKING: # pragma: no cover - imported for type hints
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
|
|
||||||
# Backwards compatibility for tests that patch these symbols
|
# Backwards compatibility for tests that patch these symbols
|
||||||
KeyManager = SeedPassKeyManager
|
KeyManager = SeedPassKeyManager
|
||||||
@@ -46,6 +48,9 @@ DEFAULT_RELAYS = [
|
|||||||
"wss://relay.primal.net",
|
"wss://relay.primal.net",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Identifier prefix for replaceable manifest events
|
||||||
|
MANIFEST_ID_PREFIX = "seedpass-manifest-"
|
||||||
|
|
||||||
|
|
||||||
def prepare_snapshot(
|
def prepare_snapshot(
|
||||||
encrypted_bytes: bytes, limit: int
|
encrypted_bytes: bytes, limit: int
|
||||||
@@ -135,6 +140,7 @@ class NostrClient:
|
|||||||
self.last_error: Optional[str] = None
|
self.last_error: Optional[str] = None
|
||||||
|
|
||||||
self.delta_threshold = 100
|
self.delta_threshold = 100
|
||||||
|
self._state_lock = threading.Lock()
|
||||||
self.current_manifest: Manifest | None = None
|
self.current_manifest: Manifest | None = None
|
||||||
self.current_manifest_id: str | None = None
|
self.current_manifest_id: str | None = None
|
||||||
self._delta_events: list[str] = []
|
self._delta_events: list[str] = []
|
||||||
@@ -168,14 +174,26 @@ class NostrClient:
|
|||||||
async def _initialize_client_pool(self) -> None:
|
async def _initialize_client_pool(self) -> None:
|
||||||
if self.offline_mode or not self.relays:
|
if self.offline_mode or not self.relays:
|
||||||
return
|
return
|
||||||
if hasattr(self.client, "add_relays"):
|
|
||||||
await self.client.add_relays(self.relays)
|
formatted = []
|
||||||
else:
|
|
||||||
for relay in self.relays:
|
for relay in self.relays:
|
||||||
|
if isinstance(relay, str):
|
||||||
|
try:
|
||||||
|
formatted.append(RelayUrl.parse(relay))
|
||||||
|
except Exception:
|
||||||
|
logger.error("Invalid relay URL: %s", relay)
|
||||||
|
else:
|
||||||
|
formatted.append(relay)
|
||||||
|
|
||||||
|
if hasattr(self.client, "add_relays"):
|
||||||
|
await self.client.add_relays(formatted)
|
||||||
|
else:
|
||||||
|
for relay in formatted:
|
||||||
await self.client.add_relay(relay)
|
await self.client.add_relay(relay)
|
||||||
|
|
||||||
await self.client.connect()
|
await self.client.connect()
|
||||||
self._connected = True
|
self._connected = True
|
||||||
logger.info(f"NostrClient connected to relays: {self.relays}")
|
logger.info("NostrClient connected to relays: %s", formatted)
|
||||||
|
|
||||||
async def _ping_relay(self, relay: str, timeout: float) -> bool:
|
async def _ping_relay(self, relay: str, timeout: float) -> bool:
|
||||||
"""Attempt to retrieve the latest event from a single relay."""
|
"""Attempt to retrieve the latest event from a single relay."""
|
||||||
@@ -295,8 +313,8 @@ class NostrClient:
|
|||||||
|
|
||||||
if retries is None or delay is None:
|
if retries is None or delay is None:
|
||||||
if self.config_manager is None:
|
if self.config_manager is None:
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
from password_manager.vault import Vault
|
from seedpass.core.vault import Vault
|
||||||
|
|
||||||
cfg_mgr = ConfigManager(
|
cfg_mgr = ConfigManager(
|
||||||
Vault(self.encryption_manager, self.fingerprint_dir),
|
Vault(self.encryption_manager, self.fingerprint_dir),
|
||||||
@@ -310,8 +328,7 @@ class NostrClient:
|
|||||||
|
|
||||||
self.connect()
|
self.connect()
|
||||||
self.last_error = None
|
self.last_error = None
|
||||||
attempt = 0
|
for attempt in range(retries):
|
||||||
while True:
|
|
||||||
try:
|
try:
|
||||||
result = asyncio.run(self._retrieve_json_from_nostr())
|
result = asyncio.run(self._retrieve_json_from_nostr())
|
||||||
if result is not None:
|
if result is not None:
|
||||||
@@ -319,10 +336,9 @@ class NostrClient:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.last_error = str(e)
|
self.last_error = str(e)
|
||||||
logger.error("Failed to retrieve events from Nostr: %s", e)
|
logger.error("Failed to retrieve events from Nostr: %s", e)
|
||||||
if attempt >= retries:
|
if attempt < retries - 1:
|
||||||
break
|
sleep_time = delay * (2**attempt)
|
||||||
attempt += 1
|
time.sleep(sleep_time)
|
||||||
time.sleep(delay)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _retrieve_json_from_nostr(self) -> Optional[bytes]:
|
async def _retrieve_json_from_nostr(self) -> Optional[bytes]:
|
||||||
@@ -365,6 +381,7 @@ class NostrClient:
|
|||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
if self.offline_mode or not self.relays:
|
if self.offline_mode or not self.relays:
|
||||||
return Manifest(ver=1, algo="gzip", chunks=[]), ""
|
return Manifest(ver=1, algo="gzip", chunks=[]), ""
|
||||||
|
await self.ensure_manifest_is_current()
|
||||||
await self._connect_async()
|
await self._connect_async()
|
||||||
manifest, chunks = prepare_snapshot(encrypted_bytes, limit)
|
manifest, chunks = prepare_snapshot(encrypted_bytes, limit)
|
||||||
for meta, chunk in zip(manifest.chunks, chunks):
|
for meta, chunk in zip(manifest.chunks, chunks):
|
||||||
@@ -390,22 +407,24 @@ class NostrClient:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
manifest_identifier = f"{MANIFEST_ID_PREFIX}{self.fingerprint}"
|
||||||
manifest_event = (
|
manifest_event = (
|
||||||
EventBuilder(Kind(KIND_MANIFEST), manifest_json)
|
EventBuilder(Kind(KIND_MANIFEST), manifest_json)
|
||||||
|
.tags([Tag.identifier(manifest_identifier)])
|
||||||
.build(self.keys.public_key())
|
.build(self.keys.public_key())
|
||||||
.sign_with_keys(self.keys)
|
.sign_with_keys(self.keys)
|
||||||
)
|
)
|
||||||
result = await self.client.send_event(manifest_event)
|
await self.client.send_event(manifest_event)
|
||||||
manifest_id = result.id.to_hex() if hasattr(result, "id") else str(result)
|
with self._state_lock:
|
||||||
self.current_manifest = manifest
|
self.current_manifest = manifest
|
||||||
self.current_manifest_id = manifest_id
|
self.current_manifest_id = manifest_identifier
|
||||||
# Record when this snapshot was published for future delta events
|
# Record when this snapshot was published for future delta events
|
||||||
self.current_manifest.delta_since = int(time.time())
|
self.current_manifest.delta_since = int(time.time())
|
||||||
self._delta_events = []
|
self._delta_events = []
|
||||||
if getattr(self, "verbose_timing", False):
|
if getattr(self, "verbose_timing", False):
|
||||||
duration = time.perf_counter() - start
|
duration = time.perf_counter() - start
|
||||||
logger.info("publish_snapshot completed in %.2f seconds", duration)
|
logger.info("publish_snapshot completed in %.2f seconds", duration)
|
||||||
return manifest, manifest_id
|
return manifest, manifest_identifier
|
||||||
|
|
||||||
async def _fetch_chunks_with_retry(
|
async def _fetch_chunks_with_retry(
|
||||||
self, manifest_event
|
self, manifest_event
|
||||||
@@ -430,11 +449,24 @@ class NostrClient:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if self.config_manager is None:
|
||||||
|
from seedpass.core.config_manager import ConfigManager
|
||||||
|
from seedpass.core.vault import Vault
|
||||||
|
|
||||||
|
cfg_mgr = ConfigManager(
|
||||||
|
Vault(self.encryption_manager, self.fingerprint_dir),
|
||||||
|
self.fingerprint_dir,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cfg_mgr = self.config_manager
|
||||||
|
cfg = cfg_mgr.load_config(require_pin=False)
|
||||||
|
max_retries = int(cfg.get("nostr_max_retries", MAX_RETRIES))
|
||||||
|
delay = float(cfg.get("nostr_retry_delay", RETRY_DELAY))
|
||||||
|
|
||||||
chunks: list[bytes] = []
|
chunks: list[bytes] = []
|
||||||
for meta in manifest.chunks:
|
for meta in manifest.chunks:
|
||||||
attempt = 0
|
|
||||||
chunk_bytes: bytes | None = None
|
chunk_bytes: bytes | None = None
|
||||||
while attempt < MAX_RETRIES:
|
for attempt in range(max_retries):
|
||||||
cf = Filter().author(pubkey).kind(Kind(KIND_SNAPSHOT_CHUNK))
|
cf = Filter().author(pubkey).kind(Kind(KIND_SNAPSHOT_CHUNK))
|
||||||
if meta.event_id:
|
if meta.event_id:
|
||||||
cf = cf.id(EventId.parse(meta.event_id))
|
cf = cf.id(EventId.parse(meta.event_id))
|
||||||
@@ -447,18 +479,33 @@ class NostrClient:
|
|||||||
if hashlib.sha256(candidate).hexdigest() == meta.hash:
|
if hashlib.sha256(candidate).hexdigest() == meta.hash:
|
||||||
chunk_bytes = candidate
|
chunk_bytes = candidate
|
||||||
break
|
break
|
||||||
attempt += 1
|
if attempt < max_retries - 1:
|
||||||
if attempt < MAX_RETRIES:
|
await asyncio.sleep(delay * (2**attempt))
|
||||||
await asyncio.sleep(RETRY_DELAY)
|
|
||||||
if chunk_bytes is None:
|
if chunk_bytes is None:
|
||||||
return None
|
return None
|
||||||
chunks.append(chunk_bytes)
|
chunks.append(chunk_bytes)
|
||||||
|
|
||||||
man_id = getattr(manifest_event, "id", None)
|
ident = None
|
||||||
if hasattr(man_id, "to_hex"):
|
try:
|
||||||
man_id = man_id.to_hex()
|
tags_obj = manifest_event.tags()
|
||||||
|
ident = tags_obj.identifier()
|
||||||
|
except Exception:
|
||||||
|
tags = getattr(manifest_event, "tags", None)
|
||||||
|
if callable(tags):
|
||||||
|
tags = tags()
|
||||||
|
if tags:
|
||||||
|
tag = tags[0]
|
||||||
|
if hasattr(tag, "as_vec"):
|
||||||
|
vec = tag.as_vec()
|
||||||
|
if vec and len(vec) >= 2:
|
||||||
|
ident = vec[1]
|
||||||
|
elif isinstance(tag, (list, tuple)) and len(tag) >= 2:
|
||||||
|
ident = tag[1]
|
||||||
|
elif isinstance(tag, str):
|
||||||
|
ident = tag
|
||||||
|
with self._state_lock:
|
||||||
self.current_manifest = manifest
|
self.current_manifest = manifest
|
||||||
self.current_manifest_id = man_id
|
self.current_manifest_id = ident
|
||||||
return manifest, chunks
|
return manifest, chunks
|
||||||
|
|
||||||
async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None:
|
async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None:
|
||||||
@@ -467,24 +514,76 @@ class NostrClient:
|
|||||||
return None
|
return None
|
||||||
await self._connect_async()
|
await self._connect_async()
|
||||||
|
|
||||||
|
self.last_error = None
|
||||||
pubkey = self.keys.public_key()
|
pubkey = self.keys.public_key()
|
||||||
f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).limit(3)
|
ident = f"{MANIFEST_ID_PREFIX}{self.fingerprint}"
|
||||||
|
f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).identifier(ident).limit(1)
|
||||||
timeout = timedelta(seconds=10)
|
timeout = timedelta(seconds=10)
|
||||||
|
try:
|
||||||
events = (await self.client.fetch_events(f, timeout)).to_vec()
|
events = (await self.client.fetch_events(f, timeout)).to_vec()
|
||||||
|
except Exception as e: # pragma: no cover - network errors
|
||||||
|
self.last_error = str(e)
|
||||||
|
logger.error(
|
||||||
|
"Failed to fetch manifest from relays %s: %s",
|
||||||
|
self.relays,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
if not events:
|
if not events:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
for manifest_event in events:
|
for manifest_event in events:
|
||||||
|
try:
|
||||||
result = await self._fetch_chunks_with_retry(manifest_event)
|
result = await self._fetch_chunks_with_retry(manifest_event)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
return result
|
return result
|
||||||
|
except Exception as e: # pragma: no cover - network errors
|
||||||
|
self.last_error = str(e)
|
||||||
|
logger.error(
|
||||||
|
"Error retrieving snapshot from relays %s: %s",
|
||||||
|
self.relays,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.last_error is None:
|
||||||
|
self.last_error = "Snapshot not found on relays"
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def ensure_manifest_is_current(self) -> None:
|
||||||
|
"""Verify the local manifest is up to date before publishing."""
|
||||||
|
if self.offline_mode or not self.relays:
|
||||||
|
return
|
||||||
|
await self._connect_async()
|
||||||
|
pubkey = self.keys.public_key()
|
||||||
|
ident = f"{MANIFEST_ID_PREFIX}{self.fingerprint}"
|
||||||
|
f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).identifier(ident).limit(1)
|
||||||
|
timeout = timedelta(seconds=10)
|
||||||
|
try:
|
||||||
|
events = (await self.client.fetch_events(f, timeout)).to_vec()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if not events:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = json.loads(events[0].content())
|
||||||
|
remote = data.get("delta_since")
|
||||||
|
if remote is not None:
|
||||||
|
remote = int(remote)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
with self._state_lock:
|
||||||
|
local = self.current_manifest.delta_since if self.current_manifest else None
|
||||||
|
if remote is not None and (local is None or remote > local):
|
||||||
|
self.last_error = "Manifest out of date"
|
||||||
|
raise RuntimeError("Manifest out of date")
|
||||||
|
|
||||||
async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str:
|
async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str:
|
||||||
"""Publish a delta event referencing a manifest."""
|
"""Publish a delta event referencing a manifest."""
|
||||||
if self.offline_mode or not self.relays:
|
if self.offline_mode or not self.relays:
|
||||||
return ""
|
return ""
|
||||||
|
await self.ensure_manifest_is_current()
|
||||||
await self._connect_async()
|
await self._connect_async()
|
||||||
|
|
||||||
content = base64.b64encode(delta_bytes).decode("utf-8")
|
content = base64.b64encode(delta_bytes).decode("utf-8")
|
||||||
@@ -498,13 +597,17 @@ class NostrClient:
|
|||||||
)
|
)
|
||||||
if hasattr(created_at, "secs"):
|
if hasattr(created_at, "secs"):
|
||||||
created_at = created_at.secs
|
created_at = created_at.secs
|
||||||
|
manifest_event = None
|
||||||
|
with self._state_lock:
|
||||||
if self.current_manifest is not None:
|
if self.current_manifest is not None:
|
||||||
self.current_manifest.delta_since = int(created_at)
|
self.current_manifest.delta_since = int(created_at)
|
||||||
manifest_json = json.dumps(
|
manifest_json = json.dumps(
|
||||||
{
|
{
|
||||||
"ver": self.current_manifest.ver,
|
"ver": self.current_manifest.ver,
|
||||||
"algo": self.current_manifest.algo,
|
"algo": self.current_manifest.algo,
|
||||||
"chunks": [meta.__dict__ for meta in self.current_manifest.chunks],
|
"chunks": [
|
||||||
|
meta.__dict__ for meta in self.current_manifest.chunks
|
||||||
|
],
|
||||||
"delta_since": self.current_manifest.delta_since,
|
"delta_since": self.current_manifest.delta_since,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -514,8 +617,9 @@ class NostrClient:
|
|||||||
.build(self.keys.public_key())
|
.build(self.keys.public_key())
|
||||||
.sign_with_keys(self.keys)
|
.sign_with_keys(self.keys)
|
||||||
)
|
)
|
||||||
await self.client.send_event(manifest_event)
|
|
||||||
self._delta_events.append(delta_id)
|
self._delta_events.append(delta_id)
|
||||||
|
if manifest_event is not None:
|
||||||
|
await self.client.send_event(manifest_event)
|
||||||
return delta_id
|
return delta_id
|
||||||
|
|
||||||
async def fetch_deltas_since(self, version: int) -> list[bytes]:
|
async def fetch_deltas_since(self, version: int) -> list[bytes]:
|
||||||
@@ -533,12 +637,16 @@ class NostrClient:
|
|||||||
)
|
)
|
||||||
timeout = timedelta(seconds=10)
|
timeout = timedelta(seconds=10)
|
||||||
events = (await self.client.fetch_events(f, timeout)).to_vec()
|
events = (await self.client.fetch_events(f, timeout)).to_vec()
|
||||||
|
events.sort(
|
||||||
|
key=lambda ev: getattr(ev, "created_at", getattr(ev, "timestamp", 0))
|
||||||
|
)
|
||||||
deltas: list[bytes] = []
|
deltas: list[bytes] = []
|
||||||
for ev in events:
|
for ev in events:
|
||||||
deltas.append(base64.b64decode(ev.content().encode("utf-8")))
|
deltas.append(base64.b64decode(ev.content().encode("utf-8")))
|
||||||
|
|
||||||
if self.current_manifest is not None:
|
manifest = self.get_current_manifest()
|
||||||
snap_size = sum(c.size for c in self.current_manifest.chunks)
|
if manifest is not None:
|
||||||
|
snap_size = sum(c.size for c in manifest.chunks)
|
||||||
if (
|
if (
|
||||||
len(deltas) >= self.delta_threshold
|
len(deltas) >= self.delta_threshold
|
||||||
or sum(len(d) for d in deltas) > snap_size
|
or sum(len(d) for d in deltas) > snap_size
|
||||||
@@ -557,6 +665,21 @@ class NostrClient:
|
|||||||
await self.client.send_event(exp_event)
|
await self.client.send_event(exp_event)
|
||||||
return deltas
|
return deltas
|
||||||
|
|
||||||
|
def get_current_manifest(self) -> Manifest | None:
|
||||||
|
"""Thread-safe access to ``current_manifest``."""
|
||||||
|
with self._state_lock:
|
||||||
|
return self.current_manifest
|
||||||
|
|
||||||
|
def get_current_manifest_id(self) -> str | None:
|
||||||
|
"""Thread-safe access to ``current_manifest_id``."""
|
||||||
|
with self._state_lock:
|
||||||
|
return self.current_manifest_id
|
||||||
|
|
||||||
|
def get_delta_events(self) -> list[str]:
|
||||||
|
"""Thread-safe snapshot of pending delta event IDs."""
|
||||||
|
with self._state_lock:
|
||||||
|
return list(self._delta_events)
|
||||||
|
|
||||||
def close_client_pool(self) -> None:
|
def close_client_pool(self) -> None:
|
||||||
"""Disconnects the client from all relays."""
|
"""Disconnects the client from all relays."""
|
||||||
try:
|
try:
|
||||||
|
@@ -11,7 +11,7 @@ pytest>=7.0
|
|||||||
pytest-cov
|
pytest-cov
|
||||||
pytest-xdist
|
pytest-xdist
|
||||||
portalocker>=2.8
|
portalocker>=2.8
|
||||||
nostr-sdk>=0.42.1
|
nostr-sdk>=0.43
|
||||||
websocket-client==1.7.0
|
websocket-client==1.7.0
|
||||||
|
|
||||||
websockets>=15.0.0
|
websockets>=15.0.0
|
||||||
@@ -25,10 +25,14 @@ freezegun
|
|||||||
pyperclip
|
pyperclip
|
||||||
qrcode>=8.2
|
qrcode>=8.2
|
||||||
typer>=0.12.3
|
typer>=0.12.3
|
||||||
fastapi>=0.116.0
|
fastapi>=0.116.1
|
||||||
uvicorn>=0.35.0
|
uvicorn>=0.35.0
|
||||||
|
starlette>=0.47.2
|
||||||
httpx>=0.28.1
|
httpx>=0.28.1
|
||||||
requests>=2.32
|
requests>=2.32
|
||||||
python-multipart
|
python-multipart
|
||||||
orjson
|
orjson
|
||||||
argon2-cffi
|
argon2-cffi
|
||||||
|
toga-core>=0.5.2
|
||||||
|
pillow
|
||||||
|
toga-dummy>=0.5.2 # for headless GUI tests
|
||||||
|
@@ -10,7 +10,7 @@ mnemonic
|
|||||||
aiohttp>=3.12.14
|
aiohttp>=3.12.14
|
||||||
bcrypt
|
bcrypt
|
||||||
portalocker>=2.8
|
portalocker>=2.8
|
||||||
nostr-sdk>=0.42.1
|
nostr-sdk>=0.43
|
||||||
websocket-client==1.7.0
|
websocket-client==1.7.0
|
||||||
|
|
||||||
websockets>=15.0.0
|
websockets>=15.0.0
|
||||||
@@ -27,3 +27,4 @@ requests>=2.32
|
|||||||
python-multipart
|
python-multipart
|
||||||
orjson
|
orjson
|
||||||
argon2-cffi
|
argon2-cffi
|
||||||
|
toga-core>=0.5.2
|
||||||
|
@@ -14,8 +14,9 @@ import asyncio
|
|||||||
import sys
|
import sys
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from password_manager.manager import PasswordManager
|
from seedpass.core.manager import PasswordManager
|
||||||
from password_manager.entry_types import EntryType
|
from seedpass.core.entry_types import EntryType
|
||||||
|
from seedpass.core.api import UtilityService
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
@@ -85,8 +86,9 @@ def search_entry(query: str, authorization: str | None = Header(None)) -> List[A
|
|||||||
"username": username,
|
"username": username,
|
||||||
"url": url,
|
"url": url,
|
||||||
"archived": archived,
|
"archived": archived,
|
||||||
|
"type": etype.value,
|
||||||
}
|
}
|
||||||
for idx, label, username, url, archived in results
|
for idx, label, username, url, archived, etype in results
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -117,11 +119,23 @@ def create_entry(
|
|||||||
etype = (entry.get("type") or entry.get("kind") or "password").lower()
|
etype = (entry.get("type") or entry.get("kind") or "password").lower()
|
||||||
|
|
||||||
if etype == "password":
|
if etype == "password":
|
||||||
|
policy_keys = [
|
||||||
|
"include_special_chars",
|
||||||
|
"allowed_special_chars",
|
||||||
|
"special_mode",
|
||||||
|
"exclude_ambiguous",
|
||||||
|
"min_uppercase",
|
||||||
|
"min_lowercase",
|
||||||
|
"min_digits",
|
||||||
|
"min_special",
|
||||||
|
]
|
||||||
|
kwargs = {k: entry.get(k) for k in policy_keys if entry.get(k) is not None}
|
||||||
index = _pm.entry_manager.add_entry(
|
index = _pm.entry_manager.add_entry(
|
||||||
entry.get("label"),
|
entry.get("label"),
|
||||||
int(entry.get("length", 12)),
|
int(entry.get("length", 12)),
|
||||||
entry.get("username"),
|
entry.get("username"),
|
||||||
entry.get("url"),
|
entry.get("url"),
|
||||||
|
**kwargs,
|
||||||
)
|
)
|
||||||
return {"id": index}
|
return {"id": index}
|
||||||
|
|
||||||
@@ -164,6 +178,7 @@ def create_entry(
|
|||||||
if etype == "nostr":
|
if etype == "nostr":
|
||||||
index = _pm.entry_manager.add_nostr_key(
|
index = _pm.entry_manager.add_nostr_key(
|
||||||
entry.get("label"),
|
entry.get("label"),
|
||||||
|
_pm.parent_seed,
|
||||||
index=entry.get("index"),
|
index=entry.get("index"),
|
||||||
notes=entry.get("notes", ""),
|
notes=entry.get("notes", ""),
|
||||||
archived=entry.get("archived", False),
|
archived=entry.get("archived", False),
|
||||||
@@ -173,6 +188,7 @@ def create_entry(
|
|||||||
if etype == "key_value":
|
if etype == "key_value":
|
||||||
index = _pm.entry_manager.add_key_value(
|
index = _pm.entry_manager.add_key_value(
|
||||||
entry.get("label"),
|
entry.get("label"),
|
||||||
|
entry.get("key"),
|
||||||
entry.get("value"),
|
entry.get("value"),
|
||||||
notes=entry.get("notes", ""),
|
notes=entry.get("notes", ""),
|
||||||
)
|
)
|
||||||
@@ -217,6 +233,7 @@ def update_entry(
|
|||||||
label=entry.get("label"),
|
label=entry.get("label"),
|
||||||
period=entry.get("period"),
|
period=entry.get("period"),
|
||||||
digits=entry.get("digits"),
|
digits=entry.get("digits"),
|
||||||
|
key=entry.get("key"),
|
||||||
value=entry.get("value"),
|
value=entry.get("value"),
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -554,14 +571,40 @@ def backup_parent_seed(
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/change-password")
|
@app.post("/api/v1/change-password")
|
||||||
def change_password(authorization: str | None = Header(None)) -> dict[str, str]:
|
def change_password(
|
||||||
|
data: dict, authorization: str | None = Header(None)
|
||||||
|
) -> dict[str, str]:
|
||||||
"""Change the master password for the active profile."""
|
"""Change the master password for the active profile."""
|
||||||
_check_token(authorization)
|
_check_token(authorization)
|
||||||
assert _pm is not None
|
assert _pm is not None
|
||||||
_pm.change_password()
|
_pm.change_password(data.get("old", ""), data.get("new", ""))
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/password")
|
||||||
|
def generate_password(
|
||||||
|
data: dict, authorization: str | None = Header(None)
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Generate a password using optional policy overrides."""
|
||||||
|
_check_token(authorization)
|
||||||
|
assert _pm is not None
|
||||||
|
length = int(data.get("length", 12))
|
||||||
|
policy_keys = [
|
||||||
|
"include_special_chars",
|
||||||
|
"allowed_special_chars",
|
||||||
|
"special_mode",
|
||||||
|
"exclude_ambiguous",
|
||||||
|
"min_uppercase",
|
||||||
|
"min_lowercase",
|
||||||
|
"min_digits",
|
||||||
|
"min_special",
|
||||||
|
]
|
||||||
|
kwargs = {k: data.get(k) for k in policy_keys if data.get(k) is not None}
|
||||||
|
util = UtilityService(_pm)
|
||||||
|
password = util.generate_password(length, **kwargs)
|
||||||
|
return {"password": password}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/vault/lock")
|
@app.post("/api/v1/vault/lock")
|
||||||
def lock_vault(authorization: str | None = Header(None)) -> dict[str, str]:
|
def lock_vault(authorization: str | None = Header(None)) -> dict[str, str]:
|
||||||
"""Lock the vault and clear sensitive data from memory."""
|
"""Lock the vault and clear sensitive data from memory."""
|
||||||
|
@@ -1,15 +1,32 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
import sys
|
||||||
|
|
||||||
from password_manager.manager import PasswordManager
|
from seedpass.core.manager import PasswordManager
|
||||||
from password_manager.entry_types import EntryType
|
from seedpass.core.entry_types import EntryType
|
||||||
|
from seedpass.core.api import (
|
||||||
|
VaultService,
|
||||||
|
ProfileService,
|
||||||
|
SyncService,
|
||||||
|
EntryService,
|
||||||
|
ConfigService,
|
||||||
|
UtilityService,
|
||||||
|
NostrService,
|
||||||
|
ChangePasswordRequest,
|
||||||
|
UnlockRequest,
|
||||||
|
BackupParentSeedRequest,
|
||||||
|
ProfileSwitchRequest,
|
||||||
|
ProfileRemoveRequest,
|
||||||
|
)
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from . import api as api_module
|
from . import api as api_module
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
|
import importlib.util
|
||||||
|
import subprocess
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
help="SeedPass command line interface",
|
help="SeedPass command line interface",
|
||||||
@@ -52,6 +69,43 @@ def _get_pm(ctx: typer.Context) -> PasswordManager:
|
|||||||
return pm
|
return pm
|
||||||
|
|
||||||
|
|
||||||
|
def _get_services(
|
||||||
|
ctx: typer.Context,
|
||||||
|
) -> tuple[VaultService, ProfileService, SyncService]:
|
||||||
|
"""Return service layer instances for the current context."""
|
||||||
|
|
||||||
|
pm = _get_pm(ctx)
|
||||||
|
return VaultService(pm), ProfileService(pm), SyncService(pm)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_entry_service(ctx: typer.Context) -> EntryService:
|
||||||
|
pm = _get_pm(ctx)
|
||||||
|
return EntryService(pm)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_config_service(ctx: typer.Context) -> ConfigService:
|
||||||
|
pm = _get_pm(ctx)
|
||||||
|
return ConfigService(pm)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_util_service(ctx: typer.Context) -> UtilityService:
|
||||||
|
pm = _get_pm(ctx)
|
||||||
|
return UtilityService(pm)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_nostr_service(ctx: typer.Context) -> NostrService:
|
||||||
|
pm = _get_pm(ctx)
|
||||||
|
return NostrService(pm)
|
||||||
|
|
||||||
|
|
||||||
|
def _gui_backend_available() -> bool:
|
||||||
|
"""Return True if a platform-specific BeeWare backend is installed."""
|
||||||
|
for pkg in ("toga_gtk", "toga_winforms", "toga_cocoa"):
|
||||||
|
if importlib.util.find_spec(pkg) is not None:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@app.callback(invoke_without_command=True)
|
@app.callback(invoke_without_command=True)
|
||||||
def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None:
|
def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None:
|
||||||
"""SeedPass CLI entry point.
|
"""SeedPass CLI entry point.
|
||||||
@@ -68,14 +122,14 @@ def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) ->
|
|||||||
def entry_list(
|
def entry_list(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
sort: str = typer.Option(
|
sort: str = typer.Option(
|
||||||
"index", "--sort", help="Sort by 'index', 'label', or 'username'"
|
"index", "--sort", help="Sort by 'index', 'label', or 'updated'"
|
||||||
),
|
),
|
||||||
kind: Optional[str] = typer.Option(None, "--kind", help="Filter by entry type"),
|
kind: Optional[str] = typer.Option(None, "--kind", help="Filter by entry type"),
|
||||||
archived: bool = typer.Option(False, "--archived", help="Include archived"),
|
archived: bool = typer.Option(False, "--archived", help="Include archived"),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""List entries in the vault."""
|
"""List entries in the vault."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_entry_service(ctx)
|
||||||
entries = pm.entry_manager.list_entries(
|
entries = service.list_entries(
|
||||||
sort_by=sort, filter_kind=kind, include_archived=archived
|
sort_by=sort, filter_kind=kind, include_archived=archived
|
||||||
)
|
)
|
||||||
for idx, label, username, url, is_archived in entries:
|
for idx, label, username, url, is_archived in entries:
|
||||||
@@ -90,15 +144,25 @@ def entry_list(
|
|||||||
|
|
||||||
|
|
||||||
@entry_app.command("search")
|
@entry_app.command("search")
|
||||||
def entry_search(ctx: typer.Context, query: str) -> None:
|
def entry_search(
|
||||||
|
ctx: typer.Context,
|
||||||
|
query: str,
|
||||||
|
kind: List[str] = typer.Option(
|
||||||
|
None,
|
||||||
|
"--kind",
|
||||||
|
"-k",
|
||||||
|
help="Filter by entry kinds (can be repeated)",
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
"""Search entries."""
|
"""Search entries."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_entry_service(ctx)
|
||||||
results = pm.entry_manager.search_entries(query)
|
kinds = list(kind) if kind else None
|
||||||
|
results = service.search_entries(query, kinds=kinds)
|
||||||
if not results:
|
if not results:
|
||||||
typer.echo("No matching entries found")
|
typer.echo("No matching entries found")
|
||||||
return
|
return
|
||||||
for idx, label, username, url, _arch in results:
|
for idx, label, username, url, _arch, etype in results:
|
||||||
line = f"{idx}: {label}"
|
line = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}"
|
||||||
if username:
|
if username:
|
||||||
line += f" ({username})"
|
line += f" ({username})"
|
||||||
if url:
|
if url:
|
||||||
@@ -109,29 +173,29 @@ def entry_search(ctx: typer.Context, query: str) -> None:
|
|||||||
@entry_app.command("get")
|
@entry_app.command("get")
|
||||||
def entry_get(ctx: typer.Context, query: str) -> None:
|
def entry_get(ctx: typer.Context, query: str) -> None:
|
||||||
"""Retrieve a single entry's secret."""
|
"""Retrieve a single entry's secret."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_entry_service(ctx)
|
||||||
matches = pm.entry_manager.search_entries(query)
|
matches = service.search_entries(query)
|
||||||
if len(matches) == 0:
|
if len(matches) == 0:
|
||||||
typer.echo("No matching entries found")
|
typer.echo("No matching entries found")
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
if len(matches) > 1:
|
if len(matches) > 1:
|
||||||
typer.echo("Matches:")
|
typer.echo("Matches:")
|
||||||
for idx, label, username, _url, _arch in matches:
|
for idx, label, username, _url, _arch, etype in matches:
|
||||||
name = f"{idx}: {label}"
|
name = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}"
|
||||||
if username:
|
if username:
|
||||||
name += f" ({username})"
|
name += f" ({username})"
|
||||||
typer.echo(name)
|
typer.echo(name)
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
index = matches[0][0]
|
index = matches[0][0]
|
||||||
entry = pm.entry_manager.retrieve_entry(index)
|
entry = service.retrieve_entry(index)
|
||||||
etype = entry.get("type", entry.get("kind"))
|
etype = entry.get("type", entry.get("kind"))
|
||||||
if etype == EntryType.PASSWORD.value:
|
if etype == EntryType.PASSWORD.value:
|
||||||
length = int(entry.get("length", 12))
|
length = int(entry.get("length", 12))
|
||||||
password = pm.password_generator.generate_password(length, index)
|
password = service.generate_password(length, index)
|
||||||
typer.echo(password)
|
typer.echo(password)
|
||||||
elif etype == EntryType.TOTP.value:
|
elif etype == EntryType.TOTP.value:
|
||||||
code = pm.entry_manager.get_totp_code(index, pm.parent_seed)
|
code = service.get_totp_code(index)
|
||||||
typer.echo(code)
|
typer.echo(code)
|
||||||
else:
|
else:
|
||||||
typer.echo("Unsupported entry type")
|
typer.echo("Unsupported entry type")
|
||||||
@@ -145,12 +209,50 @@ def entry_add(
|
|||||||
length: int = typer.Option(12, "--length"),
|
length: int = typer.Option(12, "--length"),
|
||||||
username: Optional[str] = typer.Option(None, "--username"),
|
username: Optional[str] = typer.Option(None, "--username"),
|
||||||
url: Optional[str] = typer.Option(None, "--url"),
|
url: Optional[str] = typer.Option(None, "--url"),
|
||||||
|
no_special: bool = typer.Option(
|
||||||
|
False, "--no-special", help="Exclude special characters", is_flag=True
|
||||||
|
),
|
||||||
|
allowed_special_chars: Optional[str] = typer.Option(
|
||||||
|
None, "--allowed-special-chars", help="Explicit set of special characters"
|
||||||
|
),
|
||||||
|
special_mode: Optional[str] = typer.Option(
|
||||||
|
None,
|
||||||
|
"--special-mode",
|
||||||
|
help="Special character mode",
|
||||||
|
),
|
||||||
|
exclude_ambiguous: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--exclude-ambiguous",
|
||||||
|
help="Exclude ambiguous characters",
|
||||||
|
is_flag=True,
|
||||||
|
),
|
||||||
|
min_uppercase: Optional[int] = typer.Option(None, "--min-uppercase"),
|
||||||
|
min_lowercase: Optional[int] = typer.Option(None, "--min-lowercase"),
|
||||||
|
min_digits: Optional[int] = typer.Option(None, "--min-digits"),
|
||||||
|
min_special: Optional[int] = typer.Option(None, "--min-special"),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a new password entry and output its index."""
|
"""Add a new password entry and output its index."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_entry_service(ctx)
|
||||||
index = pm.entry_manager.add_entry(label, length, username, url)
|
kwargs = {}
|
||||||
|
if no_special:
|
||||||
|
kwargs["include_special_chars"] = False
|
||||||
|
if allowed_special_chars is not None:
|
||||||
|
kwargs["allowed_special_chars"] = allowed_special_chars
|
||||||
|
if special_mode is not None:
|
||||||
|
kwargs["special_mode"] = special_mode
|
||||||
|
if exclude_ambiguous:
|
||||||
|
kwargs["exclude_ambiguous"] = True
|
||||||
|
if min_uppercase is not None:
|
||||||
|
kwargs["min_uppercase"] = min_uppercase
|
||||||
|
if min_lowercase is not None:
|
||||||
|
kwargs["min_lowercase"] = min_lowercase
|
||||||
|
if min_digits is not None:
|
||||||
|
kwargs["min_digits"] = min_digits
|
||||||
|
if min_special is not None:
|
||||||
|
kwargs["min_special"] = min_special
|
||||||
|
|
||||||
|
index = service.add_entry(label, length, username, url, **kwargs)
|
||||||
typer.echo(str(index))
|
typer.echo(str(index))
|
||||||
pm.sync_vault()
|
|
||||||
|
|
||||||
|
|
||||||
@entry_app.command("add-totp")
|
@entry_app.command("add-totp")
|
||||||
@@ -163,17 +265,15 @@ def entry_add_totp(
|
|||||||
digits: int = typer.Option(6, "--digits", help="Number of TOTP digits"),
|
digits: int = typer.Option(6, "--digits", help="Number of TOTP digits"),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a TOTP entry and output the otpauth URI."""
|
"""Add a TOTP entry and output the otpauth URI."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_entry_service(ctx)
|
||||||
uri = pm.entry_manager.add_totp(
|
uri = service.add_totp(
|
||||||
label,
|
label,
|
||||||
pm.parent_seed,
|
|
||||||
index=index,
|
index=index,
|
||||||
secret=secret,
|
secret=secret,
|
||||||
period=period,
|
period=period,
|
||||||
digits=digits,
|
digits=digits,
|
||||||
)
|
)
|
||||||
typer.echo(uri)
|
typer.echo(uri)
|
||||||
pm.sync_vault()
|
|
||||||
|
|
||||||
|
|
||||||
@entry_app.command("add-ssh")
|
@entry_app.command("add-ssh")
|
||||||
@@ -184,15 +284,13 @@ def entry_add_ssh(
|
|||||||
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add an SSH key entry and output its index."""
|
"""Add an SSH key entry and output its index."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_entry_service(ctx)
|
||||||
idx = pm.entry_manager.add_ssh_key(
|
idx = service.add_ssh_key(
|
||||||
label,
|
label,
|
||||||
pm.parent_seed,
|
|
||||||
index=index,
|
index=index,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
)
|
)
|
||||||
typer.echo(str(idx))
|
typer.echo(str(idx))
|
||||||
pm.sync_vault()
|
|
||||||
|
|
||||||
|
|
||||||
@entry_app.command("add-pgp")
|
@entry_app.command("add-pgp")
|
||||||
@@ -205,17 +303,15 @@ def entry_add_pgp(
|
|||||||
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a PGP key entry and output its index."""
|
"""Add a PGP key entry and output its index."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_entry_service(ctx)
|
||||||
idx = pm.entry_manager.add_pgp_key(
|
idx = service.add_pgp_key(
|
||||||
label,
|
label,
|
||||||
pm.parent_seed,
|
|
||||||
index=index,
|
index=index,
|
||||||
key_type=key_type,
|
key_type=key_type,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
)
|
)
|
||||||
typer.echo(str(idx))
|
typer.echo(str(idx))
|
||||||
pm.sync_vault()
|
|
||||||
|
|
||||||
|
|
||||||
@entry_app.command("add-nostr")
|
@entry_app.command("add-nostr")
|
||||||
@@ -226,14 +322,13 @@ def entry_add_nostr(
|
|||||||
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a Nostr key entry and output its index."""
|
"""Add a Nostr key entry and output its index."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_entry_service(ctx)
|
||||||
idx = pm.entry_manager.add_nostr_key(
|
idx = service.add_nostr_key(
|
||||||
label,
|
label,
|
||||||
index=index,
|
index=index,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
)
|
)
|
||||||
typer.echo(str(idx))
|
typer.echo(str(idx))
|
||||||
pm.sync_vault()
|
|
||||||
|
|
||||||
|
|
||||||
@entry_app.command("add-seed")
|
@entry_app.command("add-seed")
|
||||||
@@ -245,30 +340,28 @@ def entry_add_seed(
|
|||||||
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a derived seed phrase entry and output its index."""
|
"""Add a derived seed phrase entry and output its index."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_entry_service(ctx)
|
||||||
idx = pm.entry_manager.add_seed(
|
idx = service.add_seed(
|
||||||
label,
|
label,
|
||||||
pm.parent_seed,
|
|
||||||
index=index,
|
index=index,
|
||||||
words_num=words,
|
words=words,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
)
|
)
|
||||||
typer.echo(str(idx))
|
typer.echo(str(idx))
|
||||||
pm.sync_vault()
|
|
||||||
|
|
||||||
|
|
||||||
@entry_app.command("add-key-value")
|
@entry_app.command("add-key-value")
|
||||||
def entry_add_key_value(
|
def entry_add_key_value(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
label: str,
|
label: str,
|
||||||
|
key: str = typer.Option(..., "--key", help="Key name"),
|
||||||
value: str = typer.Option(..., "--value", help="Stored value"),
|
value: str = typer.Option(..., "--value", help="Stored value"),
|
||||||
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a key/value entry and output its index."""
|
"""Add a key/value entry and output its index."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_entry_service(ctx)
|
||||||
idx = pm.entry_manager.add_key_value(label, value, notes=notes)
|
idx = service.add_key_value(label, key, value, notes=notes)
|
||||||
typer.echo(str(idx))
|
typer.echo(str(idx))
|
||||||
pm.sync_vault()
|
|
||||||
|
|
||||||
|
|
||||||
@entry_app.command("add-managed-account")
|
@entry_app.command("add-managed-account")
|
||||||
@@ -279,15 +372,13 @@ def entry_add_managed_account(
|
|||||||
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a managed account seed entry and output its index."""
|
"""Add a managed account seed entry and output its index."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_entry_service(ctx)
|
||||||
idx = pm.entry_manager.add_managed_account(
|
idx = service.add_managed_account(
|
||||||
label,
|
label,
|
||||||
pm.parent_seed,
|
|
||||||
index=index,
|
index=index,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
)
|
)
|
||||||
typer.echo(str(idx))
|
typer.echo(str(idx))
|
||||||
pm.sync_vault()
|
|
||||||
|
|
||||||
|
|
||||||
@entry_app.command("modify")
|
@entry_app.command("modify")
|
||||||
@@ -302,12 +393,13 @@ def entry_modify(
|
|||||||
None, "--period", help="TOTP period in seconds"
|
None, "--period", help="TOTP period in seconds"
|
||||||
),
|
),
|
||||||
digits: Optional[int] = typer.Option(None, "--digits", help="TOTP digits"),
|
digits: Optional[int] = typer.Option(None, "--digits", help="TOTP digits"),
|
||||||
|
key: Optional[str] = typer.Option(None, "--key", help="New key"),
|
||||||
value: Optional[str] = typer.Option(None, "--value", help="New value"),
|
value: Optional[str] = typer.Option(None, "--value", help="New value"),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Modify an existing entry."""
|
"""Modify an existing entry."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_entry_service(ctx)
|
||||||
try:
|
try:
|
||||||
pm.entry_manager.modify_entry(
|
service.modify_entry(
|
||||||
entry_id,
|
entry_id,
|
||||||
username=username,
|
username=username,
|
||||||
url=url,
|
url=url,
|
||||||
@@ -315,37 +407,36 @@ def entry_modify(
|
|||||||
label=label,
|
label=label,
|
||||||
period=period,
|
period=period,
|
||||||
digits=digits,
|
digits=digits,
|
||||||
|
key=key,
|
||||||
value=value,
|
value=value,
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
typer.echo(str(e))
|
typer.echo(str(e))
|
||||||
|
sys.stdout.flush()
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
pm.sync_vault()
|
|
||||||
|
|
||||||
|
|
||||||
@entry_app.command("archive")
|
@entry_app.command("archive")
|
||||||
def entry_archive(ctx: typer.Context, entry_id: int) -> None:
|
def entry_archive(ctx: typer.Context, entry_id: int) -> None:
|
||||||
"""Archive an entry."""
|
"""Archive an entry."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_entry_service(ctx)
|
||||||
pm.entry_manager.archive_entry(entry_id)
|
service.archive_entry(entry_id)
|
||||||
typer.echo(str(entry_id))
|
typer.echo(str(entry_id))
|
||||||
pm.sync_vault()
|
|
||||||
|
|
||||||
|
|
||||||
@entry_app.command("unarchive")
|
@entry_app.command("unarchive")
|
||||||
def entry_unarchive(ctx: typer.Context, entry_id: int) -> None:
|
def entry_unarchive(ctx: typer.Context, entry_id: int) -> None:
|
||||||
"""Restore an archived entry."""
|
"""Restore an archived entry."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_entry_service(ctx)
|
||||||
pm.entry_manager.restore_entry(entry_id)
|
service.restore_entry(entry_id)
|
||||||
typer.echo(str(entry_id))
|
typer.echo(str(entry_id))
|
||||||
pm.sync_vault()
|
|
||||||
|
|
||||||
|
|
||||||
@entry_app.command("totp-codes")
|
@entry_app.command("totp-codes")
|
||||||
def entry_totp_codes(ctx: typer.Context) -> None:
|
def entry_totp_codes(ctx: typer.Context) -> None:
|
||||||
"""Display all current TOTP codes."""
|
"""Display all current TOTP codes."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_entry_service(ctx)
|
||||||
pm.handle_display_totp_codes()
|
service.display_totp_codes()
|
||||||
|
|
||||||
|
|
||||||
@entry_app.command("export-totp")
|
@entry_app.command("export-totp")
|
||||||
@@ -353,8 +444,8 @@ def entry_export_totp(
|
|||||||
ctx: typer.Context, file: str = typer.Option(..., help="Output file")
|
ctx: typer.Context, file: str = typer.Option(..., help="Output file")
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Export all TOTP secrets to a JSON file."""
|
"""Export all TOTP secrets to a JSON file."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_entry_service(ctx)
|
||||||
data = pm.entry_manager.export_totp_entries(pm.parent_seed)
|
data = service.export_totp_entries()
|
||||||
Path(file).write_text(json.dumps(data, indent=2))
|
Path(file).write_text(json.dumps(data, indent=2))
|
||||||
typer.echo(str(file))
|
typer.echo(str(file))
|
||||||
|
|
||||||
@@ -363,9 +454,10 @@ def entry_export_totp(
|
|||||||
def vault_export(
|
def vault_export(
|
||||||
ctx: typer.Context, file: str = typer.Option(..., help="Output file")
|
ctx: typer.Context, file: str = typer.Option(..., help="Output file")
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Export the vault."""
|
"""Export the vault profile to an encrypted file."""
|
||||||
pm = _get_pm(ctx)
|
vault_service, _profile, _sync = _get_services(ctx)
|
||||||
pm.handle_export_database(Path(file))
|
data = vault_service.export_profile()
|
||||||
|
Path(file).write_bytes(data)
|
||||||
typer.echo(str(file))
|
typer.echo(str(file))
|
||||||
|
|
||||||
|
|
||||||
@@ -373,33 +465,63 @@ def vault_export(
|
|||||||
def vault_import(
|
def vault_import(
|
||||||
ctx: typer.Context, file: str = typer.Option(..., help="Input file")
|
ctx: typer.Context, file: str = typer.Option(..., help="Input file")
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Import a vault from an encrypted JSON file."""
|
"""Import a vault profile from an encrypted file."""
|
||||||
pm = _get_pm(ctx)
|
vault_service, _profile, _sync = _get_services(ctx)
|
||||||
pm.handle_import_database(Path(file))
|
data = Path(file).read_bytes()
|
||||||
pm.sync_vault()
|
vault_service.import_profile(data)
|
||||||
typer.echo(str(file))
|
typer.echo(str(file))
|
||||||
|
|
||||||
|
|
||||||
@vault_app.command("change-password")
|
@vault_app.command("change-password")
|
||||||
def vault_change_password(ctx: typer.Context) -> None:
|
def vault_change_password(ctx: typer.Context) -> None:
|
||||||
"""Change the master password used for encryption."""
|
"""Change the master password used for encryption."""
|
||||||
pm = _get_pm(ctx)
|
vault_service, _profile, _sync = _get_services(ctx)
|
||||||
pm.change_password()
|
old_pw = typer.prompt("Current password", hide_input=True)
|
||||||
|
new_pw = typer.prompt("New password", hide_input=True, confirmation_prompt=True)
|
||||||
|
try:
|
||||||
|
vault_service.change_password(
|
||||||
|
ChangePasswordRequest(old_password=old_pw, new_password=new_pw)
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - pass through errors
|
||||||
|
typer.echo(f"Error: {exc}")
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
typer.echo("Password updated")
|
||||||
|
|
||||||
|
|
||||||
|
@vault_app.command("unlock")
|
||||||
|
def vault_unlock(ctx: typer.Context) -> None:
|
||||||
|
"""Unlock the vault for the active profile."""
|
||||||
|
vault_service, _profile, _sync = _get_services(ctx)
|
||||||
|
password = typer.prompt("Master password", hide_input=True)
|
||||||
|
try:
|
||||||
|
resp = vault_service.unlock(UnlockRequest(password=password))
|
||||||
|
except Exception as exc: # pragma: no cover - pass through errors
|
||||||
|
typer.echo(f"Error: {exc}")
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
typer.echo(f"Unlocked in {resp.duration:.2f}s")
|
||||||
|
|
||||||
|
|
||||||
@vault_app.command("lock")
|
@vault_app.command("lock")
|
||||||
def vault_lock(ctx: typer.Context) -> None:
|
def vault_lock(ctx: typer.Context) -> None:
|
||||||
"""Lock the vault and clear sensitive data from memory."""
|
"""Lock the vault and clear sensitive data from memory."""
|
||||||
pm = _get_pm(ctx)
|
vault_service, _profile, _sync = _get_services(ctx)
|
||||||
pm.lock_vault()
|
vault_service.lock()
|
||||||
|
typer.echo("locked")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("lock")
|
||||||
|
def root_lock(ctx: typer.Context) -> None:
|
||||||
|
"""Lock the vault for the active profile."""
|
||||||
|
vault_service, _profile, _sync = _get_services(ctx)
|
||||||
|
vault_service.lock()
|
||||||
typer.echo("locked")
|
typer.echo("locked")
|
||||||
|
|
||||||
|
|
||||||
@vault_app.command("stats")
|
@vault_app.command("stats")
|
||||||
def vault_stats(ctx: typer.Context) -> None:
|
def vault_stats(ctx: typer.Context) -> None:
|
||||||
"""Display statistics about the current seed profile."""
|
"""Display statistics about the current seed profile."""
|
||||||
pm = _get_pm(ctx)
|
vault_service, _profile, _sync = _get_services(ctx)
|
||||||
stats = pm.get_profile_stats()
|
stats = vault_service.stats()
|
||||||
typer.echo(json.dumps(stats, indent=2))
|
typer.echo(json.dumps(stats, indent=2))
|
||||||
|
|
||||||
|
|
||||||
@@ -411,21 +533,24 @@ def vault_reveal_parent_seed(
|
|||||||
),
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Display the parent seed and optionally write an encrypted backup file."""
|
"""Display the parent seed and optionally write an encrypted backup file."""
|
||||||
pm = _get_pm(ctx)
|
vault_service, _profile, _sync = _get_services(ctx)
|
||||||
pm.handle_backup_reveal_parent_seed(Path(file) if file else None)
|
password = typer.prompt("Master password", hide_input=True)
|
||||||
|
vault_service.backup_parent_seed(
|
||||||
|
BackupParentSeedRequest(path=Path(file) if file else None, password=password)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostr_app.command("sync")
|
@nostr_app.command("sync")
|
||||||
def nostr_sync(ctx: typer.Context) -> None:
|
def nostr_sync(ctx: typer.Context) -> None:
|
||||||
"""Sync with configured Nostr relays."""
|
"""Sync with configured Nostr relays."""
|
||||||
pm = _get_pm(ctx)
|
_vault, _profile, sync_service = _get_services(ctx)
|
||||||
result = pm.sync_vault()
|
model = sync_service.sync()
|
||||||
if result:
|
if model:
|
||||||
typer.echo("Event IDs:")
|
typer.echo("Event IDs:")
|
||||||
typer.echo(f"- manifest: {result['manifest_id']}")
|
typer.echo(f"- manifest: {model.manifest_id}")
|
||||||
for cid in result["chunk_ids"]:
|
for cid in model.chunk_ids:
|
||||||
typer.echo(f"- chunk: {cid}")
|
typer.echo(f"- chunk: {cid}")
|
||||||
for did in result["delta_ids"]:
|
for did in model.delta_ids:
|
||||||
typer.echo(f"- delta: {did}")
|
typer.echo(f"- delta: {did}")
|
||||||
else:
|
else:
|
||||||
typer.echo("Error: Failed to sync vault")
|
typer.echo("Error: Failed to sync vault")
|
||||||
@@ -434,16 +559,49 @@ def nostr_sync(ctx: typer.Context) -> None:
|
|||||||
@nostr_app.command("get-pubkey")
|
@nostr_app.command("get-pubkey")
|
||||||
def nostr_get_pubkey(ctx: typer.Context) -> None:
|
def nostr_get_pubkey(ctx: typer.Context) -> None:
|
||||||
"""Display the active profile's npub."""
|
"""Display the active profile's npub."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_nostr_service(ctx)
|
||||||
npub = pm.nostr_client.key_manager.get_npub()
|
npub = service.get_pubkey()
|
||||||
typer.echo(npub)
|
typer.echo(npub)
|
||||||
|
|
||||||
|
|
||||||
|
@nostr_app.command("list-relays")
|
||||||
|
def nostr_list_relays(ctx: typer.Context) -> None:
|
||||||
|
"""Display configured Nostr relays."""
|
||||||
|
service = _get_nostr_service(ctx)
|
||||||
|
relays = service.list_relays()
|
||||||
|
for i, r in enumerate(relays, 1):
|
||||||
|
typer.echo(f"{i}: {r}")
|
||||||
|
|
||||||
|
|
||||||
|
@nostr_app.command("add-relay")
|
||||||
|
def nostr_add_relay(ctx: typer.Context, url: str) -> None:
|
||||||
|
"""Add a relay URL."""
|
||||||
|
service = _get_nostr_service(ctx)
|
||||||
|
try:
|
||||||
|
service.add_relay(url)
|
||||||
|
except Exception as exc: # pragma: no cover - pass through errors
|
||||||
|
typer.echo(f"Error: {exc}")
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
typer.echo("Added")
|
||||||
|
|
||||||
|
|
||||||
|
@nostr_app.command("remove-relay")
|
||||||
|
def nostr_remove_relay(ctx: typer.Context, idx: int) -> None:
|
||||||
|
"""Remove a relay by index (1-based)."""
|
||||||
|
service = _get_nostr_service(ctx)
|
||||||
|
try:
|
||||||
|
service.remove_relay(idx)
|
||||||
|
except Exception as exc: # pragma: no cover - pass through errors
|
||||||
|
typer.echo(f"Error: {exc}")
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
typer.echo("Removed")
|
||||||
|
|
||||||
|
|
||||||
@config_app.command("get")
|
@config_app.command("get")
|
||||||
def config_get(ctx: typer.Context, key: str) -> None:
|
def config_get(ctx: typer.Context, key: str) -> None:
|
||||||
"""Get a configuration value."""
|
"""Get a configuration value."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_config_service(ctx)
|
||||||
value = pm.config_manager.load_config(require_pin=False).get(key)
|
value = service.get(key)
|
||||||
if value is None:
|
if value is None:
|
||||||
typer.echo("Key not found")
|
typer.echo("Key not found")
|
||||||
else:
|
else:
|
||||||
@@ -453,43 +611,18 @@ def config_get(ctx: typer.Context, key: str) -> None:
|
|||||||
@config_app.command("set")
|
@config_app.command("set")
|
||||||
def config_set(ctx: typer.Context, key: str, value: str) -> None:
|
def config_set(ctx: typer.Context, key: str, value: str) -> None:
|
||||||
"""Set a configuration value."""
|
"""Set a configuration value."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_config_service(ctx)
|
||||||
cfg = pm.config_manager
|
|
||||||
|
|
||||||
mapping = {
|
|
||||||
"inactivity_timeout": lambda v: cfg.set_inactivity_timeout(float(v)),
|
|
||||||
"secret_mode_enabled": lambda v: cfg.set_secret_mode_enabled(
|
|
||||||
v.lower() in ("1", "true", "yes", "y", "on")
|
|
||||||
),
|
|
||||||
"clipboard_clear_delay": lambda v: cfg.set_clipboard_clear_delay(int(v)),
|
|
||||||
"additional_backup_path": lambda v: cfg.set_additional_backup_path(v or None),
|
|
||||||
"relays": lambda v: cfg.set_relays(
|
|
||||||
[r.strip() for r in v.split(",") if r.strip()], require_pin=False
|
|
||||||
),
|
|
||||||
"kdf_iterations": lambda v: cfg.set_kdf_iterations(int(v)),
|
|
||||||
"kdf_mode": lambda v: cfg.set_kdf_mode(v),
|
|
||||||
"backup_interval": lambda v: cfg.set_backup_interval(float(v)),
|
|
||||||
"nostr_max_retries": lambda v: cfg.set_nostr_max_retries(int(v)),
|
|
||||||
"nostr_retry_delay": lambda v: cfg.set_nostr_retry_delay(float(v)),
|
|
||||||
"min_uppercase": lambda v: cfg.set_min_uppercase(int(v)),
|
|
||||||
"min_lowercase": lambda v: cfg.set_min_lowercase(int(v)),
|
|
||||||
"min_digits": lambda v: cfg.set_min_digits(int(v)),
|
|
||||||
"min_special": lambda v: cfg.set_min_special(int(v)),
|
|
||||||
"quick_unlock": lambda v: cfg.set_quick_unlock(
|
|
||||||
v.lower() in ("1", "true", "yes", "y", "on")
|
|
||||||
),
|
|
||||||
"verbose_timing": lambda v: cfg.set_verbose_timing(
|
|
||||||
v.lower() in ("1", "true", "yes", "y", "on")
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
action = mapping.get(key)
|
|
||||||
if action is None:
|
|
||||||
typer.echo("Unknown key")
|
|
||||||
raise typer.Exit(code=1)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
action(value)
|
val = (
|
||||||
|
[r.strip() for r in value.split(",") if r.strip()]
|
||||||
|
if key == "relays"
|
||||||
|
else value
|
||||||
|
)
|
||||||
|
service.set(key, val)
|
||||||
|
except KeyError:
|
||||||
|
typer.echo("Unknown key")
|
||||||
|
raise typer.Exit(code=1)
|
||||||
except Exception as exc: # pragma: no cover - pass through errors
|
except Exception as exc: # pragma: no cover - pass through errors
|
||||||
typer.echo(f"Error: {exc}")
|
typer.echo(f"Error: {exc}")
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
@@ -499,12 +632,15 @@ def config_set(ctx: typer.Context, key: str, value: str) -> None:
|
|||||||
|
|
||||||
@config_app.command("toggle-secret-mode")
|
@config_app.command("toggle-secret-mode")
|
||||||
def config_toggle_secret_mode(ctx: typer.Context) -> None:
|
def config_toggle_secret_mode(ctx: typer.Context) -> None:
|
||||||
"""Interactively enable or disable secret mode."""
|
"""Interactively enable or disable secret mode.
|
||||||
pm = _get_pm(ctx)
|
|
||||||
cfg = pm.config_manager
|
When enabled, newly generated and retrieved passwords are copied to the
|
||||||
|
clipboard instead of printed to the screen.
|
||||||
|
"""
|
||||||
|
service = _get_config_service(ctx)
|
||||||
try:
|
try:
|
||||||
enabled = cfg.get_secret_mode_enabled()
|
enabled = service.get_secret_mode_enabled()
|
||||||
delay = cfg.get_clipboard_clear_delay()
|
delay = service.get_clipboard_clear_delay()
|
||||||
except Exception as exc: # pragma: no cover - pass through errors
|
except Exception as exc: # pragma: no cover - pass through errors
|
||||||
typer.echo(f"Error loading settings: {exc}")
|
typer.echo(f"Error loading settings: {exc}")
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
@@ -536,10 +672,7 @@ def config_toggle_secret_mode(ctx: typer.Context) -> None:
|
|||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cfg.set_secret_mode_enabled(enabled)
|
service.set_secret_mode(enabled, delay)
|
||||||
cfg.set_clipboard_clear_delay(delay)
|
|
||||||
pm.secret_mode_enabled = enabled
|
|
||||||
pm.clipboard_clear_delay = delay
|
|
||||||
except Exception as exc: # pragma: no cover - pass through errors
|
except Exception as exc: # pragma: no cover - pass through errors
|
||||||
typer.echo(f"Error: {exc}")
|
typer.echo(f"Error: {exc}")
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
@@ -551,10 +684,9 @@ def config_toggle_secret_mode(ctx: typer.Context) -> None:
|
|||||||
@config_app.command("toggle-offline")
|
@config_app.command("toggle-offline")
|
||||||
def config_toggle_offline(ctx: typer.Context) -> None:
|
def config_toggle_offline(ctx: typer.Context) -> None:
|
||||||
"""Enable or disable offline mode."""
|
"""Enable or disable offline mode."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_config_service(ctx)
|
||||||
cfg = pm.config_manager
|
|
||||||
try:
|
try:
|
||||||
enabled = cfg.get_offline_mode()
|
enabled = service.get_offline_mode()
|
||||||
except Exception as exc: # pragma: no cover - pass through errors
|
except Exception as exc: # pragma: no cover - pass through errors
|
||||||
typer.echo(f"Error loading settings: {exc}")
|
typer.echo(f"Error loading settings: {exc}")
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
@@ -573,8 +705,7 @@ def config_toggle_offline(ctx: typer.Context) -> None:
|
|||||||
enabled = False
|
enabled = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cfg.set_offline_mode(enabled)
|
service.set_offline_mode(enabled)
|
||||||
pm.offline_mode = enabled
|
|
||||||
except Exception as exc: # pragma: no cover - pass through errors
|
except Exception as exc: # pragma: no cover - pass through errors
|
||||||
typer.echo(f"Error: {exc}")
|
typer.echo(f"Error: {exc}")
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
@@ -586,52 +717,97 @@ def config_toggle_offline(ctx: typer.Context) -> None:
|
|||||||
@fingerprint_app.command("list")
|
@fingerprint_app.command("list")
|
||||||
def fingerprint_list(ctx: typer.Context) -> None:
|
def fingerprint_list(ctx: typer.Context) -> None:
|
||||||
"""List available seed profiles."""
|
"""List available seed profiles."""
|
||||||
pm = _get_pm(ctx)
|
_vault, profile_service, _sync = _get_services(ctx)
|
||||||
for fp in pm.fingerprint_manager.list_fingerprints():
|
for fp in profile_service.list_profiles():
|
||||||
typer.echo(fp)
|
typer.echo(fp)
|
||||||
|
|
||||||
|
|
||||||
@fingerprint_app.command("add")
|
@fingerprint_app.command("add")
|
||||||
def fingerprint_add(ctx: typer.Context) -> None:
|
def fingerprint_add(ctx: typer.Context) -> None:
|
||||||
"""Create a new seed profile."""
|
"""Create a new seed profile."""
|
||||||
pm = _get_pm(ctx)
|
_vault, profile_service, _sync = _get_services(ctx)
|
||||||
pm.add_new_fingerprint()
|
profile_service.add_profile()
|
||||||
|
|
||||||
|
|
||||||
@fingerprint_app.command("remove")
|
@fingerprint_app.command("remove")
|
||||||
def fingerprint_remove(ctx: typer.Context, fingerprint: str) -> None:
|
def fingerprint_remove(ctx: typer.Context, fingerprint: str) -> None:
|
||||||
"""Remove a seed profile."""
|
"""Remove a seed profile."""
|
||||||
pm = _get_pm(ctx)
|
_vault, profile_service, _sync = _get_services(ctx)
|
||||||
pm.fingerprint_manager.remove_fingerprint(fingerprint)
|
profile_service.remove_profile(ProfileRemoveRequest(fingerprint=fingerprint))
|
||||||
|
|
||||||
|
|
||||||
@fingerprint_app.command("switch")
|
@fingerprint_app.command("switch")
|
||||||
def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None:
|
def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None:
|
||||||
"""Switch to another seed profile."""
|
"""Switch to another seed profile."""
|
||||||
pm = _get_pm(ctx)
|
_vault, profile_service, _sync = _get_services(ctx)
|
||||||
pm.select_fingerprint(fingerprint)
|
password = typer.prompt("Master password", hide_input=True)
|
||||||
|
profile_service.switch_profile(
|
||||||
|
ProfileSwitchRequest(fingerprint=fingerprint, password=password)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@util_app.command("generate-password")
|
@util_app.command("generate-password")
|
||||||
def generate_password(ctx: typer.Context, length: int = 24) -> None:
|
def generate_password(
|
||||||
|
ctx: typer.Context,
|
||||||
|
length: int = 24,
|
||||||
|
no_special: bool = typer.Option(
|
||||||
|
False, "--no-special", help="Exclude special characters", is_flag=True
|
||||||
|
),
|
||||||
|
allowed_special_chars: Optional[str] = typer.Option(
|
||||||
|
None, "--allowed-special-chars", help="Explicit set of special characters"
|
||||||
|
),
|
||||||
|
special_mode: Optional[str] = typer.Option(
|
||||||
|
None,
|
||||||
|
"--special-mode",
|
||||||
|
help="Special character mode",
|
||||||
|
),
|
||||||
|
exclude_ambiguous: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--exclude-ambiguous",
|
||||||
|
help="Exclude ambiguous characters",
|
||||||
|
is_flag=True,
|
||||||
|
),
|
||||||
|
min_uppercase: Optional[int] = typer.Option(None, "--min-uppercase"),
|
||||||
|
min_lowercase: Optional[int] = typer.Option(None, "--min-lowercase"),
|
||||||
|
min_digits: Optional[int] = typer.Option(None, "--min-digits"),
|
||||||
|
min_special: Optional[int] = typer.Option(None, "--min-special"),
|
||||||
|
) -> None:
|
||||||
"""Generate a strong password."""
|
"""Generate a strong password."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_util_service(ctx)
|
||||||
password = pm.password_generator.generate_password(length)
|
kwargs = {}
|
||||||
|
if no_special:
|
||||||
|
kwargs["include_special_chars"] = False
|
||||||
|
if allowed_special_chars is not None:
|
||||||
|
kwargs["allowed_special_chars"] = allowed_special_chars
|
||||||
|
if special_mode is not None:
|
||||||
|
kwargs["special_mode"] = special_mode
|
||||||
|
if exclude_ambiguous:
|
||||||
|
kwargs["exclude_ambiguous"] = True
|
||||||
|
if min_uppercase is not None:
|
||||||
|
kwargs["min_uppercase"] = min_uppercase
|
||||||
|
if min_lowercase is not None:
|
||||||
|
kwargs["min_lowercase"] = min_lowercase
|
||||||
|
if min_digits is not None:
|
||||||
|
kwargs["min_digits"] = min_digits
|
||||||
|
if min_special is not None:
|
||||||
|
kwargs["min_special"] = min_special
|
||||||
|
|
||||||
|
password = service.generate_password(length, **kwargs)
|
||||||
typer.echo(password)
|
typer.echo(password)
|
||||||
|
|
||||||
|
|
||||||
@util_app.command("verify-checksum")
|
@util_app.command("verify-checksum")
|
||||||
def verify_checksum(ctx: typer.Context) -> None:
|
def verify_checksum(ctx: typer.Context) -> None:
|
||||||
"""Verify the SeedPass script checksum."""
|
"""Verify the SeedPass script checksum."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_util_service(ctx)
|
||||||
pm.handle_verify_checksum()
|
service.verify_checksum()
|
||||||
|
|
||||||
|
|
||||||
@util_app.command("update-checksum")
|
@util_app.command("update-checksum")
|
||||||
def update_checksum(ctx: typer.Context) -> None:
|
def update_checksum(ctx: typer.Context) -> None:
|
||||||
"""Regenerate the script checksum file."""
|
"""Regenerate the script checksum file."""
|
||||||
pm = _get_pm(ctx)
|
service = _get_util_service(ctx)
|
||||||
pm.handle_update_script_checksum()
|
service.update_checksum()
|
||||||
|
|
||||||
|
|
||||||
@api_app.command("start")
|
@api_app.command("start")
|
||||||
@@ -657,5 +833,46 @@ def api_stop(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> N
|
|||||||
typer.echo(f"Failed to stop server: {exc}")
|
typer.echo(f"Failed to stop server: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def gui() -> None:
|
||||||
|
"""Launch the BeeWare GUI.
|
||||||
|
|
||||||
|
If the platform specific backend is missing, attempt to install it and
|
||||||
|
retry launching the GUI.
|
||||||
|
"""
|
||||||
|
if not _gui_backend_available():
|
||||||
|
if sys.platform.startswith("linux"):
|
||||||
|
pkg = "toga-gtk"
|
||||||
|
elif sys.platform == "win32":
|
||||||
|
pkg = "toga-winforms"
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
pkg = "toga-cocoa"
|
||||||
|
else:
|
||||||
|
typer.echo(
|
||||||
|
f"Unsupported platform '{sys.platform}' for BeeWare GUI.",
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
typer.echo(f"Attempting to install {pkg} for GUI support...")
|
||||||
|
try:
|
||||||
|
subprocess.check_call([sys.executable, "-m", "pip", "install", pkg])
|
||||||
|
typer.echo(f"Successfully installed {pkg}.")
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
typer.echo(f"Failed to install {pkg}: {exc}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
if not _gui_backend_available():
|
||||||
|
typer.echo(
|
||||||
|
"BeeWare GUI backend still unavailable after installation attempt.",
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
from seedpass_gui.app import main
|
||||||
|
|
||||||
|
main()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app()
|
app()
|
||||||
|
@@ -1,10 +1,17 @@
|
|||||||
# password_manager/__init__.py
|
# seedpass.core/__init__.py
|
||||||
|
|
||||||
"""Expose password manager components with lazy imports."""
|
"""Expose password manager components with lazy imports."""
|
||||||
|
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
|
|
||||||
__all__ = ["PasswordManager", "ConfigManager", "Vault", "EntryType"]
|
__all__ = [
|
||||||
|
"PasswordManager",
|
||||||
|
"ConfigManager",
|
||||||
|
"Vault",
|
||||||
|
"EntryType",
|
||||||
|
"StateManager",
|
||||||
|
"StatsManager",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str):
|
def __getattr__(name: str):
|
||||||
@@ -16,4 +23,8 @@ def __getattr__(name: str):
|
|||||||
return import_module(".vault", __name__).Vault
|
return import_module(".vault", __name__).Vault
|
||||||
if name == "EntryType":
|
if name == "EntryType":
|
||||||
return import_module(".entry_types", __name__).EntryType
|
return import_module(".entry_types", __name__).EntryType
|
||||||
|
if name == "StateManager":
|
||||||
|
return import_module(".state_manager", __name__).StateManager
|
||||||
|
if name == "StatsManager":
|
||||||
|
return import_module(".stats_manager", __name__).StatsManager
|
||||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
705
src/seedpass/core/api.py
Normal file
705
src/seedpass/core/api.py
Normal file
@@ -0,0 +1,705 @@
|
|||||||
|
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, Any
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .manager import PasswordManager
|
||||||
|
from .pubsub import bus
|
||||||
|
from .entry_types import EntryType
|
||||||
|
|
||||||
|
|
||||||
|
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 PasswordPolicyOptions(BaseModel):
|
||||||
|
"""Optional password policy overrides."""
|
||||||
|
|
||||||
|
include_special_chars: bool | None = None
|
||||||
|
allowed_special_chars: str | None = None
|
||||||
|
special_mode: str | None = None
|
||||||
|
exclude_ambiguous: bool | None = None
|
||||||
|
min_uppercase: int | None = None
|
||||||
|
min_lowercase: int | None = None
|
||||||
|
min_digits: int | None = None
|
||||||
|
min_special: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddPasswordEntryRequest(PasswordPolicyOptions):
|
||||||
|
label: str
|
||||||
|
length: int
|
||||||
|
username: str | None = None
|
||||||
|
url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratePasswordRequest(PasswordPolicyOptions):
|
||||||
|
length: int
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratePasswordResponse(BaseModel):
|
||||||
|
password: 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, EntryType]]:
|
||||||
|
"""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:
|
||||||
|
entry = self._manager.entry_manager.retrieve_entry(index)
|
||||||
|
gen_fn = getattr(self._manager, "_generate_password_for_entry", None)
|
||||||
|
if gen_fn is None:
|
||||||
|
return self._manager.password_generator.generate_password(length, index)
|
||||||
|
return gen_fn(entry, index, length)
|
||||||
|
|
||||||
|
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,
|
||||||
|
*,
|
||||||
|
include_special_chars: bool | None = None,
|
||||||
|
allowed_special_chars: str | None = None,
|
||||||
|
special_mode: str | None = None,
|
||||||
|
exclude_ambiguous: bool | None = None,
|
||||||
|
min_uppercase: int | None = None,
|
||||||
|
min_lowercase: int | None = None,
|
||||||
|
min_digits: int | None = None,
|
||||||
|
min_special: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
with self._lock:
|
||||||
|
kwargs: dict[str, Any] = {}
|
||||||
|
if include_special_chars is not None:
|
||||||
|
kwargs["include_special_chars"] = include_special_chars
|
||||||
|
if allowed_special_chars is not None:
|
||||||
|
kwargs["allowed_special_chars"] = allowed_special_chars
|
||||||
|
if special_mode is not None:
|
||||||
|
kwargs["special_mode"] = special_mode
|
||||||
|
if exclude_ambiguous is not None:
|
||||||
|
kwargs["exclude_ambiguous"] = exclude_ambiguous
|
||||||
|
if min_uppercase is not None:
|
||||||
|
kwargs["min_uppercase"] = min_uppercase
|
||||||
|
if min_lowercase is not None:
|
||||||
|
kwargs["min_lowercase"] = min_lowercase
|
||||||
|
if min_digits is not None:
|
||||||
|
kwargs["min_digits"] = min_digits
|
||||||
|
if min_special is not None:
|
||||||
|
kwargs["min_special"] = min_special
|
||||||
|
|
||||||
|
idx = self._manager.entry_manager.add_entry(
|
||||||
|
label,
|
||||||
|
length,
|
||||||
|
username,
|
||||||
|
url,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
self._manager.parent_seed,
|
||||||
|
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, key: str, value: str, *, notes: str = ""
|
||||||
|
) -> int:
|
||||||
|
with self._lock:
|
||||||
|
idx = self._manager.entry_manager.add_key_value(
|
||||||
|
label, key, 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,
|
||||||
|
key: str | 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,
|
||||||
|
key=key,
|
||||||
|
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),
|
||||||
|
"include_special_chars": (
|
||||||
|
"set_include_special_chars",
|
||||||
|
lambda v: v.lower() in ("1", "true", "yes", "y", "on"),
|
||||||
|
),
|
||||||
|
"allowed_special_chars": ("set_allowed_special_chars", lambda v: v),
|
||||||
|
"special_mode": ("set_special_mode", lambda v: v),
|
||||||
|
"exclude_ambiguous": (
|
||||||
|
"set_exclude_ambiguous",
|
||||||
|
lambda v: v.lower() in ("1", "true", "yes", "y", "on"),
|
||||||
|
),
|
||||||
|
"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,
|
||||||
|
*,
|
||||||
|
include_special_chars: bool | None = None,
|
||||||
|
allowed_special_chars: str | None = None,
|
||||||
|
special_mode: str | None = None,
|
||||||
|
exclude_ambiguous: bool | None = None,
|
||||||
|
min_uppercase: int | None = None,
|
||||||
|
min_lowercase: int | None = None,
|
||||||
|
min_digits: int | None = None,
|
||||||
|
min_special: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
with self._lock:
|
||||||
|
pg = self._manager.password_generator
|
||||||
|
base_policy = getattr(pg, "policy", None)
|
||||||
|
overrides: dict[str, Any] = {}
|
||||||
|
if include_special_chars is not None:
|
||||||
|
overrides["include_special_chars"] = include_special_chars
|
||||||
|
if allowed_special_chars is not None:
|
||||||
|
overrides["allowed_special_chars"] = allowed_special_chars
|
||||||
|
if special_mode is not None:
|
||||||
|
overrides["special_mode"] = special_mode
|
||||||
|
if exclude_ambiguous is not None:
|
||||||
|
overrides["exclude_ambiguous"] = exclude_ambiguous
|
||||||
|
if min_uppercase is not None:
|
||||||
|
overrides["min_uppercase"] = int(min_uppercase)
|
||||||
|
if min_lowercase is not None:
|
||||||
|
overrides["min_lowercase"] = int(min_lowercase)
|
||||||
|
if min_digits is not None:
|
||||||
|
overrides["min_digits"] = int(min_digits)
|
||||||
|
if min_special is not None:
|
||||||
|
overrides["min_special"] = int(min_special)
|
||||||
|
|
||||||
|
if base_policy is not None and overrides:
|
||||||
|
pg.policy = dataclasses.replace(
|
||||||
|
base_policy,
|
||||||
|
**{k: overrides[k] for k in overrides if hasattr(base_policy, k)},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return pg.generate_password(length)
|
||||||
|
finally:
|
||||||
|
pg.policy = base_policy
|
||||||
|
return pg.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
|
Backup Manager Module
|
||||||
@@ -19,7 +19,7 @@ import traceback
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from termcolor import colored
|
from termcolor import colored
|
||||||
|
|
||||||
from password_manager.config_manager import ConfigManager
|
from .config_manager import ConfigManager
|
||||||
|
|
||||||
from utils.file_lock import exclusive_lock
|
from utils.file_lock import exclusive_lock
|
||||||
from constants import APP_DIR
|
from constants import APP_DIR
|
@@ -10,10 +10,10 @@ from utils.seed_prompt import masked_input
|
|||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
|
||||||
from password_manager.vault import Vault
|
from .vault import Vault
|
||||||
from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS
|
from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS
|
||||||
|
|
||||||
from constants import INACTIVITY_TIMEOUT
|
from constants import INACTIVITY_TIMEOUT, MAX_RETRIES, RETRY_DELAY
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -52,12 +52,16 @@ class ConfigManager:
|
|||||||
"secret_mode_enabled": False,
|
"secret_mode_enabled": False,
|
||||||
"clipboard_clear_delay": 45,
|
"clipboard_clear_delay": 45,
|
||||||
"quick_unlock": False,
|
"quick_unlock": False,
|
||||||
"nostr_max_retries": 2,
|
"nostr_max_retries": MAX_RETRIES,
|
||||||
"nostr_retry_delay": 1.0,
|
"nostr_retry_delay": float(RETRY_DELAY),
|
||||||
"min_uppercase": 2,
|
"min_uppercase": 2,
|
||||||
"min_lowercase": 2,
|
"min_lowercase": 2,
|
||||||
"min_digits": 2,
|
"min_digits": 2,
|
||||||
"min_special": 2,
|
"min_special": 2,
|
||||||
|
"include_special_chars": True,
|
||||||
|
"allowed_special_chars": "",
|
||||||
|
"special_mode": "standard",
|
||||||
|
"exclude_ambiguous": False,
|
||||||
"verbose_timing": False,
|
"verbose_timing": False,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
@@ -77,12 +81,16 @@ class ConfigManager:
|
|||||||
data.setdefault("secret_mode_enabled", False)
|
data.setdefault("secret_mode_enabled", False)
|
||||||
data.setdefault("clipboard_clear_delay", 45)
|
data.setdefault("clipboard_clear_delay", 45)
|
||||||
data.setdefault("quick_unlock", False)
|
data.setdefault("quick_unlock", False)
|
||||||
data.setdefault("nostr_max_retries", 2)
|
data.setdefault("nostr_max_retries", MAX_RETRIES)
|
||||||
data.setdefault("nostr_retry_delay", 1.0)
|
data.setdefault("nostr_retry_delay", float(RETRY_DELAY))
|
||||||
data.setdefault("min_uppercase", 2)
|
data.setdefault("min_uppercase", 2)
|
||||||
data.setdefault("min_lowercase", 2)
|
data.setdefault("min_lowercase", 2)
|
||||||
data.setdefault("min_digits", 2)
|
data.setdefault("min_digits", 2)
|
||||||
data.setdefault("min_special", 2)
|
data.setdefault("min_special", 2)
|
||||||
|
data.setdefault("include_special_chars", True)
|
||||||
|
data.setdefault("allowed_special_chars", "")
|
||||||
|
data.setdefault("special_mode", "standard")
|
||||||
|
data.setdefault("exclude_ambiguous", False)
|
||||||
data.setdefault("verbose_timing", False)
|
data.setdefault("verbose_timing", False)
|
||||||
|
|
||||||
# Migrate legacy hashed_password.enc if present and password_hash is missing
|
# Migrate legacy hashed_password.enc if present and password_hash is missing
|
||||||
@@ -251,7 +259,7 @@ class ConfigManager:
|
|||||||
# Password policy settings
|
# Password policy settings
|
||||||
def get_password_policy(self) -> "PasswordPolicy":
|
def get_password_policy(self) -> "PasswordPolicy":
|
||||||
"""Return the password complexity policy."""
|
"""Return the password complexity policy."""
|
||||||
from password_manager.password_generation import PasswordPolicy
|
from .password_generation import PasswordPolicy
|
||||||
|
|
||||||
cfg = self.load_config(require_pin=False)
|
cfg = self.load_config(require_pin=False)
|
||||||
return PasswordPolicy(
|
return PasswordPolicy(
|
||||||
@@ -259,6 +267,10 @@ class ConfigManager:
|
|||||||
min_lowercase=int(cfg.get("min_lowercase", 2)),
|
min_lowercase=int(cfg.get("min_lowercase", 2)),
|
||||||
min_digits=int(cfg.get("min_digits", 2)),
|
min_digits=int(cfg.get("min_digits", 2)),
|
||||||
min_special=int(cfg.get("min_special", 2)),
|
min_special=int(cfg.get("min_special", 2)),
|
||||||
|
include_special_chars=bool(cfg.get("include_special_chars", True)),
|
||||||
|
allowed_special_chars=cfg.get("allowed_special_chars") or None,
|
||||||
|
special_mode=cfg.get("special_mode") or None,
|
||||||
|
exclude_ambiguous=bool(cfg.get("exclude_ambiguous", False)),
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_min_uppercase(self, count: int) -> None:
|
def set_min_uppercase(self, count: int) -> None:
|
||||||
@@ -281,6 +293,30 @@ class ConfigManager:
|
|||||||
cfg["min_special"] = int(count)
|
cfg["min_special"] = int(count)
|
||||||
self.save_config(cfg)
|
self.save_config(cfg)
|
||||||
|
|
||||||
|
def set_include_special_chars(self, enabled: bool) -> None:
|
||||||
|
"""Persist whether special characters are allowed."""
|
||||||
|
cfg = self.load_config(require_pin=False)
|
||||||
|
cfg["include_special_chars"] = bool(enabled)
|
||||||
|
self.save_config(cfg)
|
||||||
|
|
||||||
|
def set_allowed_special_chars(self, chars: str | None) -> None:
|
||||||
|
"""Persist the set of allowed special characters."""
|
||||||
|
cfg = self.load_config(require_pin=False)
|
||||||
|
cfg["allowed_special_chars"] = chars or ""
|
||||||
|
self.save_config(cfg)
|
||||||
|
|
||||||
|
def set_special_mode(self, mode: str) -> None:
|
||||||
|
"""Persist the special character mode."""
|
||||||
|
cfg = self.load_config(require_pin=False)
|
||||||
|
cfg["special_mode"] = mode
|
||||||
|
self.save_config(cfg)
|
||||||
|
|
||||||
|
def set_exclude_ambiguous(self, enabled: bool) -> None:
|
||||||
|
"""Persist whether ambiguous characters are excluded."""
|
||||||
|
cfg = self.load_config(require_pin=False)
|
||||||
|
cfg["exclude_ambiguous"] = bool(enabled)
|
||||||
|
self.save_config(cfg)
|
||||||
|
|
||||||
def set_quick_unlock(self, enabled: bool) -> None:
|
def set_quick_unlock(self, enabled: bool) -> None:
|
||||||
"""Persist the quick unlock toggle."""
|
"""Persist the quick unlock toggle."""
|
||||||
cfg = self.load_config(require_pin=False)
|
cfg = self.load_config(require_pin=False)
|
||||||
@@ -303,7 +339,7 @@ class ConfigManager:
|
|||||||
def get_nostr_max_retries(self) -> int:
|
def get_nostr_max_retries(self) -> int:
|
||||||
"""Retrieve the configured Nostr retry count."""
|
"""Retrieve the configured Nostr retry count."""
|
||||||
cfg = self.load_config(require_pin=False)
|
cfg = self.load_config(require_pin=False)
|
||||||
return int(cfg.get("nostr_max_retries", 2))
|
return int(cfg.get("nostr_max_retries", MAX_RETRIES))
|
||||||
|
|
||||||
def set_nostr_retry_delay(self, delay: float) -> None:
|
def set_nostr_retry_delay(self, delay: float) -> None:
|
||||||
"""Persist the delay between Nostr retry attempts."""
|
"""Persist the delay between Nostr retry attempts."""
|
||||||
@@ -316,7 +352,7 @@ class ConfigManager:
|
|||||||
def get_nostr_retry_delay(self) -> float:
|
def get_nostr_retry_delay(self) -> float:
|
||||||
"""Retrieve the delay in seconds between Nostr retries."""
|
"""Retrieve the delay in seconds between Nostr retries."""
|
||||||
cfg = self.load_config(require_pin=False)
|
cfg = self.load_config(require_pin=False)
|
||||||
return float(cfg.get("nostr_retry_delay", 1.0))
|
return float(cfg.get("nostr_retry_delay", float(RETRY_DELAY)))
|
||||||
|
|
||||||
def set_verbose_timing(self, enabled: bool) -> None:
|
def set_verbose_timing(self, enabled: bool) -> None:
|
||||||
cfg = self.load_config(require_pin=False)
|
cfg = self.load_config(require_pin=False)
|
@@ -1,4 +1,4 @@
|
|||||||
# /src/password_manager/encryption.py
|
# /src/seedpass.core/encryption.py
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
@@ -228,6 +228,7 @@ class EncryptionManager:
|
|||||||
relative_path: Optional[Path] = None,
|
relative_path: Optional[Path] = None,
|
||||||
*,
|
*,
|
||||||
strict: bool = True,
|
strict: bool = True,
|
||||||
|
merge: bool = False,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Decrypts data from Nostr and saves it.
|
"""Decrypts data from Nostr and saves it.
|
||||||
|
|
||||||
@@ -249,6 +250,20 @@ class EncryptionManager:
|
|||||||
data = json_lib.loads(decrypted_data)
|
data = json_lib.loads(decrypted_data)
|
||||||
else:
|
else:
|
||||||
data = json_lib.loads(decrypted_data.decode("utf-8"))
|
data = json_lib.loads(decrypted_data.decode("utf-8"))
|
||||||
|
if merge and (self.fingerprint_dir / relative_path).exists():
|
||||||
|
current = self.load_json_data(relative_path)
|
||||||
|
current_entries = current.get("entries", {})
|
||||||
|
for idx, entry in data.get("entries", {}).items():
|
||||||
|
cur_ts = current_entries.get(idx, {}).get("modified_ts", 0)
|
||||||
|
new_ts = entry.get("modified_ts", 0)
|
||||||
|
if idx not in current_entries or new_ts >= cur_ts:
|
||||||
|
current_entries[idx] = entry
|
||||||
|
current["entries"] = current_entries
|
||||||
|
if "schema_version" in data:
|
||||||
|
current["schema_version"] = max(
|
||||||
|
current.get("schema_version", 0), data.get("schema_version", 0)
|
||||||
|
)
|
||||||
|
data = current
|
||||||
self.save_json_data(data, relative_path) # This always saves in V2 format
|
self.save_json_data(data, relative_path) # This always saves in V2 format
|
||||||
self.update_checksum(relative_path)
|
self.update_checksum(relative_path)
|
||||||
logger.info("Index file from Nostr was processed and saved successfully.")
|
logger.info("Index file from Nostr was processed and saved successfully.")
|
@@ -1,4 +1,4 @@
|
|||||||
# password_manager/entry_management.py
|
# seedpass.core/entry_management.py
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Entry Management Module
|
Entry Management Module
|
||||||
@@ -27,18 +27,26 @@ import logging
|
|||||||
import hashlib
|
import hashlib
|
||||||
import sys
|
import sys
|
||||||
import shutil
|
import shutil
|
||||||
|
import time
|
||||||
from typing import Optional, Tuple, Dict, Any, List
|
from typing import Optional, Tuple, Dict, Any, List
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from termcolor import colored
|
from termcolor import colored
|
||||||
from password_manager.migrations import LATEST_VERSION
|
from .migrations import LATEST_VERSION
|
||||||
from password_manager.entry_types import EntryType
|
from .entry_types import EntryType
|
||||||
from password_manager.totp import TotpManager
|
from .totp import TotpManager
|
||||||
from utils.fingerprint import generate_fingerprint
|
from utils.fingerprint import generate_fingerprint
|
||||||
from utils.checksum import canonical_json_dumps
|
from utils.checksum import canonical_json_dumps
|
||||||
|
from utils.key_validation import (
|
||||||
|
validate_totp_secret,
|
||||||
|
validate_ssh_key_pair,
|
||||||
|
validate_pgp_private_key,
|
||||||
|
validate_nostr_keys,
|
||||||
|
validate_seed_phrase,
|
||||||
|
)
|
||||||
|
|
||||||
from password_manager.vault import Vault
|
from .vault import Vault
|
||||||
from password_manager.backup import BackupManager
|
from .backup import BackupManager
|
||||||
|
|
||||||
|
|
||||||
# Instantiate the logger
|
# Instantiate the logger
|
||||||
@@ -97,6 +105,7 @@ class EntryManager:
|
|||||||
entry["word_count"] = entry["words"]
|
entry["word_count"] = entry["words"]
|
||||||
entry.pop("words", None)
|
entry.pop("words", None)
|
||||||
entry.setdefault("tags", [])
|
entry.setdefault("tags", [])
|
||||||
|
entry.setdefault("modified_ts", entry.get("updated", 0))
|
||||||
logger.debug("Index loaded successfully.")
|
logger.debug("Index loaded successfully.")
|
||||||
self._index_cache = data
|
self._index_cache = data
|
||||||
return data
|
return data
|
||||||
@@ -150,6 +159,15 @@ class EntryManager:
|
|||||||
notes: str = "",
|
notes: str = "",
|
||||||
custom_fields: List[Dict[str, Any]] | None = None,
|
custom_fields: List[Dict[str, Any]] | None = None,
|
||||||
tags: list[str] | None = None,
|
tags: list[str] | None = None,
|
||||||
|
*,
|
||||||
|
include_special_chars: bool | None = None,
|
||||||
|
allowed_special_chars: str | None = None,
|
||||||
|
special_mode: str | None = None,
|
||||||
|
exclude_ambiguous: bool | None = None,
|
||||||
|
min_uppercase: int | None = None,
|
||||||
|
min_lowercase: int | None = None,
|
||||||
|
min_digits: int | None = None,
|
||||||
|
min_special: int | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
Adds a new entry to the encrypted JSON index file.
|
Adds a new entry to the encrypted JSON index file.
|
||||||
@@ -167,7 +185,7 @@ class EntryManager:
|
|||||||
data = self._load_index()
|
data = self._load_index()
|
||||||
|
|
||||||
data.setdefault("entries", {})
|
data.setdefault("entries", {})
|
||||||
data["entries"][str(index)] = {
|
entry = {
|
||||||
"label": label,
|
"label": label,
|
||||||
"length": length,
|
"length": length,
|
||||||
"username": username if username else "",
|
"username": username if username else "",
|
||||||
@@ -176,10 +194,33 @@ class EntryManager:
|
|||||||
"type": EntryType.PASSWORD.value,
|
"type": EntryType.PASSWORD.value,
|
||||||
"kind": EntryType.PASSWORD.value,
|
"kind": EntryType.PASSWORD.value,
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
|
"modified_ts": int(time.time()),
|
||||||
"custom_fields": custom_fields or [],
|
"custom_fields": custom_fields or [],
|
||||||
"tags": tags or [],
|
"tags": tags or [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
policy: dict[str, Any] = {}
|
||||||
|
if include_special_chars is not None:
|
||||||
|
policy["include_special_chars"] = include_special_chars
|
||||||
|
if allowed_special_chars is not None:
|
||||||
|
policy["allowed_special_chars"] = allowed_special_chars
|
||||||
|
if special_mode is not None:
|
||||||
|
policy["special_mode"] = special_mode
|
||||||
|
if exclude_ambiguous is not None:
|
||||||
|
policy["exclude_ambiguous"] = exclude_ambiguous
|
||||||
|
if min_uppercase is not None:
|
||||||
|
policy["min_uppercase"] = int(min_uppercase)
|
||||||
|
if min_lowercase is not None:
|
||||||
|
policy["min_lowercase"] = int(min_lowercase)
|
||||||
|
if min_digits is not None:
|
||||||
|
policy["min_digits"] = int(min_digits)
|
||||||
|
if min_special is not None:
|
||||||
|
policy["min_special"] = int(min_special)
|
||||||
|
if policy:
|
||||||
|
entry["policy"] = policy
|
||||||
|
|
||||||
|
data["entries"][str(index)] = entry
|
||||||
|
|
||||||
logger.debug(f"Added entry at index {index}: {data['entries'][str(index)]}")
|
logger.debug(f"Added entry at index {index}: {data['entries'][str(index)]}")
|
||||||
|
|
||||||
self._save_index(data)
|
self._save_index(data)
|
||||||
@@ -232,10 +273,13 @@ class EntryManager:
|
|||||||
if index is None:
|
if index is None:
|
||||||
index = self.get_next_totp_index()
|
index = self.get_next_totp_index()
|
||||||
secret = TotpManager.derive_secret(parent_seed, index)
|
secret = TotpManager.derive_secret(parent_seed, index)
|
||||||
|
if not validate_totp_secret(secret):
|
||||||
|
raise ValueError("Invalid derived TOTP secret")
|
||||||
entry = {
|
entry = {
|
||||||
"type": EntryType.TOTP.value,
|
"type": EntryType.TOTP.value,
|
||||||
"kind": EntryType.TOTP.value,
|
"kind": EntryType.TOTP.value,
|
||||||
"label": label,
|
"label": label,
|
||||||
|
"modified_ts": int(time.time()),
|
||||||
"index": index,
|
"index": index,
|
||||||
"period": period,
|
"period": period,
|
||||||
"digits": digits,
|
"digits": digits,
|
||||||
@@ -244,11 +288,14 @@ class EntryManager:
|
|||||||
"tags": tags or [],
|
"tags": tags or [],
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
|
if not validate_totp_secret(secret):
|
||||||
|
raise ValueError("Invalid TOTP secret")
|
||||||
entry = {
|
entry = {
|
||||||
"type": EntryType.TOTP.value,
|
"type": EntryType.TOTP.value,
|
||||||
"kind": EntryType.TOTP.value,
|
"kind": EntryType.TOTP.value,
|
||||||
"label": label,
|
"label": label,
|
||||||
"secret": secret,
|
"secret": secret,
|
||||||
|
"modified_ts": int(time.time()),
|
||||||
"period": period,
|
"period": period,
|
||||||
"digits": digits,
|
"digits": digits,
|
||||||
"archived": archived,
|
"archived": archived,
|
||||||
@@ -287,6 +334,12 @@ class EntryManager:
|
|||||||
if index is None:
|
if index is None:
|
||||||
index = self.get_next_index()
|
index = self.get_next_index()
|
||||||
|
|
||||||
|
from .password_generation import derive_ssh_key_pair
|
||||||
|
|
||||||
|
priv_pem, pub_pem = derive_ssh_key_pair(parent_seed, index)
|
||||||
|
if not validate_ssh_key_pair(priv_pem, pub_pem):
|
||||||
|
raise ValueError("Derived SSH key pair failed validation")
|
||||||
|
|
||||||
data = self._load_index()
|
data = self._load_index()
|
||||||
data.setdefault("entries", {})
|
data.setdefault("entries", {})
|
||||||
data["entries"][str(index)] = {
|
data["entries"][str(index)] = {
|
||||||
@@ -294,6 +347,7 @@ class EntryManager:
|
|||||||
"kind": EntryType.SSH.value,
|
"kind": EntryType.SSH.value,
|
||||||
"index": index,
|
"index": index,
|
||||||
"label": label,
|
"label": label,
|
||||||
|
"modified_ts": int(time.time()),
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
"archived": archived,
|
"archived": archived,
|
||||||
"tags": tags or [],
|
"tags": tags or [],
|
||||||
@@ -312,7 +366,7 @@ class EntryManager:
|
|||||||
if not entry or (etype != EntryType.SSH.value and kind != EntryType.SSH.value):
|
if not entry or (etype != EntryType.SSH.value and kind != EntryType.SSH.value):
|
||||||
raise ValueError("Entry is not an SSH key entry")
|
raise ValueError("Entry is not an SSH key entry")
|
||||||
|
|
||||||
from password_manager.password_generation import derive_ssh_key_pair
|
from .password_generation import derive_ssh_key_pair
|
||||||
|
|
||||||
key_index = int(entry.get("index", index))
|
key_index = int(entry.get("index", index))
|
||||||
return derive_ssh_key_pair(parent_seed, key_index)
|
return derive_ssh_key_pair(parent_seed, key_index)
|
||||||
@@ -333,6 +387,17 @@ class EntryManager:
|
|||||||
if index is None:
|
if index is None:
|
||||||
index = self.get_next_index()
|
index = self.get_next_index()
|
||||||
|
|
||||||
|
from .password_generation import derive_pgp_key
|
||||||
|
from local_bip85.bip85 import BIP85
|
||||||
|
from bip_utils import Bip39SeedGenerator
|
||||||
|
|
||||||
|
seed_bytes = Bip39SeedGenerator(parent_seed).Generate()
|
||||||
|
bip85 = BIP85(seed_bytes)
|
||||||
|
|
||||||
|
priv_key, fp = derive_pgp_key(bip85, index, key_type, user_id)
|
||||||
|
if not validate_pgp_private_key(priv_key, fp):
|
||||||
|
raise ValueError("Derived PGP key failed validation")
|
||||||
|
|
||||||
data = self._load_index()
|
data = self._load_index()
|
||||||
data.setdefault("entries", {})
|
data.setdefault("entries", {})
|
||||||
data["entries"][str(index)] = {
|
data["entries"][str(index)] = {
|
||||||
@@ -340,6 +405,7 @@ class EntryManager:
|
|||||||
"kind": EntryType.PGP.value,
|
"kind": EntryType.PGP.value,
|
||||||
"index": index,
|
"index": index,
|
||||||
"label": label,
|
"label": label,
|
||||||
|
"modified_ts": int(time.time()),
|
||||||
"key_type": key_type,
|
"key_type": key_type,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
@@ -360,7 +426,7 @@ class EntryManager:
|
|||||||
if not entry or (etype != EntryType.PGP.value and kind != EntryType.PGP.value):
|
if not entry or (etype != EntryType.PGP.value and kind != EntryType.PGP.value):
|
||||||
raise ValueError("Entry is not a PGP key entry")
|
raise ValueError("Entry is not a PGP key entry")
|
||||||
|
|
||||||
from password_manager.password_generation import derive_pgp_key
|
from .password_generation import derive_pgp_key
|
||||||
from local_bip85.bip85 import BIP85
|
from local_bip85.bip85 import BIP85
|
||||||
from bip_utils import Bip39SeedGenerator
|
from bip_utils import Bip39SeedGenerator
|
||||||
|
|
||||||
@@ -375,6 +441,7 @@ class EntryManager:
|
|||||||
def add_nostr_key(
|
def add_nostr_key(
|
||||||
self,
|
self,
|
||||||
label: str,
|
label: str,
|
||||||
|
parent_seed: str,
|
||||||
index: int | None = None,
|
index: int | None = None,
|
||||||
notes: str = "",
|
notes: str = "",
|
||||||
archived: bool = False,
|
archived: bool = False,
|
||||||
@@ -385,6 +452,19 @@ class EntryManager:
|
|||||||
if index is None:
|
if index is None:
|
||||||
index = self.get_next_index()
|
index = self.get_next_index()
|
||||||
|
|
||||||
|
from local_bip85.bip85 import BIP85
|
||||||
|
from bip_utils import Bip39SeedGenerator
|
||||||
|
from nostr.coincurve_keys import Keys
|
||||||
|
|
||||||
|
seed_bytes = Bip39SeedGenerator(parent_seed).Generate()
|
||||||
|
bip85 = BIP85(seed_bytes)
|
||||||
|
entropy = bip85.derive_entropy(index=index, bytes_len=32)
|
||||||
|
keys = Keys(priv_k=entropy.hex())
|
||||||
|
npub = Keys.hex_to_bech32(keys.public_key_hex(), "npub")
|
||||||
|
nsec = Keys.hex_to_bech32(keys.private_key_hex(), "nsec")
|
||||||
|
if not validate_nostr_keys(npub, nsec):
|
||||||
|
raise ValueError("Derived Nostr keys failed validation")
|
||||||
|
|
||||||
data = self._load_index()
|
data = self._load_index()
|
||||||
data.setdefault("entries", {})
|
data.setdefault("entries", {})
|
||||||
data["entries"][str(index)] = {
|
data["entries"][str(index)] = {
|
||||||
@@ -392,6 +472,7 @@ class EntryManager:
|
|||||||
"kind": EntryType.NOSTR.value,
|
"kind": EntryType.NOSTR.value,
|
||||||
"index": index,
|
"index": index,
|
||||||
"label": label,
|
"label": label,
|
||||||
|
"modified_ts": int(time.time()),
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
"archived": archived,
|
"archived": archived,
|
||||||
"tags": tags or [],
|
"tags": tags or [],
|
||||||
@@ -404,6 +485,7 @@ class EntryManager:
|
|||||||
def add_key_value(
|
def add_key_value(
|
||||||
self,
|
self,
|
||||||
label: str,
|
label: str,
|
||||||
|
key: str,
|
||||||
value: str,
|
value: str,
|
||||||
*,
|
*,
|
||||||
notes: str = "",
|
notes: str = "",
|
||||||
@@ -421,6 +503,8 @@ class EntryManager:
|
|||||||
"type": EntryType.KEY_VALUE.value,
|
"type": EntryType.KEY_VALUE.value,
|
||||||
"kind": EntryType.KEY_VALUE.value,
|
"kind": EntryType.KEY_VALUE.value,
|
||||||
"label": label,
|
"label": label,
|
||||||
|
"key": key,
|
||||||
|
"modified_ts": int(time.time()),
|
||||||
"value": value,
|
"value": value,
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
"archived": archived,
|
"archived": archived,
|
||||||
@@ -473,6 +557,16 @@ class EntryManager:
|
|||||||
if index is None:
|
if index is None:
|
||||||
index = self.get_next_index()
|
index = self.get_next_index()
|
||||||
|
|
||||||
|
from .password_generation import derive_seed_phrase
|
||||||
|
from local_bip85.bip85 import BIP85
|
||||||
|
from bip_utils import Bip39SeedGenerator
|
||||||
|
|
||||||
|
seed_bytes = Bip39SeedGenerator(parent_seed).Generate()
|
||||||
|
bip85 = BIP85(seed_bytes)
|
||||||
|
phrase = derive_seed_phrase(bip85, index, words_num)
|
||||||
|
if not validate_seed_phrase(phrase):
|
||||||
|
raise ValueError("Derived seed phrase failed validation")
|
||||||
|
|
||||||
data = self._load_index()
|
data = self._load_index()
|
||||||
data.setdefault("entries", {})
|
data.setdefault("entries", {})
|
||||||
data["entries"][str(index)] = {
|
data["entries"][str(index)] = {
|
||||||
@@ -480,6 +574,7 @@ class EntryManager:
|
|||||||
"kind": EntryType.SEED.value,
|
"kind": EntryType.SEED.value,
|
||||||
"index": index,
|
"index": index,
|
||||||
"label": label,
|
"label": label,
|
||||||
|
"modified_ts": int(time.time()),
|
||||||
"word_count": words_num,
|
"word_count": words_num,
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
"archived": archived,
|
"archived": archived,
|
||||||
@@ -501,7 +596,7 @@ class EntryManager:
|
|||||||
):
|
):
|
||||||
raise ValueError("Entry is not a seed entry")
|
raise ValueError("Entry is not a seed entry")
|
||||||
|
|
||||||
from password_manager.password_generation import derive_seed_phrase
|
from .password_generation import derive_seed_phrase
|
||||||
from local_bip85.bip85 import BIP85
|
from local_bip85.bip85 import BIP85
|
||||||
from bip_utils import Bip39SeedGenerator
|
from bip_utils import Bip39SeedGenerator
|
||||||
|
|
||||||
@@ -530,7 +625,7 @@ class EntryManager:
|
|||||||
if index is None:
|
if index is None:
|
||||||
index = self.get_next_index()
|
index = self.get_next_index()
|
||||||
|
|
||||||
from password_manager.password_generation import derive_seed_phrase
|
from .password_generation import derive_seed_phrase
|
||||||
from local_bip85.bip85 import BIP85
|
from local_bip85.bip85 import BIP85
|
||||||
from bip_utils import Bip39SeedGenerator
|
from bip_utils import Bip39SeedGenerator
|
||||||
|
|
||||||
@@ -540,6 +635,8 @@ class EntryManager:
|
|||||||
word_count = 12
|
word_count = 12
|
||||||
|
|
||||||
seed_phrase = derive_seed_phrase(bip85, index, word_count)
|
seed_phrase = derive_seed_phrase(bip85, index, word_count)
|
||||||
|
if not validate_seed_phrase(seed_phrase):
|
||||||
|
raise ValueError("Derived managed account seed failed validation")
|
||||||
fingerprint = generate_fingerprint(seed_phrase)
|
fingerprint = generate_fingerprint(seed_phrase)
|
||||||
|
|
||||||
account_dir = self.fingerprint_dir / "accounts" / fingerprint
|
account_dir = self.fingerprint_dir / "accounts" / fingerprint
|
||||||
@@ -552,6 +649,7 @@ class EntryManager:
|
|||||||
"kind": EntryType.MANAGED_ACCOUNT.value,
|
"kind": EntryType.MANAGED_ACCOUNT.value,
|
||||||
"index": index,
|
"index": index,
|
||||||
"label": label,
|
"label": label,
|
||||||
|
"modified_ts": int(time.time()),
|
||||||
"word_count": word_count,
|
"word_count": word_count,
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
"fingerprint": fingerprint,
|
"fingerprint": fingerprint,
|
||||||
@@ -576,7 +674,7 @@ class EntryManager:
|
|||||||
):
|
):
|
||||||
raise ValueError("Entry is not a managed account entry")
|
raise ValueError("Entry is not a managed account entry")
|
||||||
|
|
||||||
from password_manager.password_generation import derive_seed_phrase
|
from .password_generation import derive_seed_phrase
|
||||||
from local_bip85.bip85 import BIP85
|
from local_bip85.bip85 import BIP85
|
||||||
from bip_utils import Bip39SeedGenerator
|
from bip_utils import Bip39SeedGenerator
|
||||||
|
|
||||||
@@ -682,7 +780,8 @@ class EntryManager:
|
|||||||
):
|
):
|
||||||
entry.setdefault("custom_fields", [])
|
entry.setdefault("custom_fields", [])
|
||||||
logger.debug(f"Retrieved entry at index {index}: {entry}")
|
logger.debug(f"Retrieved entry at index {index}: {entry}")
|
||||||
return entry
|
clean = {k: v for k, v in entry.items() if k != "modified_ts"}
|
||||||
|
return clean
|
||||||
else:
|
else:
|
||||||
logger.warning(f"No entry found at index {index}.")
|
logger.warning(f"No entry found at index {index}.")
|
||||||
print(colored(f"Warning: No entry found at index {index}.", "yellow"))
|
print(colored(f"Warning: No entry found at index {index}.", "yellow"))
|
||||||
@@ -708,9 +807,18 @@ class EntryManager:
|
|||||||
label: Optional[str] = None,
|
label: Optional[str] = None,
|
||||||
period: Optional[int] = None,
|
period: Optional[int] = None,
|
||||||
digits: Optional[int] = None,
|
digits: Optional[int] = None,
|
||||||
|
key: Optional[str] = None,
|
||||||
value: Optional[str] = None,
|
value: Optional[str] = None,
|
||||||
custom_fields: List[Dict[str, Any]] | None = None,
|
custom_fields: List[Dict[str, Any]] | None = None,
|
||||||
tags: list[str] | None = None,
|
tags: list[str] | None = None,
|
||||||
|
include_special_chars: bool | None = None,
|
||||||
|
allowed_special_chars: str | None = None,
|
||||||
|
special_mode: str | None = None,
|
||||||
|
exclude_ambiguous: bool | None = None,
|
||||||
|
min_uppercase: int | None = None,
|
||||||
|
min_lowercase: int | None = None,
|
||||||
|
min_digits: int | None = None,
|
||||||
|
min_special: int | None = None,
|
||||||
**legacy,
|
**legacy,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -724,6 +832,7 @@ class EntryManager:
|
|||||||
:param label: (Optional) The new label for the entry.
|
:param label: (Optional) The new label for the entry.
|
||||||
:param period: (Optional) The new TOTP period in seconds.
|
:param period: (Optional) The new TOTP period in seconds.
|
||||||
:param digits: (Optional) The new number of digits for TOTP codes.
|
:param digits: (Optional) The new number of digits for TOTP codes.
|
||||||
|
:param key: (Optional) New key for key/value entries.
|
||||||
:param value: (Optional) New value for key/value entries.
|
:param value: (Optional) New value for key/value entries.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
@@ -752,9 +861,18 @@ class EntryManager:
|
|||||||
"label": label,
|
"label": label,
|
||||||
"period": period,
|
"period": period,
|
||||||
"digits": digits,
|
"digits": digits,
|
||||||
|
"key": key,
|
||||||
"value": value,
|
"value": value,
|
||||||
"custom_fields": custom_fields,
|
"custom_fields": custom_fields,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
|
"include_special_chars": include_special_chars,
|
||||||
|
"allowed_special_chars": allowed_special_chars,
|
||||||
|
"special_mode": special_mode,
|
||||||
|
"exclude_ambiguous": exclude_ambiguous,
|
||||||
|
"min_uppercase": min_uppercase,
|
||||||
|
"min_lowercase": min_lowercase,
|
||||||
|
"min_digits": min_digits,
|
||||||
|
"min_special": min_special,
|
||||||
}
|
}
|
||||||
|
|
||||||
allowed = {
|
allowed = {
|
||||||
@@ -766,6 +884,14 @@ class EntryManager:
|
|||||||
"notes",
|
"notes",
|
||||||
"custom_fields",
|
"custom_fields",
|
||||||
"tags",
|
"tags",
|
||||||
|
"include_special_chars",
|
||||||
|
"allowed_special_chars",
|
||||||
|
"special_mode",
|
||||||
|
"exclude_ambiguous",
|
||||||
|
"min_uppercase",
|
||||||
|
"min_lowercase",
|
||||||
|
"min_digits",
|
||||||
|
"min_special",
|
||||||
},
|
},
|
||||||
EntryType.TOTP.value: {
|
EntryType.TOTP.value: {
|
||||||
"label",
|
"label",
|
||||||
@@ -778,6 +904,7 @@ class EntryManager:
|
|||||||
},
|
},
|
||||||
EntryType.KEY_VALUE.value: {
|
EntryType.KEY_VALUE.value: {
|
||||||
"label",
|
"label",
|
||||||
|
"key",
|
||||||
"value",
|
"value",
|
||||||
"archived",
|
"archived",
|
||||||
"notes",
|
"notes",
|
||||||
@@ -858,6 +985,9 @@ class EntryManager:
|
|||||||
EntryType.KEY_VALUE.value,
|
EntryType.KEY_VALUE.value,
|
||||||
EntryType.MANAGED_ACCOUNT.value,
|
EntryType.MANAGED_ACCOUNT.value,
|
||||||
):
|
):
|
||||||
|
if key is not None and entry_type == EntryType.KEY_VALUE.value:
|
||||||
|
entry["key"] = key
|
||||||
|
logger.debug(f"Updated key for index {index}.")
|
||||||
if value is not None:
|
if value is not None:
|
||||||
entry["value"] = value
|
entry["value"] = value
|
||||||
logger.debug(f"Updated value for index {index}.")
|
logger.debug(f"Updated value for index {index}.")
|
||||||
@@ -887,6 +1017,30 @@ class EntryManager:
|
|||||||
entry["tags"] = tags
|
entry["tags"] = tags
|
||||||
logger.debug(f"Updated tags for index {index}: {tags}")
|
logger.debug(f"Updated tags for index {index}: {tags}")
|
||||||
|
|
||||||
|
policy_updates: dict[str, Any] = {}
|
||||||
|
if include_special_chars is not None:
|
||||||
|
policy_updates["include_special_chars"] = include_special_chars
|
||||||
|
if allowed_special_chars is not None:
|
||||||
|
policy_updates["allowed_special_chars"] = allowed_special_chars
|
||||||
|
if special_mode is not None:
|
||||||
|
policy_updates["special_mode"] = special_mode
|
||||||
|
if exclude_ambiguous is not None:
|
||||||
|
policy_updates["exclude_ambiguous"] = exclude_ambiguous
|
||||||
|
if min_uppercase is not None:
|
||||||
|
policy_updates["min_uppercase"] = int(min_uppercase)
|
||||||
|
if min_lowercase is not None:
|
||||||
|
policy_updates["min_lowercase"] = int(min_lowercase)
|
||||||
|
if min_digits is not None:
|
||||||
|
policy_updates["min_digits"] = int(min_digits)
|
||||||
|
if min_special is not None:
|
||||||
|
policy_updates["min_special"] = int(min_special)
|
||||||
|
if policy_updates:
|
||||||
|
entry_policy = entry.get("policy", {})
|
||||||
|
entry_policy.update(policy_updates)
|
||||||
|
entry["policy"] = entry_policy
|
||||||
|
|
||||||
|
entry["modified_ts"] = int(time.time())
|
||||||
|
|
||||||
data["entries"][str(index)] = entry
|
data["entries"][str(index)] = entry
|
||||||
logger.debug(f"Modified entry at index {index}: {entry}")
|
logger.debug(f"Modified entry at index {index}: {entry}")
|
||||||
|
|
||||||
@@ -922,10 +1076,17 @@ class EntryManager:
|
|||||||
include_archived: bool = False,
|
include_archived: bool = False,
|
||||||
verbose: bool = True,
|
verbose: bool = True,
|
||||||
) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]:
|
) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]:
|
||||||
"""List entries in the index with optional sorting and filtering.
|
"""List entries sorted and filtered according to the provided options.
|
||||||
|
|
||||||
By default archived entries are omitted unless ``include_archived`` is
|
Parameters
|
||||||
``True``.
|
----------
|
||||||
|
sort_by:
|
||||||
|
Field to sort by. Supported values are ``"index"``, ``"label"`` and
|
||||||
|
``"updated"``.
|
||||||
|
filter_kind:
|
||||||
|
Optional entry kind to restrict the results.
|
||||||
|
|
||||||
|
Archived entries are omitted unless ``include_archived`` is ``True``.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = self._load_index()
|
data = self._load_index()
|
||||||
@@ -941,11 +1102,14 @@ class EntryManager:
|
|||||||
idx_str, entry = item
|
idx_str, entry = item
|
||||||
if sort_by == "index":
|
if sort_by == "index":
|
||||||
return int(idx_str)
|
return int(idx_str)
|
||||||
if sort_by in {"website", "label"}:
|
if sort_by == "label":
|
||||||
|
# labels are stored in the index so no additional
|
||||||
|
# decryption is required when sorting
|
||||||
return entry.get("label", entry.get("website", "")).lower()
|
return entry.get("label", entry.get("website", "")).lower()
|
||||||
if sort_by == "username":
|
if sort_by == "updated":
|
||||||
return entry.get("username", "").lower()
|
# sort newest first
|
||||||
raise ValueError("sort_by must be 'index', 'label', or 'username'")
|
return -int(entry.get("updated", 0))
|
||||||
|
raise ValueError("sort_by must be 'index', 'label', or 'updated'")
|
||||||
|
|
||||||
sorted_items = sorted(entries_data.items(), key=sort_key)
|
sorted_items = sorted(entries_data.items(), key=sort_key)
|
||||||
|
|
||||||
@@ -1045,9 +1209,14 @@ class EntryManager:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def search_entries(
|
def search_entries(
|
||||||
self, query: str
|
self, query: str, kinds: List[str] | None = None
|
||||||
) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]:
|
) -> List[Tuple[int, str, Optional[str], Optional[str], bool, EntryType]]:
|
||||||
"""Return entries matching the query across common fields."""
|
"""Return entries matching ``query`` across whitelisted metadata fields.
|
||||||
|
|
||||||
|
Each match is represented as ``(index, label, username, url, archived, etype)``
|
||||||
|
where ``etype`` is the :class:`EntryType` of the entry.
|
||||||
|
"""
|
||||||
|
|
||||||
data = self._load_index()
|
data = self._load_index()
|
||||||
entries_data = data.get("entries", {})
|
entries_data = data.get("entries", {})
|
||||||
|
|
||||||
@@ -1055,76 +1224,40 @@ class EntryManager:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
query_lower = query.lower()
|
query_lower = query.lower()
|
||||||
results: List[Tuple[int, str, Optional[str], Optional[str], bool]] = []
|
results: List[
|
||||||
|
Tuple[int, str, Optional[str], Optional[str], bool, EntryType]
|
||||||
|
] = []
|
||||||
|
|
||||||
for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])):
|
for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])):
|
||||||
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
|
etype = EntryType(
|
||||||
|
entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
|
||||||
|
)
|
||||||
|
|
||||||
|
if kinds is not None and etype.value not in kinds:
|
||||||
|
continue
|
||||||
|
|
||||||
label = entry.get("label", entry.get("website", ""))
|
label = entry.get("label", entry.get("website", ""))
|
||||||
notes = entry.get("notes", "")
|
username = (
|
||||||
|
entry.get("username", "") if etype == EntryType.PASSWORD else None
|
||||||
|
)
|
||||||
|
url = entry.get("url", "") if etype == EntryType.PASSWORD else None
|
||||||
tags = entry.get("tags", [])
|
tags = entry.get("tags", [])
|
||||||
|
archived = entry.get("archived", entry.get("blacklisted", False))
|
||||||
|
|
||||||
label_match = query_lower in label.lower()
|
label_match = query_lower in label.lower()
|
||||||
notes_match = query_lower in notes.lower()
|
username_match = bool(username) and query_lower in username.lower()
|
||||||
|
url_match = bool(url) and query_lower in url.lower()
|
||||||
tags_match = any(query_lower in str(t).lower() for t in tags)
|
tags_match = any(query_lower in str(t).lower() for t in tags)
|
||||||
|
|
||||||
if etype == EntryType.PASSWORD.value:
|
if label_match or username_match or url_match or tags_match:
|
||||||
username = entry.get("username", "")
|
|
||||||
url = entry.get("url", "")
|
|
||||||
custom_fields = entry.get("custom_fields", [])
|
|
||||||
custom_match = any(
|
|
||||||
query_lower in str(cf.get("label", "")).lower()
|
|
||||||
or query_lower in str(cf.get("value", "")).lower()
|
|
||||||
for cf in custom_fields
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
label_match
|
|
||||||
or query_lower in username.lower()
|
|
||||||
or query_lower in url.lower()
|
|
||||||
or notes_match
|
|
||||||
or custom_match
|
|
||||||
or tags_match
|
|
||||||
):
|
|
||||||
results.append(
|
results.append(
|
||||||
(
|
(
|
||||||
int(idx),
|
int(idx),
|
||||||
label,
|
label,
|
||||||
username,
|
username if username is not None else None,
|
||||||
url,
|
url if url is not None else None,
|
||||||
entry.get("archived", entry.get("blacklisted", False)),
|
archived,
|
||||||
)
|
etype,
|
||||||
)
|
|
||||||
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)),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@@ -1,4 +1,4 @@
|
|||||||
# password_manager/entry_types.py
|
# seedpass.core/entry_types.py
|
||||||
"""Enumerations for entry types used by SeedPass."""
|
"""Enumerations for entry types used by SeedPass."""
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
# password_manager/password_generation.py
|
# seedpass.core/password_generation.py
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Password Generation Module
|
Password Generation Module
|
||||||
@@ -42,8 +42,13 @@ except ModuleNotFoundError: # pragma: no cover - fallback for removed module
|
|||||||
|
|
||||||
from local_bip85.bip85 import BIP85
|
from local_bip85.bip85 import BIP85
|
||||||
|
|
||||||
from constants import DEFAULT_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH
|
from constants import (
|
||||||
from password_manager.encryption import EncryptionManager
|
DEFAULT_PASSWORD_LENGTH,
|
||||||
|
MIN_PASSWORD_LENGTH,
|
||||||
|
MAX_PASSWORD_LENGTH,
|
||||||
|
SAFE_SPECIAL_CHARS,
|
||||||
|
)
|
||||||
|
from .encryption import EncryptionManager
|
||||||
|
|
||||||
# Instantiate the logger
|
# Instantiate the logger
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -51,12 +56,27 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PasswordPolicy:
|
class PasswordPolicy:
|
||||||
"""Minimum complexity requirements for generated passwords."""
|
"""Minimum complexity requirements for generated passwords.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
min_uppercase: Minimum required uppercase letters.
|
||||||
|
min_lowercase: Minimum required lowercase letters.
|
||||||
|
min_digits: Minimum required digits.
|
||||||
|
min_special: Minimum required special characters.
|
||||||
|
include_special_chars: Whether to include any special characters.
|
||||||
|
allowed_special_chars: Explicit set of allowed special characters.
|
||||||
|
special_mode: Preset mode for special characters (e.g. "safe").
|
||||||
|
exclude_ambiguous: Exclude easily confused characters like ``O`` and ``0``.
|
||||||
|
"""
|
||||||
|
|
||||||
min_uppercase: int = 2
|
min_uppercase: int = 2
|
||||||
min_lowercase: int = 2
|
min_lowercase: int = 2
|
||||||
min_digits: int = 2
|
min_digits: int = 2
|
||||||
min_special: int = 2
|
min_special: int = 2
|
||||||
|
include_special_chars: bool = True
|
||||||
|
allowed_special_chars: str | None = None
|
||||||
|
special_mode: str | None = None
|
||||||
|
exclude_ambiguous: bool = False
|
||||||
|
|
||||||
|
|
||||||
class PasswordGenerator:
|
class PasswordGenerator:
|
||||||
@@ -175,9 +195,28 @@ class PasswordGenerator:
|
|||||||
|
|
||||||
dk = self._derive_password_entropy(index=index)
|
dk = self._derive_password_entropy(index=index)
|
||||||
|
|
||||||
all_allowed = string.ascii_letters + string.digits + string.punctuation
|
letters = string.ascii_letters
|
||||||
|
digits = string.digits
|
||||||
|
|
||||||
|
if self.policy.exclude_ambiguous:
|
||||||
|
ambiguous = "O0Il1"
|
||||||
|
letters = "".join(c for c in letters if c not in ambiguous)
|
||||||
|
digits = "".join(c for c in digits if c not in ambiguous)
|
||||||
|
|
||||||
|
if not self.policy.include_special_chars:
|
||||||
|
allowed_special = ""
|
||||||
|
elif self.policy.allowed_special_chars is not None:
|
||||||
|
allowed_special = self.policy.allowed_special_chars
|
||||||
|
elif self.policy.special_mode == "safe":
|
||||||
|
allowed_special = SAFE_SPECIAL_CHARS
|
||||||
|
else:
|
||||||
|
allowed_special = string.punctuation
|
||||||
|
|
||||||
|
all_allowed = letters + digits + allowed_special
|
||||||
password = self._map_entropy_to_chars(dk, all_allowed)
|
password = self._map_entropy_to_chars(dk, all_allowed)
|
||||||
password = self._enforce_complexity(password, all_allowed, dk)
|
password = self._enforce_complexity(
|
||||||
|
password, all_allowed, allowed_special, dk
|
||||||
|
)
|
||||||
password = self._shuffle_deterministically(password, dk)
|
password = self._shuffle_deterministically(password, dk)
|
||||||
|
|
||||||
# Ensure password length by extending if necessary
|
# Ensure password length by extending if necessary
|
||||||
@@ -195,7 +234,9 @@ class PasswordGenerator:
|
|||||||
# produced above when the requested length is shorter than the
|
# produced above when the requested length is shorter than the
|
||||||
# initial entropy size.
|
# initial entropy size.
|
||||||
password = password[:length]
|
password = password[:length]
|
||||||
password = self._enforce_complexity(password, all_allowed, dk)
|
password = self._enforce_complexity(
|
||||||
|
password, all_allowed, allowed_special, dk
|
||||||
|
)
|
||||||
password = self._shuffle_deterministically(password, dk)
|
password = self._shuffle_deterministically(password, dk)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Final password (trimmed to {length} chars with complexity enforced): {password}"
|
f"Final password (trimmed to {length} chars with complexity enforced): {password}"
|
||||||
@@ -208,7 +249,9 @@ class PasswordGenerator:
|
|||||||
print(colored(f"Error: Failed to generate password: {e}", "red"))
|
print(colored(f"Error: Failed to generate password: {e}", "red"))
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def _enforce_complexity(self, password: str, alphabet: str, dk: bytes) -> str:
|
def _enforce_complexity(
|
||||||
|
self, password: str, alphabet: str, allowed_special: str, dk: bytes
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Ensures that the password contains at least two uppercase letters, two lowercase letters,
|
Ensures that the password contains at least two uppercase letters, two lowercase letters,
|
||||||
two digits, and two special characters, modifying it deterministically if necessary.
|
two digits, and two special characters, modifying it deterministically if necessary.
|
||||||
@@ -226,7 +269,13 @@ class PasswordGenerator:
|
|||||||
uppercase = string.ascii_uppercase
|
uppercase = string.ascii_uppercase
|
||||||
lowercase = string.ascii_lowercase
|
lowercase = string.ascii_lowercase
|
||||||
digits = string.digits
|
digits = string.digits
|
||||||
special = string.punctuation
|
special = allowed_special
|
||||||
|
|
||||||
|
if self.policy.exclude_ambiguous:
|
||||||
|
ambiguous = "O0Il1"
|
||||||
|
uppercase = "".join(c for c in uppercase if c not in ambiguous)
|
||||||
|
lowercase = "".join(c for c in lowercase if c not in ambiguous)
|
||||||
|
digits = "".join(c for c in digits if c not in ambiguous)
|
||||||
|
|
||||||
password_chars = list(password)
|
password_chars = list(password)
|
||||||
|
|
||||||
@@ -244,7 +293,7 @@ class PasswordGenerator:
|
|||||||
min_upper = self.policy.min_uppercase
|
min_upper = self.policy.min_uppercase
|
||||||
min_lower = self.policy.min_lowercase
|
min_lower = self.policy.min_lowercase
|
||||||
min_digits = self.policy.min_digits
|
min_digits = self.policy.min_digits
|
||||||
min_special = self.policy.min_special
|
min_special = self.policy.min_special if special else 0
|
||||||
|
|
||||||
# Initialize derived key index
|
# Initialize derived key index
|
||||||
dk_index = 0
|
dk_index = 0
|
||||||
@@ -282,7 +331,7 @@ class PasswordGenerator:
|
|||||||
password_chars[index] = char
|
password_chars[index] = char
|
||||||
logger.debug(f"Added digit '{char}' at position {index}.")
|
logger.debug(f"Added digit '{char}' at position {index}.")
|
||||||
|
|
||||||
if current_special < min_special:
|
if special and current_special < min_special:
|
||||||
for _ in range(min_special - current_special):
|
for _ in range(min_special - current_special):
|
||||||
index = get_dk_value() % len(password_chars)
|
index = get_dk_value() % len(password_chars)
|
||||||
char = special[get_dk_value() % len(special)]
|
char = special[get_dk_value() % len(special)]
|
||||||
@@ -292,6 +341,7 @@ class PasswordGenerator:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Additional deterministic inclusion of symbols to increase score
|
# Additional deterministic inclusion of symbols to increase score
|
||||||
|
if special:
|
||||||
symbol_target = 3 # Increase target number of symbols
|
symbol_target = 3 # Increase target number of symbols
|
||||||
current_symbols = sum(1 for c in password_chars if c in special)
|
current_symbols = sum(1 for c in password_chars if c in special)
|
||||||
additional_symbols_needed = max(symbol_target - current_symbols, 0)
|
additional_symbols_needed = max(symbol_target - current_symbols, 0)
|
||||||
@@ -302,13 +352,18 @@ class PasswordGenerator:
|
|||||||
index = get_dk_value() % len(password_chars)
|
index = get_dk_value() % len(password_chars)
|
||||||
char = special[get_dk_value() % len(special)]
|
char = special[get_dk_value() % len(special)]
|
||||||
password_chars[index] = char
|
password_chars[index] = char
|
||||||
logger.debug(f"Added additional symbol '{char}' at position {index}.")
|
logger.debug(
|
||||||
|
f"Added additional symbol '{char}' at position {index}."
|
||||||
|
)
|
||||||
|
|
||||||
# Ensure balanced distribution by assigning different character types to specific segments
|
# Ensure balanced distribution by assigning different character types to specific segments
|
||||||
# Example: Divide password into segments and assign different types
|
# Example: Divide password into segments and assign different types
|
||||||
segment_length = len(password_chars) // 4
|
char_types = [uppercase, lowercase, digits]
|
||||||
|
if special:
|
||||||
|
char_types.append(special)
|
||||||
|
segment_length = len(password_chars) // len(char_types)
|
||||||
if segment_length > 0:
|
if segment_length > 0:
|
||||||
for i, char_type in enumerate([uppercase, lowercase, digits, special]):
|
for i, char_type in enumerate(char_types):
|
||||||
segment_start = i * segment_length
|
segment_start = i * segment_length
|
||||||
segment_end = segment_start + segment_length
|
segment_end = segment_start + segment_length
|
||||||
if segment_end > len(password_chars):
|
if segment_end > len(password_chars):
|
||||||
@@ -330,7 +385,11 @@ class PasswordGenerator:
|
|||||||
char = digits[get_dk_value() % len(digits)]
|
char = digits[get_dk_value() % len(digits)]
|
||||||
password_chars[j] = char
|
password_chars[j] = char
|
||||||
logger.debug(f"Assigned digit '{char}' to position {j}.")
|
logger.debug(f"Assigned digit '{char}' to position {j}.")
|
||||||
elif i == 3 and password_chars[j] not in special:
|
elif (
|
||||||
|
special
|
||||||
|
and i == len(char_types) - 1
|
||||||
|
and password_chars[j] not in special
|
||||||
|
):
|
||||||
char = special[get_dk_value() % len(special)]
|
char = special[get_dk_value() % len(special)]
|
||||||
password_chars[j] = char
|
password_chars[j] = char
|
||||||
logger.debug(
|
logger.debug(
|
@@ -12,14 +12,14 @@ import asyncio
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from password_manager.vault import Vault
|
from .vault import Vault
|
||||||
from password_manager.backup import BackupManager
|
from .backup import BackupManager
|
||||||
from nostr.client import NostrClient
|
from nostr.client import NostrClient
|
||||||
from utils.key_derivation import (
|
from utils.key_derivation import (
|
||||||
derive_index_key,
|
derive_index_key,
|
||||||
EncryptionMode,
|
EncryptionMode,
|
||||||
)
|
)
|
||||||
from password_manager.encryption import EncryptionManager
|
from .encryption import EncryptionManager
|
||||||
from utils.checksum import json_checksum, canonical_json_dumps
|
from utils.checksum import json_checksum, canonical_json_dumps
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
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)
|
20
src/seedpass/core/stats_manager.py
Normal file
20
src/seedpass/core/stats_manager.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""Manage display of stats screens."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class StatsManager:
|
||||||
|
"""Track whether stats have been displayed."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._displayed = False
|
||||||
|
|
||||||
|
def display_stats_once(self, manager) -> None:
|
||||||
|
"""Display stats using ``manager`` once per reset."""
|
||||||
|
if not self._displayed:
|
||||||
|
manager.display_stats()
|
||||||
|
self._displayed = True
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset the displayed flag."""
|
||||||
|
self._displayed = False
|
@@ -61,11 +61,11 @@ class Vault:
|
|||||||
return self.encryption_manager.get_encrypted_index()
|
return self.encryption_manager.get_encrypted_index()
|
||||||
|
|
||||||
def decrypt_and_save_index_from_nostr(
|
def decrypt_and_save_index_from_nostr(
|
||||||
self, encrypted_data: bytes, *, strict: bool = True
|
self, encrypted_data: bytes, *, strict: bool = True, merge: bool = False
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Decrypt Nostr payload and overwrite the local index."""
|
"""Decrypt Nostr payload and update the local index."""
|
||||||
return self.encryption_manager.decrypt_and_save_index_from_nostr(
|
return self.encryption_manager.decrypt_and_save_index_from_nostr(
|
||||||
encrypted_data, strict=strict
|
encrypted_data, strict=strict, merge=merge
|
||||||
)
|
)
|
||||||
|
|
||||||
# ----- Config helpers -----
|
# ----- Config helpers -----
|
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()
|
486
src/seedpass_gui/app.py
Normal file
486
src/seedpass_gui/app.py
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
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)
|
||||||
|
manager = getattr(self.nostr, "_manager", None)
|
||||||
|
if manager is not None:
|
||||||
|
manager.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
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.key_input = toga.TextInput(style=Pack(flex=1))
|
||||||
|
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("Key"))
|
||||||
|
box.add(self.key_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.key_input.value = entry.get("key", "")
|
||||||
|
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
|
||||||
|
key = self.key_input.value or None
|
||||||
|
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, key or "", 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({"key": key, "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, _etype 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]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.vault import Vault
|
from seedpass.core.vault import Vault
|
||||||
from password_manager.encryption import EncryptionManager
|
from seedpass.core.encryption import EncryptionManager
|
||||||
from utils.key_derivation import (
|
from utils.key_derivation import (
|
||||||
derive_index_key,
|
derive_index_key,
|
||||||
derive_key_from_password,
|
derive_key_from_password,
|
||||||
|
@@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
|||||||
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.entry_management import EntryManager
|
from seedpass.core.entry_management import EntryManager
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.manager import PasswordManager, EncryptionMode
|
from seedpass.core.manager import PasswordManager, EncryptionMode
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
class FakePasswordGenerator:
|
class FakePasswordGenerator:
|
||||||
|
@@ -4,9 +4,9 @@ from tempfile import TemporaryDirectory
|
|||||||
|
|
||||||
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||||
|
|
||||||
from password_manager.entry_management import EntryManager
|
from seedpass.core.entry_management import EntryManager
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
def test_entry_manager_additional_backup(monkeypatch):
|
def test_entry_manager_additional_backup(monkeypatch):
|
||||||
|
@@ -8,13 +8,16 @@ from fastapi.testclient import TestClient
|
|||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from seedpass import api
|
from seedpass import api
|
||||||
|
from seedpass.core.entry_types import EntryType
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client(monkeypatch):
|
def client(monkeypatch):
|
||||||
dummy = SimpleNamespace(
|
dummy = SimpleNamespace(
|
||||||
entry_manager=SimpleNamespace(
|
entry_manager=SimpleNamespace(
|
||||||
search_entries=lambda q: [(1, "Site", "user", "url", False)],
|
search_entries=lambda q: [
|
||||||
|
(1, "Site", "user", "url", False, EntryType.PASSWORD)
|
||||||
|
],
|
||||||
retrieve_entry=lambda i: {"label": "Site"},
|
retrieve_entry=lambda i: {"label": "Site"},
|
||||||
add_entry=lambda *a, **k: 1,
|
add_entry=lambda *a, **k: 1,
|
||||||
modify_entry=lambda *a, **k: None,
|
modify_entry=lambda *a, **k: None,
|
||||||
@@ -179,12 +182,16 @@ def test_change_password_route(client):
|
|||||||
cl, token = client
|
cl, token = client
|
||||||
called = {}
|
called = {}
|
||||||
|
|
||||||
api._pm.change_password = lambda: called.setdefault("called", True)
|
api._pm.change_password = lambda o, n: called.setdefault("called", (o, n))
|
||||||
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
|
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
|
||||||
res = cl.post("/api/v1/change-password", headers=headers)
|
res = cl.post(
|
||||||
|
"/api/v1/change-password",
|
||||||
|
headers=headers,
|
||||||
|
json={"old": "old", "new": "new"},
|
||||||
|
)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
assert res.json() == {"status": "ok"}
|
assert res.json() == {"status": "ok"}
|
||||||
assert called.get("called") is True
|
assert called.get("called") == ("old", "new")
|
||||||
assert res.headers.get("access-control-allow-origin") == "http://example.com"
|
assert res.headers.get("access-control-allow-origin") == "http://example.com"
|
||||||
|
|
||||||
|
|
||||||
|
@@ -5,6 +5,8 @@ import pytest
|
|||||||
from seedpass import api
|
from seedpass import api
|
||||||
from test_api import client
|
from test_api import client
|
||||||
from helpers import dummy_nostr_client
|
from helpers import dummy_nostr_client
|
||||||
|
import string
|
||||||
|
from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy
|
||||||
from nostr.client import NostrClient, DEFAULT_RELAYS
|
from nostr.client import NostrClient, DEFAULT_RELAYS
|
||||||
|
|
||||||
|
|
||||||
@@ -291,8 +293,8 @@ def test_vault_lock_endpoint(client):
|
|||||||
assert res.json() == {"status": "locked"}
|
assert res.json() == {"status": "locked"}
|
||||||
assert called.get("locked") is True
|
assert called.get("locked") is True
|
||||||
assert api._pm.locked is True
|
assert api._pm.locked is True
|
||||||
api._pm.unlock_vault = lambda: setattr(api._pm, "locked", False)
|
api._pm.unlock_vault = lambda pw: setattr(api._pm, "locked", False)
|
||||||
api._pm.unlock_vault()
|
api._pm.unlock_vault("pw")
|
||||||
assert api._pm.locked is False
|
assert api._pm.locked is False
|
||||||
|
|
||||||
|
|
||||||
@@ -401,3 +403,55 @@ def test_relay_management_endpoints(client, dummy_nostr_client, monkeypatch):
|
|||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
assert called.get("init") is True
|
assert called.get("init") is True
|
||||||
assert api._pm.nostr_client.relays == list(DEFAULT_RELAYS)
|
assert api._pm.nostr_client.relays == list(DEFAULT_RELAYS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_password_no_special_chars(client):
|
||||||
|
cl, token = client
|
||||||
|
|
||||||
|
class DummyEnc:
|
||||||
|
def derive_seed_from_mnemonic(self, mnemonic):
|
||||||
|
return b"\x00" * 32
|
||||||
|
|
||||||
|
class DummyBIP85:
|
||||||
|
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
|
||||||
|
return bytes(range(bytes_len))
|
||||||
|
|
||||||
|
api._pm.password_generator = PasswordGenerator(DummyEnc(), "seed", DummyBIP85())
|
||||||
|
api._pm.parent_seed = "seed"
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
res = cl.post(
|
||||||
|
"/api/v1/password",
|
||||||
|
json={"length": 16, "include_special_chars": False},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
pw = res.json()["password"]
|
||||||
|
assert not any(c in string.punctuation for c in pw)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_password_allowed_chars(client):
|
||||||
|
cl, token = client
|
||||||
|
|
||||||
|
class DummyEnc:
|
||||||
|
def derive_seed_from_mnemonic(self, mnemonic):
|
||||||
|
return b"\x00" * 32
|
||||||
|
|
||||||
|
class DummyBIP85:
|
||||||
|
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
|
||||||
|
return bytes((index + i) % 256 for i in range(bytes_len))
|
||||||
|
|
||||||
|
api._pm.password_generator = PasswordGenerator(DummyEnc(), "seed", DummyBIP85())
|
||||||
|
api._pm.parent_seed = "seed"
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
allowed = "@$"
|
||||||
|
res = cl.post(
|
||||||
|
"/api/v1/password",
|
||||||
|
json={"length": 16, "allowed_special_chars": allowed},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
pw = res.json()["password"]
|
||||||
|
specials = [c for c in pw if c in string.punctuation]
|
||||||
|
assert specials and all(c in allowed for c in specials)
|
||||||
|
@@ -8,10 +8,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
|||||||
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.entry_management import EntryManager
|
from seedpass.core.entry_management import EntryManager
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.manager import PasswordManager, EncryptionMode
|
from seedpass.core.manager import PasswordManager, EncryptionMode
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
class FakePasswordGenerator:
|
class FakePasswordGenerator:
|
||||||
|
@@ -6,9 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
|||||||
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.entry_management import EntryManager
|
from seedpass.core.entry_management import EntryManager
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
|
from seedpass.core.entry_types import EntryType
|
||||||
|
|
||||||
|
|
||||||
def setup_entry_mgr(tmp_path: Path) -> EntryManager:
|
def setup_entry_mgr(tmp_path: Path) -> EntryManager:
|
||||||
@@ -26,7 +27,9 @@ def test_archive_nonpassword_list_search():
|
|||||||
idx = em.search_entries("Example")[0][0]
|
idx = em.search_entries("Example")[0][0]
|
||||||
|
|
||||||
assert em.list_entries() == [(idx, "Example", None, None, False)]
|
assert em.list_entries() == [(idx, "Example", None, None, False)]
|
||||||
assert em.search_entries("Example") == [(idx, "Example", None, None, False)]
|
assert em.search_entries("Example") == [
|
||||||
|
(idx, "Example", None, None, False, EntryType.TOTP)
|
||||||
|
]
|
||||||
|
|
||||||
em.archive_entry(idx)
|
em.archive_entry(idx)
|
||||||
assert em.retrieve_entry(idx)["archived"] is True
|
assert em.retrieve_entry(idx)["archived"] is True
|
||||||
@@ -34,9 +37,13 @@ def test_archive_nonpassword_list_search():
|
|||||||
assert em.list_entries(include_archived=True) == [
|
assert em.list_entries(include_archived=True) == [
|
||||||
(idx, "Example", None, None, True)
|
(idx, "Example", None, None, True)
|
||||||
]
|
]
|
||||||
assert em.search_entries("Example") == [(idx, "Example", None, None, True)]
|
assert em.search_entries("Example") == [
|
||||||
|
(idx, "Example", None, None, True, EntryType.TOTP)
|
||||||
|
]
|
||||||
|
|
||||||
em.restore_entry(idx)
|
em.restore_entry(idx)
|
||||||
assert em.retrieve_entry(idx)["archived"] is False
|
assert em.retrieve_entry(idx)["archived"] is False
|
||||||
assert em.list_entries() == [(idx, "Example", None, None, False)]
|
assert em.list_entries() == [(idx, "Example", None, None, False)]
|
||||||
assert em.search_entries("Example") == [(idx, "Example", None, None, False)]
|
assert em.search_entries("Example") == [
|
||||||
|
(idx, "Example", None, None, False, EntryType.TOTP)
|
||||||
|
]
|
||||||
|
@@ -10,10 +10,11 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
|||||||
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.entry_management import EntryManager
|
from seedpass.core.entry_management import EntryManager
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
from password_manager.manager import PasswordManager, EncryptionMode
|
from seedpass.core.manager import PasswordManager, EncryptionMode
|
||||||
|
from seedpass.core.entry_types import EntryType
|
||||||
|
|
||||||
|
|
||||||
def setup_entry_mgr(tmp_path: Path) -> EntryManager:
|
def setup_entry_mgr(tmp_path: Path) -> EntryManager:
|
||||||
@@ -31,7 +32,7 @@ def test_archive_restore_affects_listing_and_search():
|
|||||||
|
|
||||||
assert em.list_entries() == [(idx, "example.com", "alice", "", False)]
|
assert em.list_entries() == [(idx, "example.com", "alice", "", False)]
|
||||||
assert em.search_entries("example") == [
|
assert em.search_entries("example") == [
|
||||||
(idx, "example.com", "alice", "", False)
|
(idx, "example.com", "alice", "", False, EntryType.PASSWORD)
|
||||||
]
|
]
|
||||||
|
|
||||||
em.archive_entry(idx)
|
em.archive_entry(idx)
|
||||||
@@ -40,13 +41,15 @@ def test_archive_restore_affects_listing_and_search():
|
|||||||
assert em.list_entries(include_archived=True) == [
|
assert em.list_entries(include_archived=True) == [
|
||||||
(idx, "example.com", "alice", "", True)
|
(idx, "example.com", "alice", "", True)
|
||||||
]
|
]
|
||||||
assert em.search_entries("example") == [(idx, "example.com", "alice", "", True)]
|
assert em.search_entries("example") == [
|
||||||
|
(idx, "example.com", "alice", "", True, EntryType.PASSWORD)
|
||||||
|
]
|
||||||
|
|
||||||
em.restore_entry(idx)
|
em.restore_entry(idx)
|
||||||
assert em.retrieve_entry(idx)["archived"] is False
|
assert em.retrieve_entry(idx)["archived"] is False
|
||||||
assert em.list_entries() == [(idx, "example.com", "alice", "", False)]
|
assert em.list_entries() == [(idx, "example.com", "alice", "", False)]
|
||||||
assert em.search_entries("example") == [
|
assert em.search_entries("example") == [
|
||||||
(idx, "example.com", "alice", "", False)
|
(idx, "example.com", "alice", "", False, EntryType.PASSWORD)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@@ -6,7 +6,7 @@ import sys
|
|||||||
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.manager import PasswordManager
|
from seedpass.core.manager import PasswordManager
|
||||||
from constants import MIN_HEALTHY_RELAYS
|
from constants import MIN_HEALTHY_RELAYS
|
||||||
|
|
||||||
|
|
||||||
|
@@ -4,8 +4,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.manager import PasswordManager
|
from seedpass.core.manager import PasswordManager
|
||||||
import password_manager.manager as manager_module
|
import seedpass.core.manager as manager_module
|
||||||
|
|
||||||
|
|
||||||
def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path):
|
def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path):
|
||||||
@@ -22,17 +22,12 @@ def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path):
|
|||||||
pm.config_manager = SimpleNamespace(get_quick_unlock=lambda: False)
|
pm.config_manager = SimpleNamespace(get_quick_unlock=lambda: False)
|
||||||
|
|
||||||
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "1")
|
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "1")
|
||||||
monkeypatch.setattr(
|
|
||||||
"password_manager.manager.prompt_existing_password", lambda *_a, **_k: "pw"
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
PasswordManager, "setup_encryption_manager", lambda *a, **k: True
|
PasswordManager, "setup_encryption_manager", lambda *a, **k: True
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda *a, **k: None)
|
monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda *a, **k: None)
|
||||||
monkeypatch.setattr(PasswordManager, "initialize_managers", lambda *a, **k: None)
|
monkeypatch.setattr(PasswordManager, "initialize_managers", lambda *a, **k: None)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr("seedpass.core.manager.NostrClient", lambda *a, **kw: object())
|
||||||
"password_manager.manager.NostrClient", lambda *a, **kw: object()
|
|
||||||
)
|
|
||||||
|
|
||||||
calls = {"count": 0}
|
calls = {"count": 0}
|
||||||
|
|
||||||
@@ -41,7 +36,7 @@ def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path):
|
|||||||
|
|
||||||
monkeypatch.setattr(PasswordManager, "start_background_sync", fake_bg)
|
monkeypatch.setattr(PasswordManager, "start_background_sync", fake_bg)
|
||||||
|
|
||||||
assert pm.handle_switch_fingerprint()
|
assert pm.handle_switch_fingerprint(password="pw")
|
||||||
assert calls["count"] == 1
|
assert calls["count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@@ -4,8 +4,8 @@ from tempfile import TemporaryDirectory
|
|||||||
|
|
||||||
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||||
|
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
def test_backup_interval(monkeypatch):
|
def test_backup_interval(monkeypatch):
|
||||||
|
@@ -8,8 +8,8 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
|||||||
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
def test_backup_restore_workflow(monkeypatch):
|
def test_backup_restore_workflow(monkeypatch):
|
||||||
|
@@ -5,7 +5,7 @@ import pytest
|
|||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from local_bip85.bip85 import BIP85, Bip85Error
|
from local_bip85.bip85 import BIP85, Bip85Error
|
||||||
from password_manager.password_generation import (
|
from seedpass.core.password_generation import (
|
||||||
derive_ssh_key,
|
derive_ssh_key,
|
||||||
derive_seed_phrase,
|
derive_seed_phrase,
|
||||||
)
|
)
|
||||||
|
73
src/tests/test_cli_core_services.py
Normal file
73
src/tests/test_cli_core_services.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import typer
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from seedpass import cli
|
||||||
|
from seedpass.cli import app
|
||||||
|
from seedpass.core.entry_types import EntryType
|
||||||
|
|
||||||
|
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, EntryType.PASSWORD)]
|
||||||
|
|
||||||
|
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 typer.testing import CliRunner
|
||||||
from seedpass import cli
|
from seedpass import cli
|
||||||
from password_manager.entry_types import EntryType
|
from seedpass.core.entry_types import EntryType
|
||||||
|
|
||||||
|
|
||||||
class DummyPM:
|
class DummyPM:
|
||||||
@@ -17,16 +17,18 @@ class DummyPM:
|
|||||||
list_entries=lambda sort_by="index", filter_kind=None, include_archived=False: [
|
list_entries=lambda sort_by="index", filter_kind=None, include_archived=False: [
|
||||||
(1, "Label", "user", "url", False)
|
(1, "Label", "user", "url", False)
|
||||||
],
|
],
|
||||||
search_entries=lambda q: [(1, "GitHub", "user", "", False)],
|
search_entries=lambda q, kinds=None: [
|
||||||
|
(1, "GitHub", "user", "", False, EntryType.PASSWORD)
|
||||||
|
],
|
||||||
retrieve_entry=lambda idx: {"type": EntryType.PASSWORD.value, "length": 8},
|
retrieve_entry=lambda idx: {"type": EntryType.PASSWORD.value, "length": 8},
|
||||||
get_totp_code=lambda idx, seed: "123456",
|
get_totp_code=lambda idx, seed: "123456",
|
||||||
add_entry=lambda label, length, username, url: 1,
|
add_entry=lambda label, length, username, url, **kwargs: 1,
|
||||||
add_totp=lambda label, seed, index=None, secret=None, period=30, digits=6: "totp://",
|
add_totp=lambda label, seed, index=None, secret=None, period=30, digits=6: "totp://",
|
||||||
add_ssh_key=lambda label, seed, index=None, notes="": 2,
|
add_ssh_key=lambda label, seed, index=None, notes="": 2,
|
||||||
add_pgp_key=lambda label, seed, index=None, key_type="ed25519", user_id="", notes="": 3,
|
add_pgp_key=lambda label, seed, index=None, key_type="ed25519", user_id="", notes="": 3,
|
||||||
add_nostr_key=lambda label, index=None, notes="": 4,
|
add_nostr_key=lambda label, seed, index=None, notes="": 4,
|
||||||
add_seed=lambda label, seed, index=None, words_num=24, notes="": 5,
|
add_seed=lambda label, seed, index=None, words_num=24, notes="": 5,
|
||||||
add_key_value=lambda label, value, notes="": 6,
|
add_key_value=lambda label, key, value, notes="": 6,
|
||||||
add_managed_account=lambda label, seed, index=None, notes="": 7,
|
add_managed_account=lambda label, seed, index=None, notes="": 7,
|
||||||
modify_entry=lambda *a, **kw: None,
|
modify_entry=lambda *a, **kw: None,
|
||||||
archive_entry=lambda i: None,
|
archive_entry=lambda i: None,
|
||||||
@@ -40,10 +42,10 @@ class DummyPM:
|
|||||||
self.handle_display_totp_codes = lambda: None
|
self.handle_display_totp_codes = lambda: None
|
||||||
self.handle_export_database = lambda path: None
|
self.handle_export_database = lambda path: None
|
||||||
self.handle_import_database = lambda path: None
|
self.handle_import_database = lambda path: None
|
||||||
self.change_password = lambda: None
|
self.change_password = lambda *a, **kw: None
|
||||||
self.lock_vault = lambda: None
|
self.lock_vault = lambda: None
|
||||||
self.get_profile_stats = lambda: {"n": 1}
|
self.get_profile_stats = lambda: {"n": 1}
|
||||||
self.handle_backup_reveal_parent_seed = lambda path=None: None
|
self.handle_backup_reveal_parent_seed = lambda path=None, **_: None
|
||||||
self.handle_verify_checksum = lambda: None
|
self.handle_verify_checksum = lambda: None
|
||||||
self.handle_update_script_checksum = lambda: None
|
self.handle_update_script_checksum = lambda: None
|
||||||
self.add_new_fingerprint = lambda: None
|
self.add_new_fingerprint = lambda: None
|
||||||
@@ -58,6 +60,7 @@ class DummyPM:
|
|||||||
"chunk_ids": ["c1"],
|
"chunk_ids": ["c1"],
|
||||||
"delta_ids": [],
|
"delta_ids": [],
|
||||||
}
|
}
|
||||||
|
self.start_background_vault_sync = lambda *a, **k: self.sync_vault()
|
||||||
self.config_manager = SimpleNamespace(
|
self.config_manager = SimpleNamespace(
|
||||||
load_config=lambda require_pin=False: {"inactivity_timeout": 30},
|
load_config=lambda require_pin=False: {"inactivity_timeout": 30},
|
||||||
set_inactivity_timeout=lambda v: None,
|
set_inactivity_timeout=lambda v: None,
|
||||||
@@ -76,7 +79,7 @@ class DummyPM:
|
|||||||
)
|
)
|
||||||
self.secret_mode_enabled = True
|
self.secret_mode_enabled = True
|
||||||
self.clipboard_clear_delay = 30
|
self.clipboard_clear_delay = 30
|
||||||
self.select_fingerprint = lambda fp: None
|
self.select_fingerprint = lambda fp, **_: None
|
||||||
|
|
||||||
|
|
||||||
def load_doc_commands() -> list[str]:
|
def load_doc_commands() -> list[str]:
|
||||||
@@ -84,7 +87,9 @@ def load_doc_commands() -> list[str]:
|
|||||||
cmds = set(re.findall(r"`seedpass ([^`<>]+)`", text))
|
cmds = set(re.findall(r"`seedpass ([^`<>]+)`", text))
|
||||||
cmds = {c for c in cmds if "<" not in c and ">" not in c}
|
cmds = {c for c in cmds if "<" not in c and ">" not in c}
|
||||||
cmds.discard("vault export")
|
cmds.discard("vault export")
|
||||||
|
cmds.discard("vault export --file backup.json")
|
||||||
cmds.discard("vault import")
|
cmds.discard("vault import")
|
||||||
|
cmds.discard("vault import --file backup.json")
|
||||||
return sorted(cmds)
|
return sorted(cmds)
|
||||||
|
|
||||||
|
|
||||||
|
@@ -4,6 +4,7 @@ from typer.testing import CliRunner
|
|||||||
|
|
||||||
from seedpass.cli import app
|
from seedpass.cli import app
|
||||||
from seedpass import cli
|
from seedpass import cli
|
||||||
|
from helpers import TEST_SEED
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|
||||||
@@ -22,9 +23,32 @@ runner = CliRunner()
|
|||||||
"user",
|
"user",
|
||||||
"--url",
|
"--url",
|
||||||
"https://example.com",
|
"https://example.com",
|
||||||
|
"--no-special",
|
||||||
|
"--allowed-special-chars",
|
||||||
|
"!@",
|
||||||
|
"--special-mode",
|
||||||
|
"safe",
|
||||||
|
"--exclude-ambiguous",
|
||||||
|
"--min-uppercase",
|
||||||
|
"1",
|
||||||
|
"--min-lowercase",
|
||||||
|
"2",
|
||||||
|
"--min-digits",
|
||||||
|
"3",
|
||||||
|
"--min-special",
|
||||||
|
"4",
|
||||||
],
|
],
|
||||||
("Label", 16, "user", "https://example.com"),
|
("Label", 16, "user", "https://example.com"),
|
||||||
{},
|
{
|
||||||
|
"include_special_chars": False,
|
||||||
|
"allowed_special_chars": "!@",
|
||||||
|
"special_mode": "safe",
|
||||||
|
"exclude_ambiguous": True,
|
||||||
|
"min_uppercase": 1,
|
||||||
|
"min_lowercase": 2,
|
||||||
|
"min_digits": 3,
|
||||||
|
"min_special": 4,
|
||||||
|
},
|
||||||
"1",
|
"1",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -75,7 +99,7 @@ runner = CliRunner()
|
|||||||
"add-nostr",
|
"add-nostr",
|
||||||
"add_nostr_key",
|
"add_nostr_key",
|
||||||
["Label", "--index", "4", "--notes", "n"],
|
["Label", "--index", "4", "--notes", "n"],
|
||||||
("Label",),
|
("Label", "seed"),
|
||||||
{"index": 4, "notes": "n"},
|
{"index": 4, "notes": "n"},
|
||||||
"5",
|
"5",
|
||||||
),
|
),
|
||||||
@@ -90,8 +114,8 @@ runner = CliRunner()
|
|||||||
(
|
(
|
||||||
"add-key-value",
|
"add-key-value",
|
||||||
"add_key_value",
|
"add_key_value",
|
||||||
["Label", "--value", "val", "--notes", "note"],
|
["Label", "--key", "k1", "--value", "val", "--notes", "note"],
|
||||||
("Label", "val"),
|
("Label", "k1", "val"),
|
||||||
{"notes": "note"},
|
{"notes": "note"},
|
||||||
"7",
|
"7",
|
||||||
),
|
),
|
||||||
@@ -115,14 +139,14 @@ def test_entry_add_commands(
|
|||||||
called["kwargs"] = kwargs
|
called["kwargs"] = kwargs
|
||||||
return stdout
|
return stdout
|
||||||
|
|
||||||
def sync_vault():
|
def start_background_vault_sync():
|
||||||
called["sync"] = True
|
called["sync"] = True
|
||||||
|
|
||||||
pm = SimpleNamespace(
|
pm = SimpleNamespace(
|
||||||
entry_manager=SimpleNamespace(**{method: func}),
|
entry_manager=SimpleNamespace(**{method: func}),
|
||||||
parent_seed="seed",
|
parent_seed="seed",
|
||||||
select_fingerprint=lambda fp: None,
|
select_fingerprint=lambda fp: None,
|
||||||
sync_vault=sync_vault,
|
start_background_vault_sync=start_background_vault_sync,
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||||
result = runner.invoke(app, ["entry", command] + cli_args)
|
result = runner.invoke(app, ["entry", command] + cli_args)
|
||||||
|
@@ -6,9 +6,9 @@ import sys
|
|||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
import main
|
import main
|
||||||
from password_manager.portable_backup import export_backup, import_backup
|
from seedpass.core.portable_backup import export_backup, import_backup
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from helpers import create_vault, TEST_SEED
|
from helpers import create_vault, TEST_SEED
|
||||||
|
|
||||||
|
|
||||||
|
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]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
import main
|
import main
|
||||||
from password_manager.entry_types import EntryType
|
from seedpass.core.entry_types import EntryType
|
||||||
|
|
||||||
|
|
||||||
def make_pm(search_results, entry=None, totp_code="123456"):
|
def make_pm(search_results, entry=None, totp_code="123456"):
|
||||||
@@ -27,7 +27,7 @@ def make_pm(search_results, entry=None, totp_code="123456"):
|
|||||||
|
|
||||||
|
|
||||||
def test_search_command(monkeypatch, capsys):
|
def test_search_command(monkeypatch, capsys):
|
||||||
pm = make_pm([(0, "Example", "user", "", False)])
|
pm = make_pm([(0, "Example", "user", "", False, EntryType.PASSWORD)])
|
||||||
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
|
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
|
||||||
monkeypatch.setattr(main, "configure_logging", lambda: None)
|
monkeypatch.setattr(main, "configure_logging", lambda: None)
|
||||||
monkeypatch.setattr(main, "initialize_app", lambda: None)
|
monkeypatch.setattr(main, "initialize_app", lambda: None)
|
||||||
@@ -40,7 +40,7 @@ def test_search_command(monkeypatch, capsys):
|
|||||||
|
|
||||||
def test_get_command(monkeypatch, capsys):
|
def test_get_command(monkeypatch, capsys):
|
||||||
entry = {"type": EntryType.PASSWORD.value, "length": 8}
|
entry = {"type": EntryType.PASSWORD.value, "length": 8}
|
||||||
pm = make_pm([(0, "Example", "user", "", False)], entry=entry)
|
pm = make_pm([(0, "Example", "user", "", False, EntryType.PASSWORD)], entry=entry)
|
||||||
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
|
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
|
||||||
monkeypatch.setattr(main, "configure_logging", lambda: None)
|
monkeypatch.setattr(main, "configure_logging", lambda: None)
|
||||||
monkeypatch.setattr(main, "initialize_app", lambda: None)
|
monkeypatch.setattr(main, "initialize_app", lambda: None)
|
||||||
@@ -53,7 +53,7 @@ def test_get_command(monkeypatch, capsys):
|
|||||||
|
|
||||||
def test_totp_command(monkeypatch, capsys):
|
def test_totp_command(monkeypatch, capsys):
|
||||||
entry = {"type": EntryType.TOTP.value, "period": 30, "index": 0}
|
entry = {"type": EntryType.TOTP.value, "period": 30, "index": 0}
|
||||||
pm = make_pm([(0, "Example", None, None, False)], entry=entry)
|
pm = make_pm([(0, "Example", None, None, False, EntryType.TOTP)], entry=entry)
|
||||||
called = {}
|
called = {}
|
||||||
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
|
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
|
||||||
monkeypatch.setattr(main, "configure_logging", lambda: None)
|
monkeypatch.setattr(main, "configure_logging", lambda: None)
|
||||||
@@ -83,7 +83,10 @@ def test_search_command_no_results(monkeypatch, capsys):
|
|||||||
|
|
||||||
|
|
||||||
def test_get_command_multiple_matches(monkeypatch, capsys):
|
def test_get_command_multiple_matches(monkeypatch, capsys):
|
||||||
matches = [(0, "Example", "user", "", False), (1, "Ex2", "bob", "", False)]
|
matches = [
|
||||||
|
(0, "Example", "user", "", False, EntryType.PASSWORD),
|
||||||
|
(1, "Ex2", "bob", "", False, EntryType.PASSWORD),
|
||||||
|
]
|
||||||
pm = make_pm(matches)
|
pm = make_pm(matches)
|
||||||
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
|
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
|
||||||
monkeypatch.setattr(main, "configure_logging", lambda: None)
|
monkeypatch.setattr(main, "configure_logging", lambda: None)
|
||||||
@@ -97,7 +100,7 @@ def test_get_command_multiple_matches(monkeypatch, capsys):
|
|||||||
|
|
||||||
def test_get_command_wrong_type(monkeypatch, capsys):
|
def test_get_command_wrong_type(monkeypatch, capsys):
|
||||||
entry = {"type": EntryType.TOTP.value}
|
entry = {"type": EntryType.TOTP.value}
|
||||||
pm = make_pm([(0, "Example", "user", "", False)], entry=entry)
|
pm = make_pm([(0, "Example", None, None, False, EntryType.TOTP)], entry=entry)
|
||||||
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
|
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
|
||||||
monkeypatch.setattr(main, "configure_logging", lambda: None)
|
monkeypatch.setattr(main, "configure_logging", lambda: None)
|
||||||
monkeypatch.setattr(main, "initialize_app", lambda: None)
|
monkeypatch.setattr(main, "initialize_app", lambda: None)
|
||||||
@@ -109,7 +112,10 @@ def test_get_command_wrong_type(monkeypatch, capsys):
|
|||||||
|
|
||||||
|
|
||||||
def test_totp_command_multiple_matches(monkeypatch, capsys):
|
def test_totp_command_multiple_matches(monkeypatch, capsys):
|
||||||
matches = [(0, "GH", None, None, False), (1, "Git", None, None, False)]
|
matches = [
|
||||||
|
(0, "GH", None, None, False, EntryType.TOTP),
|
||||||
|
(1, "Git", None, None, False, EntryType.TOTP),
|
||||||
|
]
|
||||||
pm = make_pm(matches)
|
pm = make_pm(matches)
|
||||||
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
|
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
|
||||||
monkeypatch.setattr(main, "configure_logging", lambda: None)
|
monkeypatch.setattr(main, "configure_logging", lambda: None)
|
||||||
@@ -123,7 +129,7 @@ def test_totp_command_multiple_matches(monkeypatch, capsys):
|
|||||||
|
|
||||||
def test_totp_command_wrong_type(monkeypatch, capsys):
|
def test_totp_command_wrong_type(monkeypatch, capsys):
|
||||||
entry = {"type": EntryType.PASSWORD.value, "length": 8}
|
entry = {"type": EntryType.PASSWORD.value, "length": 8}
|
||||||
pm = make_pm([(0, "Example", "user", "", False)], entry=entry)
|
pm = make_pm([(0, "Example", "user", "", False, EntryType.PASSWORD)], entry=entry)
|
||||||
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
|
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
|
||||||
monkeypatch.setattr(main, "configure_logging", lambda: None)
|
monkeypatch.setattr(main, "configure_logging", lambda: None)
|
||||||
monkeypatch.setattr(main, "initialize_app", lambda: None)
|
monkeypatch.setattr(main, "initialize_app", lambda: None)
|
||||||
|
@@ -6,10 +6,10 @@ from helpers import TEST_SEED, TEST_PASSWORD
|
|||||||
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.encryption import EncryptionManager
|
from seedpass.core.encryption import EncryptionManager
|
||||||
from password_manager.vault import Vault
|
from seedpass.core.vault import Vault
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
from utils.key_derivation import derive_index_key, derive_key_from_password
|
from utils.key_derivation import derive_index_key, derive_key_from_password
|
||||||
|
|
||||||
|
|
||||||
|
@@ -7,8 +7,8 @@ import sys
|
|||||||
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
from password_manager.vault import Vault
|
from seedpass.core.vault import Vault
|
||||||
from nostr.client import DEFAULT_RELAYS
|
from nostr.client import DEFAULT_RELAYS
|
||||||
from constants import INACTIVITY_TIMEOUT
|
from constants import INACTIVITY_TIMEOUT
|
||||||
|
|
||||||
@@ -196,3 +196,43 @@ def test_nostr_retry_settings_round_trip():
|
|||||||
cfg_mgr.set_nostr_retry_delay(3.5)
|
cfg_mgr.set_nostr_retry_delay(3.5)
|
||||||
assert cfg_mgr.get_nostr_max_retries() == 5
|
assert cfg_mgr.get_nostr_max_retries() == 5
|
||||||
assert cfg_mgr.get_nostr_retry_delay() == 3.5
|
assert cfg_mgr.get_nostr_retry_delay() == 3.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_special_char_settings_round_trip():
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
vault, _ = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
|
||||||
|
cfg_mgr = ConfigManager(vault, Path(tmpdir))
|
||||||
|
|
||||||
|
cfg = cfg_mgr.load_config(require_pin=False)
|
||||||
|
assert cfg["include_special_chars"] is True
|
||||||
|
assert cfg["allowed_special_chars"] == ""
|
||||||
|
assert cfg["special_mode"] == "standard"
|
||||||
|
assert cfg["exclude_ambiguous"] is False
|
||||||
|
|
||||||
|
cfg_mgr.set_include_special_chars(False)
|
||||||
|
cfg_mgr.set_allowed_special_chars("@$")
|
||||||
|
cfg_mgr.set_special_mode("safe")
|
||||||
|
cfg_mgr.set_exclude_ambiguous(True)
|
||||||
|
|
||||||
|
cfg2 = cfg_mgr.load_config(require_pin=False)
|
||||||
|
assert cfg2["include_special_chars"] is False
|
||||||
|
assert cfg2["allowed_special_chars"] == "@$"
|
||||||
|
assert cfg2["special_mode"] == "safe"
|
||||||
|
assert cfg2["exclude_ambiguous"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_policy_extended_fields():
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
vault, _ = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
|
||||||
|
cfg_mgr = ConfigManager(vault, Path(tmpdir))
|
||||||
|
|
||||||
|
cfg_mgr.set_include_special_chars(False)
|
||||||
|
cfg_mgr.set_allowed_special_chars("()")
|
||||||
|
cfg_mgr.set_special_mode("safe")
|
||||||
|
cfg_mgr.set_exclude_ambiguous(True)
|
||||||
|
|
||||||
|
policy = cfg_mgr.get_password_policy()
|
||||||
|
assert policy.include_special_chars is False
|
||||||
|
assert policy.allowed_special_chars == "()"
|
||||||
|
assert policy.special_mode == "safe"
|
||||||
|
assert policy.exclude_ambiguous is True
|
||||||
|
72
src/tests/test_core_services.py
Normal file
72
src/tests/test_core_services.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import types
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from seedpass.core.api import VaultService, EntryService, SyncService, UnlockRequest
|
||||||
|
from seedpass.core.entry_types import EntryType
|
||||||
|
|
||||||
|
|
||||||
|
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, EntryType.PASSWORD)]
|
||||||
|
|
||||||
|
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, EntryType.PASSWORD)]
|
||||||
|
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]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.entry_management import EntryManager
|
from seedpass.core.entry_management import EntryManager
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.manager import PasswordManager, EncryptionMode
|
from seedpass.core.manager import PasswordManager, EncryptionMode
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
def test_retrieve_entry_shows_custom_fields(monkeypatch, capsys):
|
def test_retrieve_entry_shows_custom_fields(monkeypatch, capsys):
|
||||||
|
@@ -6,7 +6,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from password_manager.manager import PasswordManager
|
from seedpass.core.manager import PasswordManager
|
||||||
from utils.key_derivation import EncryptionMode
|
from utils.key_derivation import EncryptionMode
|
||||||
|
|
||||||
|
|
||||||
|
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]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.entry_management import EntryManager
|
from seedpass.core.entry_management import EntryManager
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.manager import PasswordManager, EncryptionMode
|
from seedpass.core.manager import PasswordManager, EncryptionMode
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
class FakePasswordGenerator:
|
class FakePasswordGenerator:
|
||||||
|
@@ -8,7 +8,7 @@ import base64
|
|||||||
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.encryption import EncryptionManager
|
from seedpass.core.encryption import EncryptionManager
|
||||||
from utils.checksum import verify_and_update_checksum
|
from utils.checksum import verify_and_update_checksum
|
||||||
|
|
||||||
|
|
||||||
|
@@ -8,7 +8,7 @@ import base64
|
|||||||
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.encryption import EncryptionManager
|
from seedpass.core.encryption import EncryptionManager
|
||||||
|
|
||||||
|
|
||||||
def test_json_save_and_load_round_trip():
|
def test_json_save_and_load_round_trip():
|
||||||
|
@@ -5,10 +5,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
|||||||
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.entry_management import EntryManager
|
from seedpass.core.entry_management import EntryManager
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.vault import Vault
|
from seedpass.core.vault import Vault
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
def test_list_entries_empty():
|
def test_list_entries_empty():
|
||||||
|
@@ -8,10 +8,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
|||||||
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.entry_management import EntryManager
|
from seedpass.core.entry_management import EntryManager
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.vault import Vault
|
from seedpass.core.vault import Vault
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
def test_add_and_retrieve_entry():
|
def test_add_and_retrieve_entry():
|
||||||
@@ -44,7 +44,9 @@ def test_add_and_retrieve_entry():
|
|||||||
|
|
||||||
data = enc_mgr.load_json_data(entry_mgr.index_file)
|
data = enc_mgr.load_json_data(entry_mgr.index_file)
|
||||||
assert str(index) in data.get("entries", {})
|
assert str(index) in data.get("entries", {})
|
||||||
assert data["entries"][str(index)] == entry
|
stored = data["entries"][str(index)]
|
||||||
|
stored.pop("modified_ts", None)
|
||||||
|
assert stored == entry
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -71,7 +73,7 @@ def test_round_trip_entry_types(method, expected_type):
|
|||||||
entry_mgr.add_totp("example", TEST_SEED)
|
entry_mgr.add_totp("example", TEST_SEED)
|
||||||
index = 0
|
index = 0
|
||||||
elif method == "add_key_value":
|
elif method == "add_key_value":
|
||||||
index = entry_mgr.add_key_value("label", "val")
|
index = entry_mgr.add_key_value("label", "k1", "val")
|
||||||
else:
|
else:
|
||||||
if method == "add_ssh_key":
|
if method == "add_ssh_key":
|
||||||
index = entry_mgr.add_ssh_key("ssh", TEST_SEED)
|
index = entry_mgr.add_ssh_key("ssh", TEST_SEED)
|
||||||
@@ -114,9 +116,9 @@ def test_legacy_entry_defaults_to_password():
|
|||||||
("add_totp", ("totp", TEST_SEED)),
|
("add_totp", ("totp", TEST_SEED)),
|
||||||
("add_ssh_key", ("ssh", TEST_SEED)),
|
("add_ssh_key", ("ssh", TEST_SEED)),
|
||||||
("add_pgp_key", ("pgp", TEST_SEED)),
|
("add_pgp_key", ("pgp", TEST_SEED)),
|
||||||
("add_nostr_key", ("nostr",)),
|
("add_nostr_key", ("nostr", TEST_SEED)),
|
||||||
("add_seed", ("seed", TEST_SEED)),
|
("add_seed", ("seed", TEST_SEED)),
|
||||||
("add_key_value", ("label", "val")),
|
("add_key_value", ("label", "k1", "val")),
|
||||||
("add_managed_account", ("acct", TEST_SEED)),
|
("add_managed_account", ("acct", TEST_SEED)),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@@ -5,10 +5,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
|||||||
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.entry_management import EntryManager
|
from seedpass.core.entry_management import EntryManager
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.vault import Vault
|
from seedpass.core.vault import Vault
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
def test_update_checksum_writes_to_expected_path():
|
def test_update_checksum_writes_to_expected_path():
|
||||||
|
67
src/tests/test_entry_policy_override.py
Normal file
67
src/tests/test_entry_policy_override.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import string
|
||||||
|
|
||||||
|
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||||
|
|
||||||
|
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 seedpass.core.password_generation import PasswordGenerator, PasswordPolicy
|
||||||
|
|
||||||
|
|
||||||
|
class DummyEnc:
|
||||||
|
def derive_seed_from_mnemonic(self, mnemonic):
|
||||||
|
return b"\x00" * 32
|
||||||
|
|
||||||
|
|
||||||
|
class DummyBIP85:
|
||||||
|
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
|
||||||
|
return bytes((index + i) % 256 for i in range(bytes_len))
|
||||||
|
|
||||||
|
|
||||||
|
def make_manager(tmp_path: Path) -> PasswordManager:
|
||||||
|
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)
|
||||||
|
|
||||||
|
pg = PasswordGenerator.__new__(PasswordGenerator)
|
||||||
|
pg.encryption_manager = DummyEnc()
|
||||||
|
pg.bip85 = DummyBIP85()
|
||||||
|
pg.policy = PasswordPolicy(
|
||||||
|
min_uppercase=0, min_lowercase=0, min_digits=1, min_special=0
|
||||||
|
)
|
||||||
|
|
||||||
|
pm = PasswordManager.__new__(PasswordManager)
|
||||||
|
pm.encryption_mode = EncryptionMode.SEED_ONLY
|
||||||
|
pm.password_generator = pg
|
||||||
|
pm.entry_manager = entry_mgr
|
||||||
|
pm.parent_seed = TEST_SEED
|
||||||
|
pm.vault = vault
|
||||||
|
pm.backup_manager = backup_mgr
|
||||||
|
pm.nostr_client = SimpleNamespace()
|
||||||
|
pm.fingerprint_dir = tmp_path
|
||||||
|
pm.secret_mode_enabled = False
|
||||||
|
return pm
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_policy_override_changes_password():
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
tmp_path = Path(tmpdir)
|
||||||
|
pm = make_manager(tmp_path)
|
||||||
|
idx = pm.entry_manager.add_entry(
|
||||||
|
"site",
|
||||||
|
16,
|
||||||
|
min_digits=5,
|
||||||
|
include_special_chars=False,
|
||||||
|
)
|
||||||
|
entry = pm.entry_manager.retrieve_entry(idx)
|
||||||
|
pw = pm._generate_password_for_entry(entry, idx)
|
||||||
|
assert sum(c.isdigit() for c in pw) >= 5
|
||||||
|
assert not any(c in string.punctuation for c in pw)
|
@@ -8,11 +8,11 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
|||||||
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.entry_management import EntryManager
|
from seedpass.core.entry_management import EntryManager
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.manager import PasswordManager, EncryptionMode
|
from seedpass.core.manager import PasswordManager, EncryptionMode
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
from password_manager.totp import TotpManager
|
from seedpass.core.totp import TotpManager
|
||||||
|
|
||||||
|
|
||||||
class FakeNostrClient:
|
class FakeNostrClient:
|
||||||
@@ -42,9 +42,7 @@ def test_handle_export_totp_codes(monkeypatch, tmp_path):
|
|||||||
|
|
||||||
export_path = tmp_path / "out.json"
|
export_path = tmp_path / "out.json"
|
||||||
monkeypatch.setattr("builtins.input", lambda *a, **k: str(export_path))
|
monkeypatch.setattr("builtins.input", lambda *a, **k: str(export_path))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: False)
|
||||||
"password_manager.manager.confirm_action", lambda *_a, **_k: False
|
|
||||||
)
|
|
||||||
|
|
||||||
pm.handle_export_totp_codes()
|
pm.handle_export_totp_codes()
|
||||||
|
|
||||||
|
@@ -9,7 +9,7 @@ import base64
|
|||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from utils.fingerprint import generate_fingerprint
|
from utils.fingerprint import generate_fingerprint
|
||||||
from password_manager.encryption import EncryptionManager
|
from seedpass.core.encryption import EncryptionManager
|
||||||
|
|
||||||
|
|
||||||
def test_generate_fingerprint_deterministic():
|
def test_generate_fingerprint_deterministic():
|
||||||
|
@@ -4,10 +4,10 @@ from tempfile import TemporaryDirectory
|
|||||||
|
|
||||||
from helpers import create_vault, dummy_nostr_client
|
from helpers import create_vault, dummy_nostr_client
|
||||||
|
|
||||||
from password_manager.entry_management import EntryManager
|
from seedpass.core.entry_management import EntryManager
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
from password_manager.manager import PasswordManager, EncryptionMode
|
from seedpass.core.manager import PasswordManager, EncryptionMode
|
||||||
|
|
||||||
|
|
||||||
def _init_pm(dir_path: Path, client) -> PasswordManager:
|
def _init_pm(dir_path: Path, client) -> PasswordManager:
|
||||||
@@ -44,7 +44,7 @@ def test_full_sync_roundtrip(dummy_nostr_client):
|
|||||||
# Manager A publishes initial snapshot
|
# Manager A publishes initial snapshot
|
||||||
pm_a.entry_manager.add_entry("site1", 12)
|
pm_a.entry_manager.add_entry("site1", 12)
|
||||||
pm_a.sync_vault()
|
pm_a.sync_vault()
|
||||||
manifest_id = relay.manifests[-1].id
|
manifest_id = relay.manifests[-1].tags[0]
|
||||||
|
|
||||||
# Manager B retrieves snapshot
|
# Manager B retrieves snapshot
|
||||||
result = pm_b.attempt_initial_sync()
|
result = pm_b.attempt_initial_sync()
|
||||||
|
@@ -4,10 +4,10 @@ from tempfile import TemporaryDirectory
|
|||||||
|
|
||||||
from helpers import create_vault, dummy_nostr_client
|
from helpers import create_vault, dummy_nostr_client
|
||||||
|
|
||||||
from password_manager.entry_management import EntryManager
|
from seedpass.core.entry_management import EntryManager
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
from password_manager.manager import PasswordManager, EncryptionMode
|
from seedpass.core.manager import PasswordManager, EncryptionMode
|
||||||
|
|
||||||
|
|
||||||
def _init_pm(dir_path: Path, client) -> PasswordManager:
|
def _init_pm(dir_path: Path, client) -> PasswordManager:
|
||||||
@@ -44,7 +44,7 @@ def test_full_sync_roundtrip(dummy_nostr_client):
|
|||||||
# Manager A publishes initial snapshot
|
# Manager A publishes initial snapshot
|
||||||
pm_a.entry_manager.add_entry("site1", 12)
|
pm_a.entry_manager.add_entry("site1", 12)
|
||||||
pm_a.sync_vault()
|
pm_a.sync_vault()
|
||||||
manifest_id = relay.manifests[-1].id
|
manifest_id = relay.manifests[-1].tags[0]
|
||||||
|
|
||||||
# Manager B retrieves snapshot
|
# Manager B retrieves snapshot
|
||||||
result = pm_b.attempt_initial_sync()
|
result = pm_b.attempt_initial_sync()
|
||||||
|
@@ -9,7 +9,7 @@ from utils.key_derivation import (
|
|||||||
derive_key_from_password_argon2,
|
derive_key_from_password_argon2,
|
||||||
derive_index_key,
|
derive_index_key,
|
||||||
)
|
)
|
||||||
from password_manager.encryption import EncryptionManager
|
from seedpass.core.encryption import EncryptionManager
|
||||||
|
|
||||||
|
|
||||||
cfg_values = st.one_of(
|
cfg_values = st.one_of(
|
||||||
|
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"
|
168
src/tests/test_gui_headless.py
Normal file
168
src/tests/test_gui_headless.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
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, seed=None):
|
||||||
|
self.added.append(("nostr", label))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def add_key_value(self, label, key, value):
|
||||||
|
self.added.append(("key_value", label, key, 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, key=None, value=None
|
||||||
|
):
|
||||||
|
self.modified.append((entry_id, username, url, label, key, 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", "k1", "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.key_input.value = "k1"
|
||||||
|
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, None)),
|
||||||
|
(EntryType.KEY_VALUE.value, (1, None, None, "New", "k2", "val2")),
|
||||||
|
(EntryType.TOTP.value, (1, None, None, "New", None, 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.key_input.value = "k2"
|
||||||
|
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["locked"] == 1
|
||||||
assert locked["unlocked"] == 1
|
assert locked["unlocked"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_activity_checks_timeout(monkeypatch):
|
||||||
|
"""AuthGuard in update_activity locks the vault after inactivity."""
|
||||||
|
import seedpass.core.manager as manager
|
||||||
|
|
||||||
|
now = {"val": 0.0}
|
||||||
|
monkeypatch.setattr(manager.time, "time", lambda: now["val"])
|
||||||
|
|
||||||
|
pm = manager.PasswordManager.__new__(manager.PasswordManager)
|
||||||
|
pm.inactivity_timeout = 0.5
|
||||||
|
pm.last_activity = 0.0
|
||||||
|
pm.locked = False
|
||||||
|
called = {}
|
||||||
|
|
||||||
|
def lock():
|
||||||
|
called["locked"] = True
|
||||||
|
pm.locked = True
|
||||||
|
|
||||||
|
pm.lock_vault = lock
|
||||||
|
pm.auth_guard = manager.AuthGuard(pm, time_fn=lambda: now["val"])
|
||||||
|
|
||||||
|
now["val"] = 0.4
|
||||||
|
pm.update_activity()
|
||||||
|
assert not called
|
||||||
|
|
||||||
|
now["val"] = 1.1
|
||||||
|
pm.update_activity()
|
||||||
|
assert called["locked"] is True
|
||||||
|
@@ -3,9 +3,9 @@ from tempfile import TemporaryDirectory
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||||
from password_manager.entry_management import EntryManager
|
from seedpass.core.entry_management import EntryManager
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
def test_index_caching():
|
def test_index_caching():
|
||||||
|
@@ -7,8 +7,8 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
|||||||
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.encryption import EncryptionManager
|
from seedpass.core.encryption import EncryptionManager
|
||||||
from password_manager.vault import Vault
|
from seedpass.core.vault import Vault
|
||||||
from utils.key_derivation import derive_index_key, derive_key_from_password
|
from utils.key_derivation import derive_index_key, derive_key_from_password
|
||||||
|
|
||||||
SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
@@ -8,10 +8,10 @@ from utils.key_derivation import (
|
|||||||
derive_key_from_password_argon2,
|
derive_key_from_password_argon2,
|
||||||
derive_index_key,
|
derive_index_key,
|
||||||
)
|
)
|
||||||
from password_manager.encryption import EncryptionManager
|
from seedpass.core.encryption import EncryptionManager
|
||||||
from password_manager.vault import Vault
|
from seedpass.core.vault import Vault
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
from password_manager.manager import PasswordManager, EncryptionMode
|
from seedpass.core.manager import PasswordManager, EncryptionMode
|
||||||
|
|
||||||
TEST_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
TEST_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
TEST_PASSWORD = "pw"
|
TEST_PASSWORD = "pw"
|
||||||
@@ -59,12 +59,12 @@ def test_setup_encryption_manager_kdf_modes(monkeypatch):
|
|||||||
cfg = _setup_profile(path, mode)
|
cfg = _setup_profile(path, mode)
|
||||||
pm = _make_pm(path, cfg)
|
pm = _make_pm(path, cfg)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"password_manager.manager.prompt_existing_password",
|
"seedpass.core.manager.prompt_existing_password",
|
||||||
lambda *_: TEST_PASSWORD,
|
lambda *_: TEST_PASSWORD,
|
||||||
)
|
)
|
||||||
if mode == "argon2":
|
if mode == "argon2":
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"password_manager.manager.derive_key_from_password_argon2",
|
"seedpass.core.manager.derive_key_from_password_argon2",
|
||||||
lambda pw: derive_key_from_password_argon2(pw, **argon_kwargs),
|
lambda pw: derive_key_from_password_argon2(pw, **argon_kwargs),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None)
|
monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None)
|
||||||
|
66
src/tests/test_key_validation_failures.py
Normal file
66
src/tests/test_key_validation_failures.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||||
|
|
||||||
|
from seedpass.core.entry_management import EntryManager
|
||||||
|
from seedpass.core.backup import BackupManager
|
||||||
|
from seedpass.core.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
|
def setup_mgr(tmp_path: Path) -> EntryManager:
|
||||||
|
vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
|
||||||
|
cfg = ConfigManager(vault, tmp_path)
|
||||||
|
backup = BackupManager(tmp_path, cfg)
|
||||||
|
return EntryManager(vault, backup)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_totp_invalid_secret(tmp_path: Path):
|
||||||
|
mgr = setup_mgr(tmp_path)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
mgr.add_totp("bad", TEST_SEED, secret="notbase32!")
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_ssh_key_validation_failure(monkeypatch, tmp_path: Path):
|
||||||
|
mgr = setup_mgr(tmp_path)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"seedpass.core.entry_management.validate_ssh_key_pair", lambda p, q: False
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
mgr.add_ssh_key("ssh", TEST_SEED)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_pgp_key_validation_failure(monkeypatch, tmp_path: Path):
|
||||||
|
mgr = setup_mgr(tmp_path)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"seedpass.core.entry_management.validate_pgp_private_key", lambda p, q: False
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
mgr.add_pgp_key("pgp", TEST_SEED, user_id="test")
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_nostr_key_validation_failure(monkeypatch, tmp_path: Path):
|
||||||
|
mgr = setup_mgr(tmp_path)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"seedpass.core.entry_management.validate_nostr_keys", lambda p, q: False
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
mgr.add_nostr_key("nostr", TEST_SEED)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_seed_validation_failure(monkeypatch, tmp_path: Path):
|
||||||
|
mgr = setup_mgr(tmp_path)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"seedpass.core.entry_management.validate_seed_phrase", lambda p: False
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
mgr.add_seed("seed", TEST_SEED)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_managed_account_validation_failure(monkeypatch, tmp_path: Path):
|
||||||
|
mgr = setup_mgr(tmp_path)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"seedpass.core.entry_management.validate_seed_phrase", lambda p: False
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
mgr.add_managed_account("acct", TEST_SEED)
|
@@ -6,9 +6,9 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
|||||||
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.entry_management import EntryManager
|
from seedpass.core.entry_management import EntryManager
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
def setup_entry_mgr(tmp_path: Path) -> EntryManager:
|
def setup_entry_mgr(tmp_path: Path) -> EntryManager:
|
||||||
@@ -23,12 +23,13 @@ def test_add_and_modify_key_value():
|
|||||||
tmp_path = Path(tmpdir)
|
tmp_path = Path(tmpdir)
|
||||||
em = setup_entry_mgr(tmp_path)
|
em = setup_entry_mgr(tmp_path)
|
||||||
|
|
||||||
idx = em.add_key_value("API", "abc123", notes="token")
|
idx = em.add_key_value("API entry", "api_key", "abc123", notes="token")
|
||||||
entry = em.retrieve_entry(idx)
|
entry = em.retrieve_entry(idx)
|
||||||
assert entry == {
|
assert entry == {
|
||||||
"type": "key_value",
|
"type": "key_value",
|
||||||
"kind": "key_value",
|
"kind": "key_value",
|
||||||
"label": "API",
|
"label": "API entry",
|
||||||
|
"key": "api_key",
|
||||||
"value": "abc123",
|
"value": "abc123",
|
||||||
"notes": "token",
|
"notes": "token",
|
||||||
"archived": False,
|
"archived": False,
|
||||||
@@ -36,9 +37,10 @@ def test_add_and_modify_key_value():
|
|||||||
"tags": [],
|
"tags": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
em.modify_entry(idx, value="def456")
|
em.modify_entry(idx, key="api_key2", value="def456")
|
||||||
updated = em.retrieve_entry(idx)
|
updated = em.retrieve_entry(idx)
|
||||||
|
assert updated["key"] == "api_key2"
|
||||||
assert updated["value"] == "def456"
|
assert updated["value"] == "def456"
|
||||||
|
|
||||||
results = em.search_entries("def456")
|
results = em.search_entries("def456")
|
||||||
assert results == [(idx, "API", None, None, False)]
|
assert results == []
|
||||||
|
@@ -3,9 +3,9 @@ from pathlib import Path
|
|||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
import constants
|
import constants
|
||||||
import password_manager.manager as manager_module
|
import seedpass.core.manager as manager_module
|
||||||
from utils.fingerprint_manager import FingerprintManager
|
from utils.fingerprint_manager import FingerprintManager
|
||||||
from password_manager.manager import EncryptionMode
|
from seedpass.core.manager import EncryptionMode
|
||||||
|
|
||||||
from helpers import TEST_SEED
|
from helpers import TEST_SEED
|
||||||
|
|
||||||
|
@@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
|||||||
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.entry_management import EntryManager
|
from seedpass.core.entry_management import EntryManager
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
from password_manager.entry_types import EntryType
|
from seedpass.core.entry_types import EntryType
|
||||||
|
|
||||||
|
|
||||||
def setup_entry_manager(tmp_path: Path) -> EntryManager:
|
def setup_entry_manager(tmp_path: Path) -> EntryManager:
|
||||||
@@ -19,29 +19,35 @@ def setup_entry_manager(tmp_path: Path) -> EntryManager:
|
|||||||
return EntryManager(vault, backup_mgr)
|
return EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
|
|
||||||
def test_sort_by_website():
|
def test_sort_by_label():
|
||||||
with TemporaryDirectory() as tmpdir:
|
with TemporaryDirectory() as tmpdir:
|
||||||
tmp_path = Path(tmpdir)
|
tmp_path = Path(tmpdir)
|
||||||
em = setup_entry_manager(tmp_path)
|
em = setup_entry_manager(tmp_path)
|
||||||
idx0 = em.add_entry("b.com", 8, "user1")
|
idx0 = em.add_entry("b.com", 8, "user1")
|
||||||
idx1 = em.add_entry("A.com", 8, "user2")
|
idx1 = em.add_entry("A.com", 8, "user2")
|
||||||
result = em.list_entries(sort_by="website")
|
result = em.list_entries(sort_by="label")
|
||||||
assert result == [
|
assert result == [
|
||||||
(idx1, "A.com", "user2", "", False),
|
(idx1, "A.com", "user2", "", False),
|
||||||
(idx0, "b.com", "user1", "", False),
|
(idx0, "b.com", "user1", "", False),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_sort_by_username():
|
def test_sort_by_updated():
|
||||||
with TemporaryDirectory() as tmpdir:
|
with TemporaryDirectory() as tmpdir:
|
||||||
tmp_path = Path(tmpdir)
|
tmp_path = Path(tmpdir)
|
||||||
em = setup_entry_manager(tmp_path)
|
em = setup_entry_manager(tmp_path)
|
||||||
idx0 = em.add_entry("alpha.com", 8, "Charlie")
|
idx0 = em.add_entry("alpha.com", 8, "u0")
|
||||||
idx1 = em.add_entry("beta.com", 8, "alice")
|
idx1 = em.add_entry("beta.com", 8, "u1")
|
||||||
result = em.list_entries(sort_by="username")
|
|
||||||
|
data = em._load_index(force_reload=True)
|
||||||
|
data["entries"][str(idx0)]["updated"] = 1
|
||||||
|
data["entries"][str(idx1)]["updated"] = 2
|
||||||
|
em._save_index(data)
|
||||||
|
|
||||||
|
result = em.list_entries(sort_by="updated")
|
||||||
assert result == [
|
assert result == [
|
||||||
(idx1, "beta.com", "alice", "", False),
|
(idx1, "beta.com", "u1", "", False),
|
||||||
(idx0, "alpha.com", "Charlie", "", False),
|
(idx0, "alpha.com", "u0", "", False),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@@ -4,14 +4,14 @@ from tempfile import TemporaryDirectory
|
|||||||
|
|
||||||
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||||
from utils.fingerprint import generate_fingerprint
|
from utils.fingerprint import generate_fingerprint
|
||||||
import password_manager.manager as manager_module
|
import seedpass.core.manager as manager_module
|
||||||
from password_manager.manager import EncryptionMode
|
from seedpass.core.manager import EncryptionMode
|
||||||
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.entry_management import EntryManager
|
from seedpass.core.entry_management import EntryManager
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
def setup_entry_manager(tmp_path: Path) -> EntryManager:
|
def setup_entry_manager(tmp_path: Path) -> EntryManager:
|
||||||
|
@@ -4,15 +4,15 @@ from tempfile import TemporaryDirectory
|
|||||||
|
|
||||||
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||||
from utils.fingerprint import generate_fingerprint
|
from utils.fingerprint import generate_fingerprint
|
||||||
import password_manager.manager as manager_module
|
import seedpass.core.manager as manager_module
|
||||||
from password_manager.manager import EncryptionMode
|
from seedpass.core.manager import EncryptionMode
|
||||||
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from password_manager.entry_management import EntryManager
|
from seedpass.core.entry_management import EntryManager
|
||||||
from password_manager.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from password_manager.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
from password_manager.password_generation import derive_seed_phrase
|
from seedpass.core.password_generation import derive_seed_phrase
|
||||||
from local_bip85.bip85 import BIP85
|
from local_bip85.bip85 import BIP85
|
||||||
from bip_utils import Bip39SeedGenerator
|
from bip_utils import Bip39SeedGenerator
|
||||||
|
|
||||||
|
210
src/tests/test_manager_add_password.py
Normal file
210
src/tests/test_manager_add_password.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
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(
|
||||||
|
[
|
||||||
|
"a", # advanced mode
|
||||||
|
"Example", # label
|
||||||
|
"", # username
|
||||||
|
"", # url
|
||||||
|
"", # notes
|
||||||
|
"", # tags
|
||||||
|
"n", # add custom field
|
||||||
|
"", # length (default)
|
||||||
|
"", # include special default
|
||||||
|
"", # allowed special default
|
||||||
|
"", # special mode default
|
||||||
|
"", # exclude ambiguous default
|
||||||
|
"", # min uppercase
|
||||||
|
"", # min lowercase
|
||||||
|
"", # min digits
|
||||||
|
"", # min special
|
||||||
|
]
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
[
|
||||||
|
"a", # advanced mode
|
||||||
|
"Example", # label
|
||||||
|
"", # username
|
||||||
|
"", # url
|
||||||
|
"", # notes
|
||||||
|
"", # tags
|
||||||
|
"n", # add custom field
|
||||||
|
"", # length (default)
|
||||||
|
"", # include special default
|
||||||
|
"", # allowed special default
|
||||||
|
"", # special mode default
|
||||||
|
"", # exclude ambiguous default
|
||||||
|
"", # min uppercase
|
||||||
|
"", # min lowercase
|
||||||
|
"", # min digits
|
||||||
|
"", # min special
|
||||||
|
]
|
||||||
|
)
|
||||||
|
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)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_add_password_quick_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 = False
|
||||||
|
pm.is_dirty = False
|
||||||
|
|
||||||
|
inputs = iter(
|
||||||
|
[
|
||||||
|
"q", # quick mode
|
||||||
|
"Example", # label
|
||||||
|
"", # username
|
||||||
|
"", # url
|
||||||
|
"", # length (default)
|
||||||
|
"", # include special 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
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user