24 Commits

Author SHA1 Message Date
thePR0M3TH3AN
ca733be2e3 Merge pull request #854 from PR0M3TH3AN/codex/reduce-log-verbosity-in-tui
Refine console logging
2025-08-23 13:33:21 -04:00
thePR0M3TH3AN
e528bebae3 Refine console logging 2025-08-23 13:19:27 -04:00
thePR0M3TH3AN
e760bf2b25 Merge pull request #853 from PR0M3TH3AN/codex/suppress-tui-warning-and-debug-messages
Reduce noisy logging in console
2025-08-23 13:02:41 -04:00
thePR0M3TH3AN
d106802a18 Reduce console logging noise 2025-08-23 12:46:52 -04:00
thePR0M3TH3AN
f2648a8c1d Merge pull request #852 from PR0M3TH3AN/codex/handle-encryption-exceptions-with-user-friendly-message
Handle decryption failures with friendly message
2025-08-23 12:30:05 -04:00
thePR0M3TH3AN
d030cf9692 Raise InvalidToken-compatible errors 2025-08-23 12:21:56 -04:00
thePR0M3TH3AN
bebbca8169 Add test for friendly decryption message? 2025-08-23 12:05:58 -04:00
thePR0M3TH3AN
4d7e3d4b63 Merge pull request #851 from PR0M3TH3AN/codex/revise-logging-configuration-and-wrap-ui-sections
Limit console logging noise during interactive prompts
2025-08-23 11:56:44 -04:00
thePR0M3TH3AN
7b0344739f refactor: restrict console logging and pause during UI 2025-08-22 22:24:04 -04:00
thePR0M3TH3AN
fde09bd1a0 Merge pull request #850 from PR0M3TH3AN/codex/redirect-log-output-for-background-tasks
Refactor logging output handling
2025-08-22 21:56:33 -04:00
thePR0M3TH3AN
b307728c05 feat: support pausing console logs 2025-08-22 21:50:04 -04:00
thePR0M3TH3AN
8ade9e3028 Merge pull request #849 from PR0M3TH3AN/codex/handle-ctrl-c-in-seed_prompt.py
Handle Ctrl-C in masked input prompts
2025-08-22 21:40:47 -04:00
thePR0M3TH3AN
c0a6187478 Handle Ctrl-C in masked input 2025-08-22 21:32:18 -04:00
thePR0M3TH3AN
d9f76ee668 Merge pull request #848 from PR0M3TH3AN/codex/update-readme-installation-instructions
docs: document headless TUI installer
2025-08-22 10:30:18 -04:00
thePR0M3TH3AN
40a75adcb7 docs: document headless TUI installer 2025-08-22 10:22:29 -04:00
thePR0M3TH3AN
bd1588fba1 Merge pull request #847 from PR0M3TH3AN/codex/introduce-seedpasserror-and-replace-sys.exit-calls
Use SeedPassError instead of sys.exit
2025-08-22 10:08:38 -04:00
thePR0M3TH3AN
d5e0d61db4 Use custom SeedPassError instead of sys.exit 2025-08-22 10:01:14 -04:00
thePR0M3TH3AN
d795ac9006 Merge pull request #846 from PR0M3TH3AN/codex/add-restore-from-local-backup-option
feat: add local backup restore during seed setup
2025-08-22 09:46:51 -04:00
thePR0M3TH3AN
ee3d9d8e9d feat: add local backup restore during seed setup 2025-08-22 09:39:38 -04:00
thePR0M3TH3AN
2b68df9428 Merge pull request #845 from PR0M3TH3AN/codex/update-offline-mode-to-true
Default offline mode and docs clarify online sync opt-in
2025-08-22 09:22:12 -04:00
thePR0M3TH3AN
a2a663eed1 Default offline mode 2025-08-22 09:10:06 -04:00
thePR0M3TH3AN
ae59ede374 Merge pull request #844 from PR0M3TH3AN/codex/modify-optional-dependencies-in-pyproject.toml
Add platform-specific GUI extras
2025-08-22 09:09:37 -04:00
thePR0M3TH3AN
61b1aa6773 chore: update poetry.lock 2025-08-21 16:38:15 -04:00
thePR0M3TH3AN
428efd02b4 Add GUI extras for platform backends 2025-08-21 16:18:32 -04:00
27 changed files with 716 additions and 150 deletions

View File

@@ -18,7 +18,7 @@ Recent releases derive passwords and other artifacts using a fully deterministic
**⚠️ First Run Warning**
Use a dedicated BIP-39 seed phrase exclusively for SeedPass. Offline Mode is **ON by default**, keeping all Nostr syncing disabled until you explicitly opt in.
Use a dedicated BIP-39 seed phrase exclusively for SeedPass. Offline Mode is **ON by default**, keeping all Nostr syncing disabled until you explicitly opt in. To synchronize with Nostr, disable offline mode through the Settings menu or by running `seedpass config toggle-offline` and choosing to turn syncing on.
---
### Supported OS
@@ -123,13 +123,13 @@ See `docs/ARCHITECTURE.md` and [Nostr Setup](docs/nostr_setup.md) for details.
### Quick Installer
Use the automated installer to download SeedPass and its dependencies in one step.
The scripts can also install the BeeWare backend for your platform when requested (`--mode gui` or `--mode both` on Linux/macOS, `-IncludeGui` on Windows).
If the GTK `gi` bindings are missing, the installer attempts to install the
The default `tui` mode installs only the text interface, so it runs headlessly and works well in CI or other automation. GUI backends are optional and must be explicitly requested (`--mode gui` or `--mode both` on Linux/macOS, `-IncludeGui` on Windows). If the GTK `gi` bindings are missing, the installer attempts to install the
necessary system packages using `apt`, `yum`, `pacman`, or Homebrew. When no display server is detected, GUI components are skipped automatically.
**Linux and macOS:**
```bash
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)"
# TUI-only/agent install (headless default)
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)" _ --mode tui
```
*Install the beta branch:*
```bash
@@ -143,6 +143,7 @@ bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/
**Windows (PowerShell):**
```powershell
# TUI-only/agent install (default)
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; $scriptContent = (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.ps1'); & ([scriptblock]::create($scriptContent))
```
*Install with the optional GUI:*
@@ -237,8 +238,9 @@ After installing `xclip`, restart SeedPass to enable clipboard support.
### Optional GUI
SeedPass ships with a GTK-based desktop interface that is still in development
and not currently functional. Install the packages for your platform before
adding the Python GUI dependencies.
and not currently functional. GUI backends are optional—run the installer with
`--mode gui` or install the Python extras below to add them. Install the packages
for your platform before adding the Python GUI dependencies.
- **Debian/Ubuntu**
```bash
@@ -257,14 +259,22 @@ adding the Python GUI dependencies.
brew install pygobject3 gtk+3 adwaita-icon-theme librsvg webkitgtk
```
With the system requirements in place, install the Python GUI extras:
With the system requirements in place, install the Python GUI extras for your
platform:
```bash
pip install .[gui]
# Linux
pip install .[gui-gtk]
# Windows
pip install .[gui-win]
# macOS
pip install .[gui-mac]
```
CLI-only users can skip these steps and install just the core package for a
lightweight setup:
lightweight, headless setup compatible with CI/automation:
```bash
pip install .
@@ -323,31 +333,30 @@ python -m seedpass_gui
seedpass-gui
```
GUI dependencies are optional. Install them alongside SeedPass with:
```bash
pip install "seedpass[gui]"
# or when working from a local checkout
pip install -e .[gui]
```
After installing the optional GUI extras, add the BeeWare backend for your
platform:
GUI dependencies are optional. Install them alongside SeedPass with the
extra 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
pip install "seedpass[gui-gtk]"
# Windows
pip install toga-winforms
pip install "seedpass[gui-win]"
# macOS
pip install toga-cocoa
pip install "seedpass[gui-mac]"
# or when working from a local checkout
pip install -e ".[gui-gtk]" # Linux
pip install -e ".[gui-win]" # Windows
pip install -e ".[gui-mac]" # macOS
```
If you see build errors about "cairo" on Linux, install the cairo development
headers using your package manager, e.g.:
```bash
sudo apt-get install libcairo2 libcairo2-dev
```
The GUI works with the same vault and configuration files as the CLI.

View File

@@ -78,7 +78,7 @@ Manage the entire vault for a profile.
### Nostr Commands
Interact with the Nostr network for backup and synchronization.
Interact with the Nostr network for backup and synchronization. Offline mode is enabled by default, so disable it with `seedpass config toggle-offline` before using these commands.
| Action | Command | Examples |
| :--- | :--- | :--- |

View File

@@ -83,7 +83,7 @@ maintainable while enabling a consistent experience on multiple platforms.
- **Change Master Password:** Rotate your encryption password at any time.
- **Checksum Verification Utilities:** Verify or regenerate the script checksum.
- **Relay Management:** List, add, remove or reset configured Nostr relays.
- **Offline Mode:** Disable network sync to work entirely locally.
- **Offline Mode (default):** SeedPass runs without network sync until you explicitly enable it.
## Prerequisites
@@ -472,7 +472,7 @@ Back in the Settings menu you can:
whether both the encrypted database and the script itself pass checksum
validation.
* Choose `14` to toggle Secret Mode and set the clipboard clear delay.
* Select `15` to toggle Offline Mode and work locally without contacting Nostr.
* Select `15` to toggle Offline Mode. SeedPass starts offline; disable it here to enable Nostr syncing.
* Choose `16` to toggle Quick Unlock so subsequent actions skip the password prompt. Startup delay is unchanged.
* Select `17` to return to the main menu.
@@ -566,7 +566,7 @@ Mutation testing is disabled in the GitHub workflow due to reliability issues an
- **Multiple Seeds Management:** While managing multiple seeds adds flexibility, it also increases the responsibility to secure each seed and its associated password.
- **No PBKDF2 Salt Required:** SeedPass deliberately omits an explicit PBKDF2 salt. Every password is derived from a unique 512-bit BIP-85 child seed, which already provides stronger per-password uniqueness than a conventional 128-bit salt.
- **Default KDF Iterations:** New profiles start with 50,000 PBKDF2 iterations. Use `seedpass config set kdf_iterations` to change this.
- **Offline Mode:** Disable Nostr sync to keep all operations local until you re-enable networking.
- **Offline Mode (default):** Nostr sync is disabled until you explicitly enable it via the Settings menu or `seedpass config toggle-offline`.
- **Quick Unlock:** Store a hashed copy of your password so future actions skip the prompt. Startup delay no longer changes. Use with caution on shared systems.
## Contributing

View File

@@ -1,6 +1,6 @@
# Nostr Setup
This guide explains how SeedPass uses the Nostr protocol for encrypted vault backups and how to configure relays.
This guide explains how SeedPass uses the Nostr protocol for encrypted vault backups and how to configure relays. SeedPass starts in offline mode, so you must explicitly disable it before any network synchronization. Run `seedpass config toggle-offline` or use the Settings menu to enable online syncing.
## Relay Configuration

238
poetry.lock generated
View File

@@ -724,6 +724,22 @@ files = [
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "clr-loader"
version = "0.2.7.post0"
description = "Generic pure Python loader for .NET runtimes"
optional = true
python-versions = ">=3.7"
groups = ["main"]
markers = "extra == \"gui-win\""
files = [
{file = "clr_loader-0.2.7.post0-py3-none-any.whl", hash = "sha256:e0b9fcc107d48347a4311a28ffe3ae78c4968edb216ffb6564cb03f7ace0bb47"},
{file = "clr_loader-0.2.7.post0.tar.gz", hash = "sha256:b7a8b3f8fbb1bcbbb6382d887e21d1742d4f10b5ea209e4ad95568fe97e1c7c6"},
]
[package.dependencies]
cffi = {version = ">=1.17", markers = "python_version >= \"3.8\""}
[[package]]
name = "coincurve"
version = "21.0.0"
@@ -1125,6 +1141,88 @@ docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)
testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"]
typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""]
[[package]]
name = "fonttools"
version = "4.59.1"
description = "Tools to manipulate font files"
optional = true
python-versions = ">=3.9"
groups = ["main"]
markers = "extra == \"gui-mac\""
files = [
{file = "fonttools-4.59.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e90a89e52deb56b928e761bb5b5f65f13f669bfd96ed5962975debea09776a23"},
{file = "fonttools-4.59.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d29ab70658d2ec19422b25e6ace00a0b0ae4181ee31e03335eaef53907d2d83"},
{file = "fonttools-4.59.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f9721a564978a10d5c12927f99170d18e9a32e5a727c61eae56f956a4d118b"},
{file = "fonttools-4.59.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c8758a7d97848fc8b514b3d9b4cb95243714b2f838dde5e1e3c007375de6214"},
{file = "fonttools-4.59.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2aeb829ad9d41a2ef17cab8bb5d186049ba38a840f10352e654aa9062ec32dc1"},
{file = "fonttools-4.59.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac216a2980a2d2b3b88c68a24f8a9bfb203e2490e991b3238502ad8f1e7bfed0"},
{file = "fonttools-4.59.1-cp310-cp310-win32.whl", hash = "sha256:d31dc137ed8ec71dbc446949eba9035926e6e967b90378805dcf667ff57cabb1"},
{file = "fonttools-4.59.1-cp310-cp310-win_amd64.whl", hash = "sha256:5265bc52ed447187d39891b5f21d7217722735d0de9fe81326566570d12851a9"},
{file = "fonttools-4.59.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4909cce2e35706f3d18c54d3dcce0414ba5e0fb436a454dffec459c61653b513"},
{file = "fonttools-4.59.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:efbec204fa9f877641747f2d9612b2b656071390d7a7ef07a9dbf0ecf9c7195c"},
{file = "fonttools-4.59.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39dfd42cc2dc647b2c5469bc7a5b234d9a49e72565b96dd14ae6f11c2c59ef15"},
{file = "fonttools-4.59.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b11bc177a0d428b37890825d7d025040d591aa833f85f8d8878ed183354f47df"},
{file = "fonttools-4.59.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b9b4c35b3be45e5bc774d3fc9608bbf4f9a8d371103b858c80edbeed31dd5aa"},
{file = "fonttools-4.59.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:01158376b8a418a0bae9625c476cebfcfcb5e6761e9d243b219cd58341e7afbb"},
{file = "fonttools-4.59.1-cp311-cp311-win32.whl", hash = "sha256:cf7c5089d37787387123f1cb8f1793a47c5e1e3d1e4e7bfbc1cc96e0f925eabe"},
{file = "fonttools-4.59.1-cp311-cp311-win_amd64.whl", hash = "sha256:c866eef7a0ba320486ade6c32bfc12813d1a5db8567e6904fb56d3d40acc5116"},
{file = "fonttools-4.59.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:43ab814bbba5f02a93a152ee61a04182bb5809bd2bc3609f7822e12c53ae2c91"},
{file = "fonttools-4.59.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4f04c3ffbfa0baafcbc550657cf83657034eb63304d27b05cff1653b448ccff6"},
{file = "fonttools-4.59.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d601b153e51a5a6221f0d4ec077b6bfc6ac35bfe6c19aeaa233d8990b2b71726"},
{file = "fonttools-4.59.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c735e385e30278c54f43a0d056736942023c9043f84ee1021eff9fd616d17693"},
{file = "fonttools-4.59.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1017413cdc8555dce7ee23720da490282ab7ec1cf022af90a241f33f9a49afc4"},
{file = "fonttools-4.59.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5c6d8d773470a5107052874341ed3c487c16ecd179976d81afed89dea5cd7406"},
{file = "fonttools-4.59.1-cp312-cp312-win32.whl", hash = "sha256:2a2d0d33307f6ad3a2086a95dd607c202ea8852fa9fb52af9b48811154d1428a"},
{file = "fonttools-4.59.1-cp312-cp312-win_amd64.whl", hash = "sha256:0b9e4fa7eaf046ed6ac470f6033d52c052481ff7a6e0a92373d14f556f298dc0"},
{file = "fonttools-4.59.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:89d9957b54246c6251345297dddf77a84d2c19df96af30d2de24093bbdf0528b"},
{file = "fonttools-4.59.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8156b11c0d5405810d216f53907bd0f8b982aa5f1e7e3127ab3be1a4062154ff"},
{file = "fonttools-4.59.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8387876a8011caec52d327d5e5bca705d9399ec4b17afb8b431ec50d47c17d23"},
{file = "fonttools-4.59.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb13823a74b3a9204a8ed76d3d6d5ec12e64cc5bc44914eb9ff1cdac04facd43"},
{file = "fonttools-4.59.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e1ca10da138c300f768bb68e40e5b20b6ecfbd95f91aac4cc15010b6b9d65455"},
{file = "fonttools-4.59.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2beb5bfc4887a3130f8625349605a3a45fe345655ce6031d1bac11017454b943"},
{file = "fonttools-4.59.1-cp313-cp313-win32.whl", hash = "sha256:419f16d750d78e6d704bfe97b48bba2f73b15c9418f817d0cb8a9ca87a5b94bf"},
{file = "fonttools-4.59.1-cp313-cp313-win_amd64.whl", hash = "sha256:c536f8a852e8d3fa71dde1ec03892aee50be59f7154b533f0bf3c1174cfd5126"},
{file = "fonttools-4.59.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d5c3bfdc9663f3d4b565f9cb3b8c1efb3e178186435b45105bde7328cfddd7fe"},
{file = "fonttools-4.59.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ea03f1da0d722fe3c2278a05957e6550175571a4894fbf9d178ceef4a3783d2b"},
{file = "fonttools-4.59.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57a3708ca6bfccb790f585fa6d8f29432ec329618a09ff94c16bcb3c55994643"},
{file = "fonttools-4.59.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:729367c91eb1ee84e61a733acc485065a00590618ca31c438e7dd4d600c01486"},
{file = "fonttools-4.59.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f8ef66ac6db450193ed150e10b3b45dde7aded10c5d279968bc63368027f62b"},
{file = "fonttools-4.59.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:075f745d539a998cd92cb84c339a82e53e49114ec62aaea8307c80d3ad3aef3a"},
{file = "fonttools-4.59.1-cp314-cp314-win32.whl", hash = "sha256:c2b0597522d4c5bb18aa5cf258746a2d4a90f25878cbe865e4d35526abd1b9fc"},
{file = "fonttools-4.59.1-cp314-cp314-win_amd64.whl", hash = "sha256:e9ad4ce044e3236f0814c906ccce8647046cc557539661e35211faadf76f283b"},
{file = "fonttools-4.59.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:652159e8214eb4856e8387ebcd6b6bd336ee258cbeb639c8be52005b122b9609"},
{file = "fonttools-4.59.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:43d177cd0e847ea026fedd9f099dc917da136ed8792d142298a252836390c478"},
{file = "fonttools-4.59.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e54437651e1440ee53a95e6ceb6ee440b67a3d348c76f45f4f48de1a5ecab019"},
{file = "fonttools-4.59.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6065fdec8ff44c32a483fd44abe5bcdb40dd5e2571a5034b555348f2b3a52cea"},
{file = "fonttools-4.59.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42052b56d176f8b315fbc09259439c013c0cb2109df72447148aeda677599612"},
{file = "fonttools-4.59.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bcd52eaa5c4c593ae9f447c1d13e7e4a00ca21d755645efa660b6999425b3c88"},
{file = "fonttools-4.59.1-cp314-cp314t-win32.whl", hash = "sha256:02e4fdf27c550dded10fe038a5981c29f81cb9bc649ff2eaa48e80dab8998f97"},
{file = "fonttools-4.59.1-cp314-cp314t-win_amd64.whl", hash = "sha256:412a5fd6345872a7c249dac5bcce380393f40c1c316ac07f447bc17d51900922"},
{file = "fonttools-4.59.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1ab4c1fb45f2984b8b4a3face7cff0f67f9766e9414cbb6fd061e9d77819de98"},
{file = "fonttools-4.59.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8ee39da0227950f88626c91e219659e6cd725ede826b1c13edd85fc4cec9bbe6"},
{file = "fonttools-4.59.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58a8844f96cff35860647a65345bfca87f47a2494bfb4bef754e58c082511443"},
{file = "fonttools-4.59.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f3f021cea6e36410874763f4a517a5e2d6ac36ca8f95521f3a9fdaad0fe73dc"},
{file = "fonttools-4.59.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bf5fb864f80061a40c1747e0dbc4f6e738de58dd6675b07eb80bd06a93b063c4"},
{file = "fonttools-4.59.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c29ea087843e27a7cffc78406d32a5abf166d92afde7890394e9e079c9b4dbe9"},
{file = "fonttools-4.59.1-cp39-cp39-win32.whl", hash = "sha256:a960b09ff50c2e87864e83f352e5a90bcf1ad5233df579b1124660e1643de272"},
{file = "fonttools-4.59.1-cp39-cp39-win_amd64.whl", hash = "sha256:e3680884189e2b7c3549f6d304376e64711fd15118e4b1ae81940cb6b1eaa267"},
{file = "fonttools-4.59.1-py3-none-any.whl", hash = "sha256:647db657073672a8330608970a984d51573557f328030566521bc03415535042"},
{file = "fonttools-4.59.1.tar.gz", hash = "sha256:74995b402ad09822a4c8002438e54940d9f1ecda898d2bb057729d7da983e4cb"},
]
[package.extras]
all = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\"", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.23.0)", "unicodedata2 (>=15.1.0) ; python_version <= \"3.12\"", "xattr ; sys_platform == \"darwin\"", "zopfli (>=0.1.4)"]
graphite = ["lz4 (>=1.7.4.2)"]
interpolatable = ["munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\""]
lxml = ["lxml (>=4.0)"]
pathops = ["skia-pathops (>=0.5.0)"]
plot = ["matplotlib"]
repacker = ["uharfbuzz (>=0.23.0)"]
symfont = ["sympy"]
type1 = ["xattr ; sys_platform == \"darwin\""]
unicode = ["unicodedata2 (>=15.1.0) ; python_version <= \"3.12\""]
woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"]
[[package]]
name = "freezegun"
version = "1.5.4"
@@ -2332,6 +2430,35 @@ files = [
{file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"},
]
[[package]]
name = "pycairo"
version = "1.28.0"
description = "Python interface for cairo"
optional = true
python-versions = ">=3.9"
groups = ["main"]
markers = "extra == \"gui-gtk\""
files = [
{file = "pycairo-1.28.0-cp310-cp310-win32.whl", hash = "sha256:53e6dbc98456f789965dad49ef89ce2c62f9a10fc96c8d084e14da0ffb73d8a6"},
{file = "pycairo-1.28.0-cp310-cp310-win_amd64.whl", hash = "sha256:c8ab91a75025f984bc327ada335c787efb61c929ea0512063793cb36cee503d4"},
{file = "pycairo-1.28.0-cp310-cp310-win_arm64.whl", hash = "sha256:e955328c1a5147bf71ee94e206413ce15e12630296a79788fcd246c80e5337b8"},
{file = "pycairo-1.28.0-cp311-cp311-win32.whl", hash = "sha256:0fee15f5d72b13ba5fd065860312493dc1bca6ff2dce200ee9d704e11c94e60a"},
{file = "pycairo-1.28.0-cp311-cp311-win_amd64.whl", hash = "sha256:6339979bfec8b58a06476094a9a5c104bd5a99932ddaff16ca0d9203d2f4482c"},
{file = "pycairo-1.28.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6ae15392e28ebfc0b35d8dc05d395d3b6be4bad9ad4caecf0fa12c8e7150225"},
{file = "pycairo-1.28.0-cp312-cp312-win32.whl", hash = "sha256:c00cfbb7f30eb7ca1d48886712932e2d91e8835a8496f4e423878296ceba573e"},
{file = "pycairo-1.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:d50d190f5033992b55050b9f337ee42a45c3568445d5e5d7987bab96c278d8a6"},
{file = "pycairo-1.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:957e0340ee1c279d197d4f7cfa96f6d8b48e453eec711fca999748d752468ff4"},
{file = "pycairo-1.28.0-cp313-cp313-win32.whl", hash = "sha256:d13352429d8a08a1cb3607767d23d2fb32e4c4f9faa642155383980ec1478c24"},
{file = "pycairo-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:082aef6b3a9dcc328fa648d38ed6b0a31c863e903ead57dd184b2e5f86790140"},
{file = "pycairo-1.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:026afd53b75291917a7412d9fe46dcfbaa0c028febd46ff1132d44a53ac2c8b6"},
{file = "pycairo-1.28.0-cp314-cp314-win32.whl", hash = "sha256:d0ab30585f536101ad6f09052fc3895e2a437ba57531ea07223d0e076248025d"},
{file = "pycairo-1.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:94f2ed204999ab95a0671a0fa948ffbb9f3d6fb8731fe787917f6d022d9c1c0f"},
{file = "pycairo-1.28.0-cp39-cp39-win32.whl", hash = "sha256:3ed16d48b8a79cc584cb1cb0ad62dfb265f2dda6d6a19ef5aab181693e19c83c"},
{file = "pycairo-1.28.0-cp39-cp39-win_amd64.whl", hash = "sha256:da0d1e6d4842eed4d52779222c6e43d254244a486ca9fdab14e30042fd5bdf28"},
{file = "pycairo-1.28.0-cp39-cp39-win_arm64.whl", hash = "sha256:458877513eb2125513122e8aa9c938630e94bb0574f94f4fb5ab55eb23d6e9ac"},
{file = "pycairo-1.28.0.tar.gz", hash = "sha256:26ec5c6126781eb167089a123919f87baa2740da2cca9098be8b3a6b91cc5fbc"},
]
[[package]]
name = "pycparser"
version = "2.22"
@@ -2544,6 +2671,21 @@ files = [
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pygobject"
version = "3.52.3"
description = "Python bindings for GObject Introspection"
optional = true
python-versions = "<4.0,>=3.9"
groups = ["main"]
markers = "extra == \"gui-gtk\""
files = [
{file = "pygobject-3.52.3.tar.gz", hash = "sha256:00e427d291e957462a8fad659a9f9c8be776ff82a8b76bdf402f1eaeec086d82"},
]
[package.dependencies]
pycairo = ">=1.16"
[[package]]
name = "pyjwt"
version = "2.10.1"
@@ -2702,6 +2844,22 @@ files = [
{file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"},
]
[[package]]
name = "pythonnet"
version = "3.0.5"
description = ".NET and Mono integration for Python"
optional = true
python-versions = "<3.14,>=3.7"
groups = ["main"]
markers = "extra == \"gui-win\""
files = [
{file = "pythonnet-3.0.5-py3-none-any.whl", hash = "sha256:f6702d694d5d5b163c9f3f5cc34e0bed8d6857150237fae411fefb883a656d20"},
{file = "pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf"},
]
[package.dependencies]
clr_loader = ">=0.2.7,<0.3.0"
[[package]]
name = "pywin32"
version = "311"
@@ -2794,6 +2952,23 @@ pygments = ">=2.13.0,<3.0.0"
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "rubicon-objc"
version = "0.5.2"
description = "A bridge between an Objective C runtime environment and Python."
optional = true
python-versions = ">=3.9"
groups = ["main"]
markers = "extra == \"gui-mac\""
files = [
{file = "rubicon_objc-0.5.2-py3-none-any.whl", hash = "sha256:829b253c579e51fc34f4bb6587c34806e78960dcc1eb24e62b38141a1fe02b39"},
{file = "rubicon_objc-0.5.2.tar.gz", hash = "sha256:1180593935f6a8a39c23b5f4b7baa24aedf9f7285e80804a1d9d6b50a50572f5"},
]
[package.extras]
dev = ["pre-commit (==4.2.0)", "pytest (==8.4.1)", "setuptools_scm (==8.3.1)", "tox (==4.28.4)"]
docs = ["furo (==2025.7.19)", "pyenchant (==3.2.2)", "sphinx (==8.2.3)", "sphinx-autobuild (==2024.10.3)", "sphinx-copybutton (==0.5.2)", "sphinx_tabs (==3.4.7)", "sphinxcontrib-spelling (==8.0.1)"]
[[package]]
name = "shellingham"
version = "1.5.4"
@@ -2894,6 +3069,24 @@ files = [
[package.extras]
tests = ["pytest", "pytest-cov"]
[[package]]
name = "toga-cocoa"
version = "0.5.2"
description = "A Cocoa (macOS) backend for the Toga widget toolkit."
optional = true
python-versions = ">=3.9"
groups = ["main"]
markers = "extra == \"gui-mac\""
files = [
{file = "toga_cocoa-0.5.2-py3-none-any.whl", hash = "sha256:a4d5d1546bf92372a6fb1b450164735fb107b2ee69d15bf87421fec3c78465f9"},
{file = "toga_cocoa-0.5.2.tar.gz", hash = "sha256:dd8e1e29eff53c2e4cbe3ded9ea037716062b65f82b9cac478a82e15ba0a2750"},
]
[package.dependencies]
fonttools = ">=4.42.1,<5.0.0"
rubicon-objc = ">=0.5.1,<0.6.0"
toga-core = "0.5.2"
[[package]]
name = "toga-core"
version = "0.5.2"
@@ -2905,7 +3098,7 @@ files = [
{file = "toga_core-0.5.2-py3-none-any.whl", hash = "sha256:e872cebd2d899e9138f73393e8cd834a55a057aa269608ff7314a853ab33cb4e"},
{file = "toga_core-0.5.2.tar.gz", hash = "sha256:bdd3760146b74c8d315cb901392c2b645ab3e5d4cd90114f3e36e0e7dad3d6d1"},
]
markers = {main = "extra == \"gui\""}
markers = {main = "extra == \"gui\" or extra == \"gui-gtk\" or extra == \"gui-win\" or extra == \"gui-mac\""}
[package.dependencies]
travertino = "0.5.2"
@@ -2929,6 +3122,42 @@ files = [
[package.dependencies]
toga-core = "0.5.2"
[[package]]
name = "toga-gtk"
version = "0.5.2"
description = "A GTK backend for the Toga widget toolkit."
optional = true
python-versions = ">=3.9"
groups = ["main"]
markers = "extra == \"gui-gtk\""
files = [
{file = "toga_gtk-0.5.2-py3-none-any.whl", hash = "sha256:15b346ac1a2584de5effe5e73a3888f055c68c93300aeb111db9d64186b31646"},
{file = "toga_gtk-0.5.2.tar.gz", hash = "sha256:9212db774dd5f47820d2242bb09c9f44e12e87e3db49ccb967016c6bb311139b"},
]
[package.dependencies]
pycairo = ">=1.17.0"
pygobject = ">=3.50.0"
toga-core = "0.5.2"
[[package]]
name = "toga-winforms"
version = "0.5.2"
description = "A Windows backend for the Toga widget toolkit using the WinForms API."
optional = true
python-versions = ">=3.9"
groups = ["main"]
markers = "extra == \"gui-win\""
files = [
{file = "toga_winforms-0.5.2-py3-none-any.whl", hash = "sha256:83181309f204bcc4a34709d23fdfd68467ae8ecc39c906d13c661cb9a0ef581b"},
{file = "toga_winforms-0.5.2.tar.gz", hash = "sha256:7e65ee9a31db6588c41c01cc49c12df1b2019581410be6ba7c685e7ac75f7c4a"},
]
[package.dependencies]
pillow = ">=10.0.0"
pythonnet = ">=3.0.0"
toga-core = "0.5.2"
[[package]]
name = "toml"
version = "0.10.2"
@@ -2995,7 +3224,7 @@ files = [
{file = "travertino-0.5.2-py3-none-any.whl", hash = "sha256:fd69ac3b14f2847e4c972198588b8a86ca3b437aaa0c8ce7259bbe5dab17aff1"},
{file = "travertino-0.5.2.tar.gz", hash = "sha256:5afcc673e14e16c3c04c0e3fe387062633e6bc88e87bc0bbd214a04b4dfbbcd4"},
]
markers = {main = "extra == \"gui\""}
markers = {main = "extra == \"gui\" or extra == \"gui-gtk\" or extra == \"gui-win\" or extra == \"gui-mac\""}
[package.extras]
dev = ["coverage-conditional-plugin (==0.9.0)", "coverage[toml] (==7.9.2)", "pytest (==8.4.1)", "tox (==4.27.0)", "typing-extensions (==4.12.2) ; python_version < \"3.10\""]
@@ -3390,8 +3619,11 @@ propcache = ">=0.2.1"
[extras]
gui = ["pillow", "toga-core"]
gui-gtk = ["toga-gtk"]
gui-mac = ["toga-cocoa"]
gui-win = ["toga-winforms"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.13"
content-hash = "8d9d5db692f39b9b05e0a365d779505583074f510d34de17627ac1849ca61bde"
content-hash = "9cdc15f624271aab6d58e5f945c0e99878079da7c3f5a397b0753166c06f9612"

View File

@@ -36,9 +36,15 @@ PyJWT = ">=2.8.0"
slowapi = "^0.1.9"
toga-core = { version = ">=0.5.2", optional = true }
pillow = { version = "*", optional = true }
toga-gtk = { version = ">=0.5.2", optional = true }
toga-winforms = { version = ">=0.5.2", optional = true }
toga-cocoa = { version = ">=0.5.2", optional = true }
[tool.poetry.extras]
gui = ["toga-core", "pillow"]
gui-gtk = ["toga-gtk"]
gui-win = ["toga-winforms"]
gui-mac = ["toga-cocoa"]
[tool.poetry.group.dev.dependencies]
pytest = "^8.2"

View File

@@ -197,14 +197,15 @@ main() {
fi
fi
if [ "$GUI_READY" = true ]; then
pip install -e .[gui]
print_info "Installing platform-specific Toga backend..."
if [ "$OS_NAME" = "Linux" ]; then
print_info "Installing toga-gtk for Linux..."
pip install toga-gtk
print_info "Installing Linux GUI dependencies..."
pip install -e ".[gui-gtk]"
elif [ "$OS_NAME" = "Darwin" ]; then
print_info "Installing toga-cocoa for macOS..."
pip install toga-cocoa
print_info "Installing macOS GUI dependencies..."
pip install -e ".[gui-mac]"
else
print_warning "Unsupported OS for GUI installation. Installing core package only."
pip install -e .
fi
else
print_warning "Skipping GUI installation."

View File

@@ -34,13 +34,9 @@ def initialize_app() -> None:
"""Ensure the application directory exists."""
try:
APP_DIR.mkdir(exist_ok=True, parents=True)
if logger.isEnabledFor(logging.DEBUG):
logger.info(f"Application directory created at {APP_DIR}")
logger.debug("Application directory created at %s", APP_DIR)
except Exception as exc:
if logger.isEnabledFor(logging.DEBUG):
logger.error(
f"Failed to create application directory: {exc}", exc_info=True
)
logger.error("Failed to create application directory: %s", exc, exc_info=True)
# -----------------------------------

View File

@@ -38,7 +38,11 @@ from utils import (
)
from utils.clipboard import ClipboardUnavailableError
from utils.atomic_write import atomic_write
import queue
from utils.logging_utils import (
ConsolePauseFilter,
ChecksumWarningFilter,
pause_logging_for_ui,
)
from local_bip85.bip85 import Bip85Error
@@ -57,7 +61,7 @@ def _warn_missing_optional_dependencies() -> None:
try:
importlib.import_module(module)
except ModuleNotFoundError:
logging.warning(
logging.debug(
"Optional dependency '%s' is not installed; %s will be unavailable.",
module,
feature,
@@ -77,43 +81,39 @@ def load_global_config() -> dict:
return {}
def configure_logging():
logger = logging.getLogger()
logger.setLevel(logging.DEBUG) # Keep this as DEBUG to capture all logs
def configure_logging() -> None:
"""Configure application-wide logging handlers."""
# Remove all handlers associated with the root logger object
for handler in logger.handlers[:]:
logger.removeHandler(handler)
# Ensure the 'logs' directory exists
log_directory = Path("logs")
if not log_directory.exists():
log_directory.mkdir(parents=True, exist_ok=True)
log_directory.mkdir(parents=True, exist_ok=True)
# Create handlers
c_handler = logging.StreamHandler(sys.stdout)
f_handler = logging.FileHandler(log_directory / "main.log")
console_handler = logging.StreamHandler(sys.stderr)
console_handler.setLevel(logging.WARNING)
console_handler.addFilter(ConsolePauseFilter())
console_handler.addFilter(ChecksumWarningFilter())
# Set levels: only errors and critical messages will be shown in the console
c_handler.setLevel(logging.ERROR)
f_handler.setLevel(logging.DEBUG)
file_handler = logging.FileHandler(log_directory / "main.log")
file_handler.setLevel(logging.DEBUG)
# Create formatters and add them to handlers
formatter = logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s [%(filename)s:%(lineno)d]"
"%(asctime)s [%(levelname)s] %(message)s [%(filename)s:%(lineno)d]",
)
c_handler.setFormatter(formatter)
f_handler.setFormatter(formatter)
console_handler.setFormatter(formatter)
file_handler.setFormatter(formatter)
# Add handlers to the logger
logger.addHandler(c_handler)
logger.addHandler(f_handler)
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
root_logger.handlers.clear()
root_logger.addHandler(console_handler)
root_logger.addHandler(file_handler)
# Set logging level for third-party libraries to WARNING to suppress their debug logs
logging.getLogger("monstr").setLevel(logging.WARNING)
logging.getLogger("nostr").setLevel(logging.WARNING)
logging.captureWarnings(True)
logging.getLogger("monstr").setLevel(logging.ERROR)
logging.getLogger("nostr").setLevel(logging.ERROR)
@pause_logging_for_ui
def confirm_action(prompt: str) -> bool:
"""
Prompts the user for confirmation.
@@ -162,6 +162,7 @@ def get_notification_text(pm: PasswordManager) -> str:
return color_text(getattr(note, "message", ""), category)
@pause_logging_for_ui
def handle_switch_fingerprint(password_manager: PasswordManager):
"""
Handles switching the active fingerprint.

View File

@@ -9,6 +9,7 @@ from typing import Optional
import typer
from .common import _get_services
from seedpass.core.errors import SeedPassError
app = typer.Typer(
help="SeedPass command line interface",
@@ -49,6 +50,15 @@ app.add_typer(util.app, name="util")
app.add_typer(api.app, name="api")
def run() -> None:
"""Invoke the CLI, handling SeedPass errors gracefully."""
try:
app()
except SeedPassError as exc:
typer.echo(str(exc), err=True)
raise typer.Exit(1) from exc
def _gui_backend_available() -> bool:
"""Return True if a platform-specific BeeWare backend is installed."""
for pkg in ("toga_gtk", "toga_winforms", "toga_cocoa"):
@@ -173,4 +183,4 @@ def gui(
if __name__ == "__main__": # pragma: no cover
app()
run()

View File

@@ -145,6 +145,28 @@ class BackupManager:
)
)
def restore_from_backup(self, backup_path: str) -> None:
"""Restore the index file from a user-specified backup path."""
try:
src = Path(backup_path)
if not src.exists():
logger.error(f"Backup file '{src}' does not exist.")
print(colored(f"Error: Backup file '{src}' does not exist.", "red"))
return
shutil.copy2(src, self.index_file)
os.chmod(self.index_file, 0o600)
logger.info(f"Index file restored from backup '{src}'.")
print(colored(f"[+] Index file restored from backup '{src}'.", "green"))
except Exception as e:
logger.error(
f"Failed to restore from backup '{backup_path}': {e}", exc_info=True
)
print(
colored(
f"Error: Failed to restore from backup '{backup_path}': {e}", "red"
)
)
def list_backups(self) -> None:
try:
backup_files = sorted(

View File

@@ -243,7 +243,7 @@ class ConfigManager:
def get_offline_mode(self) -> bool:
"""Retrieve the offline mode setting."""
config = self.load_config(require_pin=False)
return bool(config.get("offline_mode", False))
return bool(config.get("offline_mode", True))
def set_clipboard_clear_delay(self, delay: int) -> None:
"""Persist clipboard clear timeout in seconds."""

View File

@@ -29,6 +29,7 @@ from utils.file_lock import exclusive_lock
from mnemonic import Mnemonic
from utils.password_prompt import prompt_existing_password
from utils.key_derivation import KdfConfig, CURRENT_KDF_VERSION
from .errors import DecryptionError
# Instantiate the logger
logger = logging.getLogger(__name__)
@@ -137,12 +138,15 @@ class EncryptionManager:
ciphertext = encrypted_data[15:]
if len(ciphertext) < 16:
logger.error("AES-GCM payload too short")
raise InvalidToken("AES-GCM payload too short")
raise DecryptionError(
f"Failed to decrypt{ctx}: AES-GCM payload too short"
)
return self.cipher.decrypt(nonce, ciphertext, None)
except InvalidTag as e:
msg = f"Failed to decrypt{ctx}: invalid key or corrupt file"
logger.error(msg)
raise InvalidToken(msg) from e
logger.error(f"Failed to decrypt{ctx}: invalid key or corrupt file")
raise DecryptionError(
f"Failed to decrypt{ctx}: invalid key or corrupt file"
) from e
# Next try the older V2 format
if encrypted_data.startswith(b"V2:"):
@@ -151,7 +155,9 @@ class EncryptionManager:
ciphertext = encrypted_data[15:]
if len(ciphertext) < 16:
logger.error("AES-GCM payload too short")
raise InvalidToken("AES-GCM payload too short")
raise DecryptionError(
f"Failed to decrypt{ctx}: AES-GCM payload too short"
)
return self.cipher.decrypt(nonce, ciphertext, None)
except InvalidTag as e:
logger.debug(
@@ -164,9 +170,12 @@ class EncryptionManager:
)
return result
except InvalidToken:
msg = f"Failed to decrypt{ctx}: invalid key or corrupt file"
logger.error(msg)
raise InvalidToken(msg) from e
logger.error(
f"Failed to decrypt{ctx}: invalid key or corrupt file"
)
raise DecryptionError(
f"Failed to decrypt{ctx}: invalid key or corrupt file"
) from e
# If it's neither V3 nor V2, assume legacy Fernet format
logger.warning("Data is in legacy Fernet format. Attempting migration.")
@@ -176,18 +185,23 @@ class EncryptionManager:
logger.error(
"Legacy Fernet decryption failed. Vault may be corrupt or key is incorrect."
)
raise e
raise DecryptionError(
f"Failed to decrypt{ctx}: invalid key or corrupt file"
) from e
except (InvalidToken, InvalidTag) as e:
if encrypted_data.startswith(b"V3|") or encrypted_data.startswith(b"V2:"):
# Already determined not to be legacy; re-raise
raise
if isinstance(e, InvalidToken) and str(e) == "AES-GCM payload too short":
raise
if not self._legacy_migrate_flag:
except DecryptionError as e:
if (
encrypted_data.startswith(b"V3|")
or encrypted_data.startswith(b"V2:")
or not self._legacy_migrate_flag
):
raise
logger.debug(f"Could not decrypt data{ctx}: {e}")
raise LegacyFormatRequiresMigrationError(context)
raise LegacyFormatRequiresMigrationError(context) from e
except (InvalidToken, InvalidTag) as e: # pragma: no cover - safety net
raise DecryptionError(
f"Failed to decrypt{ctx}: invalid key or corrupt file"
) from e
def decrypt_legacy(
self, encrypted_data: bytes, password: str, context: Optional[str] = None
@@ -224,8 +238,8 @@ class EncryptionManager:
except Exception as e2: # pragma: no cover - try next iteration
last_exc = e2
logger.error(f"Failed legacy decryption attempt: {last_exc}", exc_info=True)
raise InvalidToken(
f"Could not decrypt{ctx} with any available method."
raise DecryptionError(
f"Failed to decrypt{ctx}: invalid key or corrupt file"
) from last_exc
# --- All functions below this point now use the smart `decrypt_data` method ---
@@ -409,10 +423,16 @@ class EncryptionManager:
if return_kdf:
return data, kdf
return data
except (InvalidToken, InvalidTag) as e:
except DecryptionError as e:
msg = f"Failed to decrypt or parse data from {file_path}: {e}"
logger.error(msg)
raise InvalidToken(msg) from e
raise
except (InvalidToken, InvalidTag) as e: # pragma: no cover - legacy safety
msg = f"Failed to decrypt or parse data from {file_path}: {e}"
logger.error(msg)
raise DecryptionError(
f"Failed to decrypt {file_path}: invalid key or corrupt file"
) from e
except JSONDecodeError as e:
msg = f"Failed to parse JSON data from {file_path}: {e}"
logger.error(msg)
@@ -484,7 +504,7 @@ class EncryptionManager:
logger.info("Index file from Nostr was processed and saved successfully.")
self.last_migration_performed = is_legacy
return True
except (InvalidToken, LegacyFormatRequiresMigrationError):
except (DecryptionError, LegacyFormatRequiresMigrationError):
try:
password = prompt_existing_password(
"Enter your master password for legacy decryption: "

View File

@@ -25,7 +25,6 @@ except Exception: # pragma: no cover - fallback when orjson is missing
USE_ORJSON = False
import logging
import hashlib
import sys
import shutil
import time
from typing import Optional, Tuple, Dict, Any, List
@@ -48,6 +47,7 @@ from utils.key_validation import (
from .vault import Vault
from .backup import BackupManager
from .errors import SeedPassError
# Instantiate the logger
@@ -148,7 +148,7 @@ class EntryManager:
except Exception as e:
logger.error(f"Error determining next index: {e}", exc_info=True)
print(colored(f"Error determining next index: {e}", "red"))
sys.exit(1)
raise SeedPassError(f"Error determining next index: {e}") from e
def add_entry(
self,
@@ -238,7 +238,7 @@ class EntryManager:
except Exception as e:
logger.error(f"Failed to add entry: {e}", exc_info=True)
print(colored(f"Error: Failed to add entry: {e}", "red"))
sys.exit(1)
raise SeedPassError(f"Failed to add entry: {e}") from e
def get_next_totp_index(self) -> int:
"""Return the next available derivation index for TOTP secrets."""

View File

@@ -0,0 +1,30 @@
"""Custom exceptions for SeedPass core modules.
This module defines :class:`SeedPassError`, a base exception used across the
core modules. Library code should raise this error instead of terminating the
process with ``sys.exit`` so that callers can handle failures gracefully.
When raised inside the CLI, :class:`SeedPassError` behaves like a Click
exception, displaying a friendly message and exiting with code ``1``.
"""
from click import ClickException
from cryptography.fernet import InvalidToken
class SeedPassError(ClickException):
"""Base exception for SeedPass-related errors."""
def __init__(self, message: str):
super().__init__(message)
class DecryptionError(InvalidToken, SeedPassError):
"""Raised when encrypted data cannot be decrypted.
Subclasses :class:`cryptography.fernet.InvalidToken` so callers expecting
the cryptography exception continue to work.
"""
__all__ = ["SeedPassError", "DecryptionError"]

View File

@@ -37,7 +37,7 @@ from .password_generation import PasswordGenerator
from .backup import BackupManager
from .vault import Vault
from .portable_backup import export_backup, import_backup, PortableMode
from cryptography.fernet import InvalidToken
from .errors import SeedPassError, DecryptionError
from .totp import TotpManager
from .entry_types import EntryType
from .pubsub import bus
@@ -102,6 +102,7 @@ from mnemonic import Mnemonic
from datetime import datetime
from utils.fingerprint_manager import FingerprintManager
from utils.logging_utils import pause_logging_for_ui
# Import NostrClient
from nostr.client import NostrClient
@@ -559,7 +560,7 @@ class PasswordManager:
print(
colored(f"Error: Failed to initialize FingerprintManager: {e}", "red")
)
sys.exit(1)
raise SeedPassError(f"Failed to initialize FingerprintManager: {e}") from e
def setup_parent_seed(self) -> None:
"""
@@ -601,7 +602,7 @@ class PasswordManager:
choice = input("Select a seed profile by number: ").strip()
if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints) + 1):
print(colored("Invalid selection. Exiting.", "red"))
sys.exit(1)
raise SeedPassError("Invalid selection.")
choice = int(choice)
if choice == len(fingerprints) + 1:
@@ -615,7 +616,7 @@ class PasswordManager:
except Exception as e:
logger.error(f"Error during seed profile selection: {e}", exc_info=True)
print(colored(f"Error: Failed to select seed profile: {e}", "red"))
sys.exit(1)
raise SeedPassError(f"Failed to select seed profile: {e}") from e
def add_new_fingerprint(self):
"""
@@ -638,7 +639,7 @@ class PasswordManager:
fingerprint = self.generate_new_seed()
else:
print(colored("Invalid choice. Exiting.", "red"))
sys.exit(1)
raise SeedPassError("Invalid choice.")
if not fingerprint:
return None
@@ -661,7 +662,7 @@ class PasswordManager:
except Exception as e:
logger.error(f"Error adding new seed profile: {e}", exc_info=True)
print(colored(f"Error: Failed to add new seed profile: {e}", "red"))
sys.exit(1)
raise SeedPassError(f"Failed to add new seed profile: {e}") from e
def select_fingerprint(
self, fingerprint: str, *, password: Optional[str] = None
@@ -678,7 +679,9 @@ class PasswordManager:
"red",
)
)
sys.exit(1)
raise SeedPassError(
f"Seed profile directory for {fingerprint} not found."
)
# Setup the encryption manager and load parent seed
self.setup_encryption_manager(self.fingerprint_dir, password)
# Initialize BIP85 and other managers
@@ -692,7 +695,7 @@ class PasswordManager:
)
else:
print(colored(f"Error: Seed profile {fingerprint} not found.", "red"))
sys.exit(1)
raise SeedPassError(f"Seed profile {fingerprint} not found.")
def setup_encryption_manager(
self,
@@ -748,13 +751,11 @@ class PasswordManager:
):
self.config_manager.set_kdf_iterations(iter_try)
break
except InvalidToken:
except DecryptionError:
seed_mgr = None
if seed_mgr is None:
msg = (
"Invalid password for selected seed profile. Please try again."
)
msg = "Incorrect password or corrupt file"
print(colored(msg, "red"))
attempts += 1
password = None
@@ -784,10 +785,10 @@ class PasswordManager:
logger.error(f"Failed to set up EncryptionManager: {e}", exc_info=True)
print(colored(f"Error: Failed to set up encryption: {e}", "red"))
if exit_on_fail:
sys.exit(1)
raise SeedPassError(f"Failed to set up encryption: {e}") from e
return False
if exit_on_fail:
sys.exit(1)
raise SeedPassError("Failed to set up encryption")
return False
def load_parent_seed(
@@ -826,11 +827,16 @@ class PasswordManager:
seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate()
self.derive_key_hierarchy(seed_bytes)
self.bip85 = BIP85(seed_bytes)
except DecryptionError as e:
logger.error(f"Failed to load parent seed: {e}", exc_info=True)
print(colored("Incorrect password or corrupt file", "red"))
raise SeedPassError("Incorrect password or corrupt file") from e
except Exception as e:
logger.error(f"Failed to load parent seed: {e}", exc_info=True)
print(colored(f"Error: Failed to load parent seed: {e}", "red"))
sys.exit(1)
raise SeedPassError(f"Failed to load parent seed: {e}") from e
@pause_logging_for_ui
@requires_unlocked
def handle_switch_fingerprint(self, *, password: Optional[str] = None) -> bool:
return self.profile_service.handle_switch_fingerprint(password=password)
@@ -891,6 +897,7 @@ class PasswordManager:
self.update_activity()
self.start_background_sync()
@pause_logging_for_ui
def handle_existing_seed(self, *, password: Optional[str] = None) -> None:
"""
Handles the scenario where an existing parent seed file is found.
@@ -913,7 +920,9 @@ class PasswordManager:
"red",
)
)
sys.exit(1)
raise SeedPassError(
"No seed profiles available. Please add a seed profile first."
)
print(colored("Available Seed Profiles:", "cyan"))
for idx, fp in enumerate(fingerprints, start=1):
@@ -927,7 +936,7 @@ class PasswordManager:
choice = input("Select a seed profile by number: ").strip()
if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)):
print(colored("Invalid selection. Exiting.", "red"))
sys.exit(1)
raise SeedPassError("Invalid selection.")
selected_fingerprint = fingerprints[int(choice) - 1]
self.current_fingerprint = selected_fingerprint
@@ -936,7 +945,7 @@ class PasswordManager:
)
if not fingerprint_dir:
print(colored("Error: Seed profile directory not found.", "red"))
sys.exit(1)
raise SeedPassError("Seed profile directory not found.")
# Derive encryption key from password using selected fingerprint
iterations = (
@@ -966,15 +975,16 @@ class PasswordManager:
if not self.validate_bip85_seed(self.parent_seed):
logging.error("Decrypted seed is invalid. Exiting.")
print(colored("Error: Decrypted seed is invalid.", "red"))
sys.exit(1)
raise SeedPassError("Decrypted seed is invalid.")
self.initialize_bip85()
logging.debug("Parent seed decrypted and validated successfully.")
except Exception as e:
logging.error(f"Failed to decrypt parent seed: {e}", exc_info=True)
print(colored(f"Error: Failed to decrypt parent seed: {e}", "red"))
sys.exit(1)
raise SeedPassError(f"Failed to decrypt parent seed: {e}") from e
@pause_logging_for_ui
def handle_new_seed_setup(self) -> None:
"""
Handles the setup process when no existing parent seed is found.
@@ -988,7 +998,8 @@ class PasswordManager:
"2. Enter an existing seed one word at a time\n"
"3. Generate a new seed\n"
"4. Restore from Nostr\n"
"Enter choice (1/2/3/4): "
"5. Restore from local backup\n"
"Enter choice (1/2/3/4/5): "
).strip()
if choice == "1":
@@ -1001,9 +1012,18 @@ class PasswordManager:
seed_phrase = masked_input("Enter your 12-word BIP-85 seed: ").strip()
self.restore_from_nostr_with_guidance(seed_phrase)
return
elif choice == "5":
backup_path = input("Enter backup file path: ").strip()
if not getattr(self, "fingerprint_manager", None):
self.initialize_fingerprint_manager()
seed_phrase = masked_input("Enter your 12-word BIP-85 seed: ").strip()
fp = self._finalize_existing_seed(seed_phrase)
if fp:
self.backup_manager.restore_from_backup(backup_path)
return
else:
print(colored("Invalid choice. Exiting.", "red"))
sys.exit(1)
raise SeedPassError("Invalid choice.")
# Some seed loading paths may not initialize managers; ensure they exist
if getattr(self, "config_manager", None) is None:
@@ -1040,13 +1060,13 @@ class PasswordManager:
if not self.validate_bip85_seed(parent_seed):
logging.error("Invalid BIP-85 seed phrase. Exiting.")
print(colored("Error: Invalid BIP-85 seed phrase.", "red"))
sys.exit(1)
raise SeedPassError("Invalid BIP-85 seed phrase.")
fingerprint = self._finalize_existing_seed(parent_seed, password=password)
return fingerprint
except KeyboardInterrupt:
logging.info("Operation cancelled by user.")
self.notify("Operation cancelled by user.", level="WARNING")
sys.exit(0)
raise SeedPassError("Operation cancelled by user.")
def setup_existing_seed_word_by_word(
self, *, seed: Optional[str] = None, password: Optional[str] = None
@@ -1079,7 +1099,9 @@ class PasswordManager:
"red",
)
)
sys.exit(1)
raise SeedPassError(
"Failed to generate seed profile for the provided seed."
)
fingerprint_dir = self.fingerprint_manager.get_fingerprint_directory(
fingerprint
@@ -1088,7 +1110,7 @@ class PasswordManager:
print(
colored("Error: Failed to retrieve seed profile directory.", "red")
)
sys.exit(1)
raise SeedPassError("Failed to retrieve seed profile directory.")
self.current_fingerprint = fingerprint
self.fingerprint_manager.current_fingerprint = fingerprint
@@ -1142,7 +1164,7 @@ class PasswordManager:
else:
logging.error("Invalid BIP-85 seed phrase. Exiting.")
print(colored("Error: Invalid BIP-85 seed phrase.", "red"))
sys.exit(1)
raise SeedPassError("Invalid BIP-85 seed phrase.")
@requires_unlocked
def generate_new_seed(self) -> Optional[str]:
@@ -1187,7 +1209,7 @@ class PasswordManager:
"red",
)
)
sys.exit(1)
raise SeedPassError("Failed to generate seed profile for the new seed.")
fingerprint_dir = self.fingerprint_manager.get_fingerprint_directory(
fingerprint
@@ -1196,7 +1218,7 @@ class PasswordManager:
print(
colored("Error: Failed to retrieve seed profile directory.", "red")
)
sys.exit(1)
raise SeedPassError("Failed to retrieve seed profile directory.")
# Persist the assigned account index for the new profile
try:
@@ -1224,7 +1246,7 @@ class PasswordManager:
return fingerprint # Return the generated fingerprint
else:
self.notify("Seed generation cancelled. Exiting.", level="WARNING")
sys.exit(0)
raise SeedPassError("Seed generation cancelled.")
def validate_bip85_seed(self, seed: str) -> bool:
"""
@@ -1261,11 +1283,11 @@ class PasswordManager:
except Bip85Error as e:
logging.error(f"Failed to generate BIP-85 seed: {e}", exc_info=True)
print(colored(f"Error: Failed to generate BIP-85 seed: {e}", "red"))
sys.exit(1)
raise SeedPassError(f"Failed to generate BIP-85 seed: {e}") from e
except Exception as e:
logging.error(f"Failed to generate BIP-85 seed: {e}", exc_info=True)
print(colored(f"Error: Failed to generate BIP-85 seed: {e}", "red"))
sys.exit(1)
raise SeedPassError(f"Failed to generate BIP-85 seed: {e}") from e
@requires_unlocked
def save_and_encrypt_seed(
@@ -1338,7 +1360,7 @@ class PasswordManager:
except Exception as e:
logging.error(f"Failed to encrypt and save parent seed: {e}", exc_info=True)
print(colored(f"Error: Failed to encrypt and save parent seed: {e}", "red"))
sys.exit(1)
raise SeedPassError(f"Failed to encrypt and save parent seed: {e}") from e
def initialize_bip85(self):
"""
@@ -1367,7 +1389,7 @@ class PasswordManager:
except Exception as e:
logging.error(f"Failed to initialize BIP-85: {e}", exc_info=True)
print(colored(f"Error: Failed to initialize BIP-85: {e}", "red"))
sys.exit(1)
raise SeedPassError(f"Failed to initialize BIP-85: {e}") from e
def initialize_managers(self) -> None:
"""
@@ -1407,7 +1429,7 @@ class PasswordManager:
)
except RuntimeError as exc:
print(colored(str(exc), "red"))
sys.exit(1)
raise SeedPassError(str(exc))
self.entry_manager = EntryManager(
vault=self.vault,
@@ -1509,7 +1531,7 @@ class PasswordManager:
except Exception as e:
logger.error(f"Failed to initialize managers: {e}", exc_info=True)
print(colored(f"Error: Failed to initialize managers: {e}", "red"))
sys.exit(1)
raise SeedPassError(f"Failed to initialize managers: {e}") from e
async def sync_index_from_nostr_async(self) -> None:
"""Always fetch the latest vault data from Nostr and update the local index."""
@@ -1864,9 +1886,11 @@ class PasswordManager:
self.notify("Starting with a new, empty vault.", level="INFO")
return
@pause_logging_for_ui
def handle_add_password(self) -> None:
self.entry_service.handle_add_password()
@pause_logging_for_ui
def handle_add_totp(self) -> None:
"""Add a TOTP entry either derived from the seed or imported."""
try:
@@ -2008,6 +2032,7 @@ class PasswordManager:
print(colored(f"Error: Failed to add TOTP: {e}", "red"))
pause()
@pause_logging_for_ui
def handle_add_ssh_key(self) -> None:
"""Add an SSH key pair entry and display the derived keys."""
try:
@@ -2065,6 +2090,7 @@ class PasswordManager:
print(colored(f"Error: Failed to add SSH key: {e}", "red"))
pause()
@pause_logging_for_ui
def handle_add_seed(self) -> None:
"""Add a derived BIP-39 seed phrase entry."""
try:
@@ -2134,6 +2160,7 @@ class PasswordManager:
print(colored(f"Error: Failed to add seed phrase: {e}", "red"))
pause()
@pause_logging_for_ui
def handle_add_pgp(self) -> None:
"""Add a PGP key entry and display the generated key."""
try:
@@ -2201,6 +2228,7 @@ class PasswordManager:
print(colored(f"Error: Failed to add PGP key: {e}", "red"))
pause()
@pause_logging_for_ui
def handle_add_nostr_key(self) -> None:
"""Add a Nostr key entry and display the derived keys."""
try:
@@ -2260,6 +2288,7 @@ class PasswordManager:
print(colored(f"Error: Failed to add Nostr key: {e}", "red"))
pause()
@pause_logging_for_ui
def handle_add_key_value(self) -> None:
"""Add a generic key/value entry."""
try:
@@ -2341,6 +2370,7 @@ class PasswordManager:
print(colored(f"Error: Failed to add key/value entry: {e}", "red"))
pause()
@pause_logging_for_ui
def handle_add_managed_account(self) -> None:
"""Add a managed account seed entry."""
try:
@@ -3130,6 +3160,7 @@ class PasswordManager:
print(colored("Error: Failed to retrieve the password.", "red"))
return
@pause_logging_for_ui
def handle_retrieve_entry(self) -> None:
"""Prompt for an index and display the corresponding entry."""
try:
@@ -3164,6 +3195,7 @@ class PasswordManager:
print(colored(f"Error: Failed to retrieve password: {e}", "red"))
pause()
@pause_logging_for_ui
def handle_modify_entry(self) -> None:
"""
Handles modifying an existing password entry by prompting the user for the index number
@@ -3604,6 +3636,7 @@ class PasswordManager:
logging.error(f"Error during modifying entry: {e}", exc_info=True)
print(colored(f"Error: Failed to modify entry: {e}", "red"))
@pause_logging_for_ui
def handle_search_entries(self) -> None:
"""Prompt for a query, list matches and optionally show details."""
try:
@@ -3813,6 +3846,7 @@ class PasswordManager:
)
print("-" * 40)
@pause_logging_for_ui
def handle_list_entries(self) -> None:
self.menu_handler.handle_list_entries()
@@ -3853,6 +3887,7 @@ class PasswordManager:
logging.error(f"Error during entry deletion: {e}", exc_info=True)
print(colored(f"Error: Failed to delete entry: {e}", "red"))
@pause_logging_for_ui
def handle_archive_entry(self) -> None:
"""Archive an entry without deleting it."""
try:
@@ -3871,6 +3906,7 @@ class PasswordManager:
logging.error(f"Error archiving entry: {e}", exc_info=True)
print(colored(f"Error: Failed to archive entry: {e}", "red"))
@pause_logging_for_ui
def handle_view_archived_entries(self) -> None:
"""Display archived entries and optionally view or restore them."""
try:
@@ -3938,9 +3974,11 @@ class PasswordManager:
logging.error(f"Error viewing archived entries: {e}", exc_info=True)
print(colored(f"Error: Failed to view archived entries: {e}", "red"))
@pause_logging_for_ui
def handle_display_totp_codes(self) -> None:
self.menu_handler.handle_display_totp_codes()
@pause_logging_for_ui
def handle_verify_checksum(self) -> None:
"""
Handles verifying the script's checksum against the stored checksum to ensure integrity.
@@ -3980,6 +4018,7 @@ class PasswordManager:
logging.error(f"Error during checksum verification: {e}", exc_info=True)
print(colored(f"Error: Failed to verify checksum: {e}", "red"))
@pause_logging_for_ui
def handle_update_script_checksum(self) -> None:
"""Generate a new checksum for the manager script."""
if not confirm_action("Generate new script checksum? (Y/N): "):
@@ -4125,6 +4164,7 @@ class PasswordManager:
logging.error(f"Failed to restore backup: {e}", exc_info=True)
print(colored(f"Error: Failed to restore backup: {e}", "red"))
@pause_logging_for_ui
def handle_export_database(
self,
dest: Path | None = None,
@@ -4167,6 +4207,7 @@ class PasswordManager:
print(colored(f"Error: Failed to export database: {e}", "red"))
return None
@pause_logging_for_ui
def handle_import_database(self, src: Path) -> None:
"""Import a portable database file, replacing the current index."""
@@ -4206,7 +4247,7 @@ class PasswordManager:
src,
parent_seed=self.parent_seed,
)
except InvalidToken:
except DecryptionError:
logging.error("Invalid backup token during import", exc_info=True)
print(
colored(
@@ -4249,6 +4290,7 @@ class PasswordManager:
print(colored("Database imported successfully.", "green"))
self.sync_vault()
@pause_logging_for_ui
def handle_export_totp_codes(self) -> Path | None:
"""Export all 2FA codes to a JSON file for other authenticator apps."""
try:
@@ -4319,6 +4361,7 @@ class PasswordManager:
print(colored(f"Error: Failed to export 2FA codes: {e}", "red"))
return None
@pause_logging_for_ui
def handle_backup_reveal_parent_seed(
self, file: Path | None = None, *, password: Optional[str] = None
) -> None:
@@ -4446,7 +4489,7 @@ class PasswordManager:
else:
logging.warning("Password verification failed.")
return is_correct
except InvalidToken as e:
except DecryptionError as e:
logging.error(f"Failed to decrypt config: {e}")
print(
colored(

View File

@@ -10,6 +10,7 @@ from .entry_types import EntryType, ALL_ENTRY_TYPES
import seedpass.core.manager as manager_module
from utils.color_scheme import color_text
from utils.terminal_utils import clear_header_with_notification
from utils.logging_utils import pause_logging_for_ui
if TYPE_CHECKING: # pragma: no cover - typing only
from .manager import PasswordManager
@@ -21,6 +22,7 @@ class MenuHandler:
def __init__(self, manager: PasswordManager) -> None:
self.manager = manager
@pause_logging_for_ui
def handle_list_entries(self) -> None:
"""List entries and optionally show details."""
pm = self.manager
@@ -86,6 +88,7 @@ class MenuHandler:
logging.error(f"Failed to list entries: {e}", exc_info=True)
print(colored(f"Error: Failed to list entries: {e}", "red"))
@pause_logging_for_ui
def handle_display_totp_codes(self) -> None:
"""Display all stored TOTP codes with a countdown progress bar."""
pm = self.manager

View File

@@ -1,4 +1,13 @@
class VaultLockedError(Exception):
"""Compatibility layer for historic exception types."""
from .core.errors import SeedPassError
class VaultLockedError(SeedPassError):
"""Raised when an operation requires an unlocked vault."""
pass
def __init__(self, message: str = "Vault is locked") -> None:
super().__init__(message)
__all__ = ["VaultLockedError", "SeedPassError"]

View File

@@ -77,7 +77,7 @@ class DummyPM:
set_offline_mode=lambda v: None,
get_secret_mode_enabled=lambda: True,
get_clipboard_clear_delay=lambda: 30,
get_offline_mode=lambda: False,
get_offline_mode=lambda: True,
)
self.secret_mode_enabled = True
self.clipboard_clear_delay = 30

View File

@@ -7,7 +7,7 @@ from seedpass.cli import common as cli_common
runner = CliRunner()
def _make_pm(called, enabled=False):
def _make_pm(called, enabled=True):
cfg = SimpleNamespace(
get_offline_mode=lambda: enabled,
set_offline_mode=lambda v: called.setdefault("enabled", v),
@@ -24,10 +24,10 @@ def test_toggle_offline_updates(monkeypatch):
called = {}
pm = _make_pm(called)
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["config", "toggle-offline"], input="y\n")
result = runner.invoke(app, ["config", "toggle-offline"], input="n\n")
assert result.exit_code == 0
assert called == {"enabled": True}
assert "Offline mode enabled." in result.stdout
assert called == {"enabled": False}
assert "Offline mode disabled." in result.stdout
def test_toggle_offline_keep(monkeypatch):

View File

@@ -0,0 +1,23 @@
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from seedpass.core.manager import PasswordManager
from seedpass.core.config_manager import ConfigManager
from seedpass.core.errors import SeedPassError
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
def test_invalid_password_shows_friendly_message_once(capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
pm = PasswordManager.__new__(PasswordManager)
pm.config_manager = ConfigManager(vault, tmp_path)
pm.fingerprint_dir = tmp_path
pm.parent_seed = ""
with pytest.raises(SeedPassError):
pm.load_parent_seed(tmp_path, password="wrongpass")
captured = capsys.readouterr().out
assert captured.count("Incorrect password or corrupt file") == 1

View File

@@ -14,6 +14,7 @@ import gzip
from seedpass.core.manager import PasswordManager, EncryptionMode
from seedpass.core.vault import Vault
from seedpass.core.errors import SeedPassError
def test_legacy_index_migrates(monkeypatch, tmp_path: Path):
@@ -386,7 +387,7 @@ def test_declined_migration_no_sync_prompt(monkeypatch, tmp_path: Path):
monkeypatch.setattr("seedpass.core.manager.confirm_action", fake_confirm)
with pytest.raises(SystemExit):
with pytest.raises(SeedPassError):
pm.initialize_managers()
assert calls["confirm"] == 0
@@ -425,7 +426,7 @@ def test_failed_migration_no_sync_prompt(monkeypatch, tmp_path: Path):
monkeypatch.setattr("seedpass.core.manager.confirm_action", fake_confirm)
with pytest.raises(SystemExit):
with pytest.raises(SeedPassError):
pm.initialize_managers()
assert calls["confirm"] == 0

View File

@@ -74,6 +74,61 @@ def test_handle_new_seed_setup_restore_from_nostr(monkeypatch, tmp_path, capsys)
assert labels == ["site1"]
def test_handle_new_seed_setup_restore_from_local_backup(monkeypatch, tmp_path, capsys):
dir_a = tmp_path / "A"
dir_b = tmp_path / "B"
dir_a.mkdir()
dir_b.mkdir()
pm_src = _init_pm(dir_a, None)
pm_src.notify = lambda *a, **k: None
pm_src.entry_manager.add_entry("site1", 12)
pm_src.backup_manager.create_backup()
backup_path = next(
pm_src.backup_manager.backup_dir.glob("entries_db_backup_*.json.enc")
)
pm_new = PasswordManager.__new__(PasswordManager)
pm_new.encryption_mode = EncryptionMode.SEED_ONLY
pm_new.notify = lambda *a, **k: None
called = {"init": False}
def init_fp_mgr():
called["init"] = True
pm_new.fingerprint_manager = object()
monkeypatch.setattr(pm_new, "initialize_fingerprint_manager", init_fp_mgr)
def finalize(seed, *, password=None):
assert pm_new.fingerprint_manager is not None
vault, enc_mgr = create_vault(dir_b, seed, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, dir_b)
backup_mgr = BackupManager(dir_b, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
pm_new.encryption_manager = enc_mgr
pm_new.vault = vault
pm_new.entry_manager = entry_mgr
pm_new.backup_manager = backup_mgr
pm_new.config_manager = cfg_mgr
pm_new.fingerprint_dir = dir_b
pm_new.current_fingerprint = "fp"
return "fp"
monkeypatch.setattr(pm_new, "_finalize_existing_seed", finalize)
monkeypatch.setattr("seedpass.core.manager.masked_input", lambda *_: TEST_SEED)
inputs = iter(["5", str(backup_path)])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
pm_new.handle_new_seed_setup()
out = capsys.readouterr().out
assert "Index file restored from backup" in out
labels = [e[1] for e in pm_new.entry_manager.list_entries()]
assert labels == ["site1"]
assert called["init"]
async def _no_snapshot():
return None

View File

@@ -1,4 +1,5 @@
import types
import pytest
from utils import seed_prompt
@@ -46,6 +47,37 @@ def test_masked_input_windows_space(monkeypatch, capsys):
assert out.count("*") == 4
def test_masked_input_posix_ctrl_c(monkeypatch):
seq = iter(["\x03"])
monkeypatch.setattr(seed_prompt.sys.stdin, "read", lambda n=1: next(seq))
monkeypatch.setattr(seed_prompt.sys.stdin, "fileno", lambda: 0)
calls: list[tuple[str, int]] = []
fake_termios = types.SimpleNamespace(
tcgetattr=lambda fd: "old",
tcsetattr=lambda fd, *_: calls.append(("tcsetattr", fd)),
TCSADRAIN=1,
)
fake_tty = types.SimpleNamespace(setraw=lambda fd: calls.append(("setraw", fd)))
monkeypatch.setattr(seed_prompt, "termios", fake_termios)
monkeypatch.setattr(seed_prompt, "tty", fake_tty)
monkeypatch.setattr(seed_prompt.sys, "platform", "linux", raising=False)
with pytest.raises(KeyboardInterrupt):
seed_prompt.masked_input("Enter: ")
assert calls == [("setraw", 0), ("tcsetattr", 0)]
def test_masked_input_windows_ctrl_c(monkeypatch):
seq = iter(["\x03"])
fake_msvcrt = types.SimpleNamespace(getwch=lambda: next(seq))
monkeypatch.setattr(seed_prompt, "msvcrt", fake_msvcrt)
monkeypatch.setattr(seed_prompt.sys, "platform", "win32", raising=False)
with pytest.raises(KeyboardInterrupt):
seed_prompt.masked_input("Password: ")
def test_prompt_seed_words_valid(monkeypatch):
from mnemonic import Mnemonic

View File

@@ -4,6 +4,7 @@ from types import SimpleNamespace
import pytest
import seedpass.core.manager as manager_module
from seedpass.core.errors import SeedPassError
from helpers import TEST_SEED
from utils import seed_prompt
@@ -86,7 +87,7 @@ def test_add_new_fingerprint_words_flow_invalid_phrase(monkeypatch):
monkeypatch.setattr(seed_prompt, "clear_screen", lambda *_a, **_k: None)
monkeypatch.setattr(builtins, "input", lambda *_: next(inputs))
with pytest.raises(SystemExit):
with pytest.raises(SeedPassError):
pm.add_new_fingerprint()
assert pm.fingerprint_manager.current_fingerprint is None

View File

@@ -0,0 +1,62 @@
import logging
from pathlib import Path
from contextlib import contextmanager
from functools import wraps
_console_paused = False
class ConsolePauseFilter(logging.Filter):
"""Filter that blocks records when console logging is paused."""
def filter(
self, record: logging.LogRecord
) -> bool: # pragma: no cover - small utility
return not _console_paused
class ChecksumWarningFilter(logging.Filter):
"""Filter allowing only checksum warnings and errors to surface."""
def filter(
self, record: logging.LogRecord
) -> bool: # pragma: no cover - simple filter
if record.levelno >= logging.ERROR:
return True
return (
record.levelno == logging.WARNING
and Path(record.pathname).name == "checksum.py"
)
def pause_console_logging() -> None:
"""Temporarily pause logging to console handlers."""
global _console_paused
_console_paused = True
def resume_console_logging() -> None:
"""Resume logging to console handlers."""
global _console_paused
_console_paused = False
@contextmanager
def console_logging_paused() -> None:
"""Context manager to pause console logging within a block."""
pause_console_logging()
try:
yield
finally:
resume_console_logging()
def pause_logging_for_ui(func):
"""Decorator to pause console logging while ``func`` executes."""
@wraps(func)
def wrapper(*args, **kwargs):
with console_logging_paused():
return func(*args, **kwargs)
return wrapper

View File

@@ -15,6 +15,7 @@ except ImportError: # pragma: no cover - POSIX only
tty = None # type: ignore
from utils.terminal_utils import clear_screen
from utils.logging_utils import pause_console_logging, resume_console_logging
DEFAULT_MAX_ATTEMPTS = 5
@@ -58,6 +59,8 @@ def _masked_input_windows(prompt: str) -> str:
buffer: list[str] = []
while True:
ch = msvcrt.getwch()
if ch == "\x03":
raise KeyboardInterrupt
if ch in ("\r", "\n"):
sys.stdout.write("\n")
return "".join(buffer)
@@ -85,6 +88,8 @@ def _masked_input_posix(prompt: str) -> str:
tty.setraw(fd)
while True:
ch = sys.stdin.read(1)
if ch == "\x03":
raise KeyboardInterrupt
if ch in ("\r", "\n"):
sys.stdout.write("\n")
return "".join(buffer)
@@ -103,10 +108,15 @@ def _masked_input_posix(prompt: str) -> str:
def masked_input(prompt: str) -> str:
"""Return input from the user while masking typed characters."""
func = _masked_input_windows if sys.platform == "win32" else _masked_input_posix
pause_console_logging()
try:
return func(prompt)
except KeyboardInterrupt:
raise
except Exception: # pragma: no cover - fallback when TTY operations fail
return input(prompt)
finally:
resume_console_logging()
def prompt_seed_words(count: int = 12, *, max_attempts: int | None = None) -> str: