mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 15:28:44 +00:00
Compare commits
24 Commits
cfb861b60a
...
beta
Author | SHA1 | Date | |
---|---|---|---|
![]() |
ca733be2e3 | ||
![]() |
e528bebae3 | ||
![]() |
e760bf2b25 | ||
![]() |
d106802a18 | ||
![]() |
f2648a8c1d | ||
![]() |
d030cf9692 | ||
![]() |
bebbca8169 | ||
![]() |
4d7e3d4b63 | ||
![]() |
7b0344739f | ||
![]() |
fde09bd1a0 | ||
![]() |
b307728c05 | ||
![]() |
8ade9e3028 | ||
![]() |
c0a6187478 | ||
![]() |
d9f76ee668 | ||
![]() |
40a75adcb7 | ||
![]() |
bd1588fba1 | ||
![]() |
d5e0d61db4 | ||
![]() |
d795ac9006 | ||
![]() |
ee3d9d8e9d | ||
![]() |
2b68df9428 | ||
![]() |
a2a663eed1 | ||
![]() |
ae59ede374 | ||
![]() |
61b1aa6773 | ||
![]() |
428efd02b4 |
63
README.md
63
README.md
@@ -18,7 +18,7 @@ Recent releases derive passwords and other artifacts using a fully deterministic
|
|||||||
|
|
||||||
**⚠️ First Run Warning**
|
**⚠️ 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
|
### Supported OS
|
||||||
@@ -123,13 +123,13 @@ See `docs/ARCHITECTURE.md` and [Nostr Setup](docs/nostr_setup.md) for details.
|
|||||||
### 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 can also install the BeeWare backend for your platform when requested (`--mode gui` or `--mode both` on Linux/macOS, `-IncludeGui` on Windows).
|
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
|
||||||
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.
|
necessary system packages using `apt`, `yum`, `pacman`, or Homebrew. When no display server is detected, GUI components are skipped automatically.
|
||||||
|
|
||||||
**Linux and macOS:**
|
**Linux and macOS:**
|
||||||
```bash
|
```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:*
|
*Install the beta branch:*
|
||||||
```bash
|
```bash
|
||||||
@@ -143,6 +143,7 @@ bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/
|
|||||||
|
|
||||||
**Windows (PowerShell):**
|
**Windows (PowerShell):**
|
||||||
```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))
|
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:*
|
*Install with the optional GUI:*
|
||||||
@@ -237,8 +238,9 @@ After installing `xclip`, restart SeedPass to enable clipboard support.
|
|||||||
### Optional GUI
|
### Optional GUI
|
||||||
|
|
||||||
SeedPass ships with a GTK-based desktop interface that is still in development
|
SeedPass ships with a GTK-based desktop interface that is still in development
|
||||||
and not currently functional. Install the packages for your platform before
|
and not currently functional. GUI backends are optional—run the installer with
|
||||||
adding the Python GUI dependencies.
|
`--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**
|
- **Debian/Ubuntu**
|
||||||
```bash
|
```bash
|
||||||
@@ -257,14 +259,22 @@ adding the Python GUI dependencies.
|
|||||||
brew install pygobject3 gtk+3 adwaita-icon-theme librsvg webkitgtk
|
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
|
```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
|
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
|
```bash
|
||||||
pip install .
|
pip install .
|
||||||
@@ -323,31 +333,30 @@ python -m seedpass_gui
|
|||||||
seedpass-gui
|
seedpass-gui
|
||||||
```
|
```
|
||||||
|
|
||||||
GUI dependencies are optional. Install them alongside SeedPass with:
|
GUI dependencies are optional. Install them alongside SeedPass with the
|
||||||
|
extra for your platform:
|
||||||
```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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux
|
# Linux
|
||||||
pip install toga-gtk
|
pip install "seedpass[gui-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
|
# Windows
|
||||||
pip install toga-winforms
|
pip install "seedpass[gui-win]"
|
||||||
|
|
||||||
# macOS
|
# 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.
|
The GUI works with the same vault and configuration files as the CLI.
|
||||||
|
@@ -78,7 +78,7 @@ Manage the entire vault for a profile.
|
|||||||
|
|
||||||
### Nostr Commands
|
### 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 |
|
| Action | Command | Examples |
|
||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
|
@@ -83,7 +83,7 @@ maintainable while enabling a consistent experience on multiple platforms.
|
|||||||
- **Change Master Password:** Rotate your encryption password at any time.
|
- **Change Master Password:** Rotate your encryption password at any time.
|
||||||
- **Checksum Verification Utilities:** Verify or regenerate the script checksum.
|
- **Checksum Verification Utilities:** Verify or regenerate the script checksum.
|
||||||
- **Relay Management:** List, add, remove or reset configured Nostr relays.
|
- **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
|
## Prerequisites
|
||||||
|
|
||||||
@@ -472,7 +472,7 @@ Back in the Settings menu you can:
|
|||||||
whether both the encrypted database and the script itself pass checksum
|
whether both the encrypted database and the script itself pass checksum
|
||||||
validation.
|
validation.
|
||||||
* Choose `14` to toggle Secret Mode and set the clipboard clear delay.
|
* 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.
|
* 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.
|
* 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
## Contributing
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
# Nostr Setup
|
# 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
|
## Relay Configuration
|
||||||
|
|
||||||
|
238
poetry.lock
generated
238
poetry.lock
generated
@@ -724,6 +724,22 @@ files = [
|
|||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
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]]
|
[[package]]
|
||||||
name = "coincurve"
|
name = "coincurve"
|
||||||
version = "21.0.0"
|
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)"]
|
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\""]
|
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]]
|
[[package]]
|
||||||
name = "freezegun"
|
name = "freezegun"
|
||||||
version = "1.5.4"
|
version = "1.5.4"
|
||||||
@@ -2332,6 +2430,35 @@ files = [
|
|||||||
{file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"},
|
{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]]
|
[[package]]
|
||||||
name = "pycparser"
|
name = "pycparser"
|
||||||
version = "2.22"
|
version = "2.22"
|
||||||
@@ -2544,6 +2671,21 @@ files = [
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
windows-terminal = ["colorama (>=0.4.6)"]
|
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]]
|
[[package]]
|
||||||
name = "pyjwt"
|
name = "pyjwt"
|
||||||
version = "2.10.1"
|
version = "2.10.1"
|
||||||
@@ -2702,6 +2844,22 @@ files = [
|
|||||||
{file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"},
|
{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]]
|
[[package]]
|
||||||
name = "pywin32"
|
name = "pywin32"
|
||||||
version = "311"
|
version = "311"
|
||||||
@@ -2794,6 +2952,23 @@ pygments = ">=2.13.0,<3.0.0"
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
jupyter = ["ipywidgets (>=7.5.1,<9)"]
|
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]]
|
[[package]]
|
||||||
name = "shellingham"
|
name = "shellingham"
|
||||||
version = "1.5.4"
|
version = "1.5.4"
|
||||||
@@ -2894,6 +3069,24 @@ files = [
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
tests = ["pytest", "pytest-cov"]
|
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]]
|
[[package]]
|
||||||
name = "toga-core"
|
name = "toga-core"
|
||||||
version = "0.5.2"
|
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-py3-none-any.whl", hash = "sha256:e872cebd2d899e9138f73393e8cd834a55a057aa269608ff7314a853ab33cb4e"},
|
||||||
{file = "toga_core-0.5.2.tar.gz", hash = "sha256:bdd3760146b74c8d315cb901392c2b645ab3e5d4cd90114f3e36e0e7dad3d6d1"},
|
{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]
|
[package.dependencies]
|
||||||
travertino = "0.5.2"
|
travertino = "0.5.2"
|
||||||
@@ -2929,6 +3122,42 @@ files = [
|
|||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
toga-core = "0.5.2"
|
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]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.10.2"
|
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-py3-none-any.whl", hash = "sha256:fd69ac3b14f2847e4c972198588b8a86ca3b437aaa0c8ce7259bbe5dab17aff1"},
|
||||||
{file = "travertino-0.5.2.tar.gz", hash = "sha256:5afcc673e14e16c3c04c0e3fe387062633e6bc88e87bc0bbd214a04b4dfbbcd4"},
|
{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]
|
[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\""]
|
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]
|
[extras]
|
||||||
gui = ["pillow", "toga-core"]
|
gui = ["pillow", "toga-core"]
|
||||||
|
gui-gtk = ["toga-gtk"]
|
||||||
|
gui-mac = ["toga-cocoa"]
|
||||||
|
gui-win = ["toga-winforms"]
|
||||||
|
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.1"
|
lock-version = "2.1"
|
||||||
python-versions = ">=3.10,<3.13"
|
python-versions = ">=3.10,<3.13"
|
||||||
content-hash = "8d9d5db692f39b9b05e0a365d779505583074f510d34de17627ac1849ca61bde"
|
content-hash = "9cdc15f624271aab6d58e5f945c0e99878079da7c3f5a397b0753166c06f9612"
|
||||||
|
@@ -36,9 +36,15 @@ PyJWT = ">=2.8.0"
|
|||||||
slowapi = "^0.1.9"
|
slowapi = "^0.1.9"
|
||||||
toga-core = { version = ">=0.5.2", optional = true }
|
toga-core = { version = ">=0.5.2", optional = true }
|
||||||
pillow = { version = "*", 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]
|
[tool.poetry.extras]
|
||||||
gui = ["toga-core", "pillow"]
|
gui = ["toga-core", "pillow"]
|
||||||
|
gui-gtk = ["toga-gtk"]
|
||||||
|
gui-win = ["toga-winforms"]
|
||||||
|
gui-mac = ["toga-cocoa"]
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
pytest = "^8.2"
|
pytest = "^8.2"
|
||||||
|
@@ -197,14 +197,15 @@ main() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
if [ "$GUI_READY" = true ]; then
|
if [ "$GUI_READY" = true ]; then
|
||||||
pip install -e .[gui]
|
|
||||||
print_info "Installing platform-specific Toga backend..."
|
|
||||||
if [ "$OS_NAME" = "Linux" ]; then
|
if [ "$OS_NAME" = "Linux" ]; then
|
||||||
print_info "Installing toga-gtk for Linux..."
|
print_info "Installing Linux GUI dependencies..."
|
||||||
pip install toga-gtk
|
pip install -e ".[gui-gtk]"
|
||||||
elif [ "$OS_NAME" = "Darwin" ]; then
|
elif [ "$OS_NAME" = "Darwin" ]; then
|
||||||
print_info "Installing toga-cocoa for macOS..."
|
print_info "Installing macOS GUI dependencies..."
|
||||||
pip install toga-cocoa
|
pip install -e ".[gui-mac]"
|
||||||
|
else
|
||||||
|
print_warning "Unsupported OS for GUI installation. Installing core package only."
|
||||||
|
pip install -e .
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
print_warning "Skipping GUI installation."
|
print_warning "Skipping GUI installation."
|
||||||
|
@@ -34,13 +34,9 @@ def initialize_app() -> None:
|
|||||||
"""Ensure the application directory exists."""
|
"""Ensure the application directory exists."""
|
||||||
try:
|
try:
|
||||||
APP_DIR.mkdir(exist_ok=True, parents=True)
|
APP_DIR.mkdir(exist_ok=True, parents=True)
|
||||||
if logger.isEnabledFor(logging.DEBUG):
|
logger.debug("Application directory created at %s", APP_DIR)
|
||||||
logger.info(f"Application directory created at {APP_DIR}")
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if logger.isEnabledFor(logging.DEBUG):
|
logger.error("Failed to create application directory: %s", exc, exc_info=True)
|
||||||
logger.error(
|
|
||||||
f"Failed to create application directory: {exc}", exc_info=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------
|
# -----------------------------------
|
||||||
|
57
src/main.py
57
src/main.py
@@ -38,7 +38,11 @@ from utils import (
|
|||||||
)
|
)
|
||||||
from utils.clipboard import ClipboardUnavailableError
|
from utils.clipboard import ClipboardUnavailableError
|
||||||
from utils.atomic_write import atomic_write
|
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
|
from local_bip85.bip85 import Bip85Error
|
||||||
|
|
||||||
|
|
||||||
@@ -57,7 +61,7 @@ def _warn_missing_optional_dependencies() -> None:
|
|||||||
try:
|
try:
|
||||||
importlib.import_module(module)
|
importlib.import_module(module)
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
logging.warning(
|
logging.debug(
|
||||||
"Optional dependency '%s' is not installed; %s will be unavailable.",
|
"Optional dependency '%s' is not installed; %s will be unavailable.",
|
||||||
module,
|
module,
|
||||||
feature,
|
feature,
|
||||||
@@ -77,43 +81,39 @@ def load_global_config() -> dict:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def configure_logging():
|
def configure_logging() -> None:
|
||||||
logger = logging.getLogger()
|
"""Configure application-wide logging handlers."""
|
||||||
logger.setLevel(logging.DEBUG) # Keep this as DEBUG to capture all logs
|
|
||||||
|
|
||||||
# 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")
|
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
|
console_handler = logging.StreamHandler(sys.stderr)
|
||||||
c_handler = logging.StreamHandler(sys.stdout)
|
console_handler.setLevel(logging.WARNING)
|
||||||
f_handler = logging.FileHandler(log_directory / "main.log")
|
console_handler.addFilter(ConsolePauseFilter())
|
||||||
|
console_handler.addFilter(ChecksumWarningFilter())
|
||||||
|
|
||||||
# Set levels: only errors and critical messages will be shown in the console
|
file_handler = logging.FileHandler(log_directory / "main.log")
|
||||||
c_handler.setLevel(logging.ERROR)
|
file_handler.setLevel(logging.DEBUG)
|
||||||
f_handler.setLevel(logging.DEBUG)
|
|
||||||
|
|
||||||
# Create formatters and add them to handlers
|
|
||||||
formatter = logging.Formatter(
|
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)
|
console_handler.setFormatter(formatter)
|
||||||
f_handler.setFormatter(formatter)
|
file_handler.setFormatter(formatter)
|
||||||
|
|
||||||
# Add handlers to the logger
|
root_logger = logging.getLogger()
|
||||||
logger.addHandler(c_handler)
|
root_logger.setLevel(logging.DEBUG)
|
||||||
logger.addHandler(f_handler)
|
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.captureWarnings(True)
|
||||||
logging.getLogger("monstr").setLevel(logging.WARNING)
|
|
||||||
logging.getLogger("nostr").setLevel(logging.WARNING)
|
logging.getLogger("monstr").setLevel(logging.ERROR)
|
||||||
|
logging.getLogger("nostr").setLevel(logging.ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def confirm_action(prompt: str) -> bool:
|
def confirm_action(prompt: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Prompts the user for confirmation.
|
Prompts the user for confirmation.
|
||||||
@@ -162,6 +162,7 @@ def get_notification_text(pm: PasswordManager) -> str:
|
|||||||
return color_text(getattr(note, "message", ""), category)
|
return color_text(getattr(note, "message", ""), category)
|
||||||
|
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_switch_fingerprint(password_manager: PasswordManager):
|
def handle_switch_fingerprint(password_manager: PasswordManager):
|
||||||
"""
|
"""
|
||||||
Handles switching the active fingerprint.
|
Handles switching the active fingerprint.
|
||||||
|
@@ -9,6 +9,7 @@ from typing import Optional
|
|||||||
import typer
|
import typer
|
||||||
|
|
||||||
from .common import _get_services
|
from .common import _get_services
|
||||||
|
from seedpass.core.errors import SeedPassError
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
help="SeedPass command line interface",
|
help="SeedPass command line interface",
|
||||||
@@ -49,6 +50,15 @@ app.add_typer(util.app, name="util")
|
|||||||
app.add_typer(api.app, name="api")
|
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:
|
def _gui_backend_available() -> bool:
|
||||||
"""Return True if a platform-specific BeeWare backend is installed."""
|
"""Return True if a platform-specific BeeWare backend is installed."""
|
||||||
for pkg in ("toga_gtk", "toga_winforms", "toga_cocoa"):
|
for pkg in ("toga_gtk", "toga_winforms", "toga_cocoa"):
|
||||||
@@ -173,4 +183,4 @@ def gui(
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
app()
|
run()
|
||||||
|
@@ -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:
|
def list_backups(self) -> None:
|
||||||
try:
|
try:
|
||||||
backup_files = sorted(
|
backup_files = sorted(
|
||||||
|
@@ -243,7 +243,7 @@ class ConfigManager:
|
|||||||
def get_offline_mode(self) -> bool:
|
def get_offline_mode(self) -> bool:
|
||||||
"""Retrieve the offline mode setting."""
|
"""Retrieve the offline mode setting."""
|
||||||
config = self.load_config(require_pin=False)
|
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:
|
def set_clipboard_clear_delay(self, delay: int) -> None:
|
||||||
"""Persist clipboard clear timeout in seconds."""
|
"""Persist clipboard clear timeout in seconds."""
|
||||||
|
@@ -29,6 +29,7 @@ from utils.file_lock import exclusive_lock
|
|||||||
from mnemonic import Mnemonic
|
from mnemonic import Mnemonic
|
||||||
from utils.password_prompt import prompt_existing_password
|
from utils.password_prompt import prompt_existing_password
|
||||||
from utils.key_derivation import KdfConfig, CURRENT_KDF_VERSION
|
from utils.key_derivation import KdfConfig, CURRENT_KDF_VERSION
|
||||||
|
from .errors import DecryptionError
|
||||||
|
|
||||||
# Instantiate the logger
|
# Instantiate the logger
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -137,12 +138,15 @@ class EncryptionManager:
|
|||||||
ciphertext = encrypted_data[15:]
|
ciphertext = encrypted_data[15:]
|
||||||
if len(ciphertext) < 16:
|
if len(ciphertext) < 16:
|
||||||
logger.error("AES-GCM payload too short")
|
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)
|
return self.cipher.decrypt(nonce, ciphertext, None)
|
||||||
except InvalidTag as e:
|
except InvalidTag as e:
|
||||||
msg = f"Failed to decrypt{ctx}: invalid key or corrupt file"
|
logger.error(f"Failed to decrypt{ctx}: invalid key or corrupt file")
|
||||||
logger.error(msg)
|
raise DecryptionError(
|
||||||
raise InvalidToken(msg) from e
|
f"Failed to decrypt{ctx}: invalid key or corrupt file"
|
||||||
|
) from e
|
||||||
|
|
||||||
# Next try the older V2 format
|
# Next try the older V2 format
|
||||||
if encrypted_data.startswith(b"V2:"):
|
if encrypted_data.startswith(b"V2:"):
|
||||||
@@ -151,7 +155,9 @@ class EncryptionManager:
|
|||||||
ciphertext = encrypted_data[15:]
|
ciphertext = encrypted_data[15:]
|
||||||
if len(ciphertext) < 16:
|
if len(ciphertext) < 16:
|
||||||
logger.error("AES-GCM payload too short")
|
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)
|
return self.cipher.decrypt(nonce, ciphertext, None)
|
||||||
except InvalidTag as e:
|
except InvalidTag as e:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -164,9 +170,12 @@ class EncryptionManager:
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
except InvalidToken:
|
except InvalidToken:
|
||||||
msg = f"Failed to decrypt{ctx}: invalid key or corrupt file"
|
logger.error(
|
||||||
logger.error(msg)
|
f"Failed to decrypt{ctx}: invalid key or corrupt file"
|
||||||
raise InvalidToken(msg) from e
|
)
|
||||||
|
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
|
# If it's neither V3 nor V2, assume legacy Fernet format
|
||||||
logger.warning("Data is in legacy Fernet format. Attempting migration.")
|
logger.warning("Data is in legacy Fernet format. Attempting migration.")
|
||||||
@@ -176,18 +185,23 @@ class EncryptionManager:
|
|||||||
logger.error(
|
logger.error(
|
||||||
"Legacy Fernet decryption failed. Vault may be corrupt or key is incorrect."
|
"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:
|
except DecryptionError as e:
|
||||||
if encrypted_data.startswith(b"V3|") or encrypted_data.startswith(b"V2:"):
|
if (
|
||||||
# Already determined not to be legacy; re-raise
|
encrypted_data.startswith(b"V3|")
|
||||||
raise
|
or encrypted_data.startswith(b"V2:")
|
||||||
if isinstance(e, InvalidToken) and str(e) == "AES-GCM payload too short":
|
or not self._legacy_migrate_flag
|
||||||
raise
|
):
|
||||||
if not self._legacy_migrate_flag:
|
|
||||||
raise
|
raise
|
||||||
logger.debug(f"Could not decrypt data{ctx}: {e}")
|
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(
|
def decrypt_legacy(
|
||||||
self, encrypted_data: bytes, password: str, context: Optional[str] = None
|
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
|
except Exception as e2: # pragma: no cover - try next iteration
|
||||||
last_exc = e2
|
last_exc = e2
|
||||||
logger.error(f"Failed legacy decryption attempt: {last_exc}", exc_info=True)
|
logger.error(f"Failed legacy decryption attempt: {last_exc}", exc_info=True)
|
||||||
raise InvalidToken(
|
raise DecryptionError(
|
||||||
f"Could not decrypt{ctx} with any available method."
|
f"Failed to decrypt{ctx}: invalid key or corrupt file"
|
||||||
) from last_exc
|
) from last_exc
|
||||||
|
|
||||||
# --- All functions below this point now use the smart `decrypt_data` method ---
|
# --- All functions below this point now use the smart `decrypt_data` method ---
|
||||||
@@ -409,10 +423,16 @@ class EncryptionManager:
|
|||||||
if return_kdf:
|
if return_kdf:
|
||||||
return data, kdf
|
return data, kdf
|
||||||
return data
|
return data
|
||||||
except (InvalidToken, InvalidTag) as e:
|
except DecryptionError as e:
|
||||||
msg = f"Failed to decrypt or parse data from {file_path}: {e}"
|
msg = f"Failed to decrypt or parse data from {file_path}: {e}"
|
||||||
logger.error(msg)
|
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:
|
except JSONDecodeError as e:
|
||||||
msg = f"Failed to parse JSON data from {file_path}: {e}"
|
msg = f"Failed to parse JSON data from {file_path}: {e}"
|
||||||
logger.error(msg)
|
logger.error(msg)
|
||||||
@@ -484,7 +504,7 @@ class EncryptionManager:
|
|||||||
logger.info("Index file from Nostr was processed and saved successfully.")
|
logger.info("Index file from Nostr was processed and saved successfully.")
|
||||||
self.last_migration_performed = is_legacy
|
self.last_migration_performed = is_legacy
|
||||||
return True
|
return True
|
||||||
except (InvalidToken, LegacyFormatRequiresMigrationError):
|
except (DecryptionError, LegacyFormatRequiresMigrationError):
|
||||||
try:
|
try:
|
||||||
password = prompt_existing_password(
|
password = prompt_existing_password(
|
||||||
"Enter your master password for legacy decryption: "
|
"Enter your master password for legacy decryption: "
|
||||||
|
@@ -25,7 +25,6 @@ except Exception: # pragma: no cover - fallback when orjson is missing
|
|||||||
USE_ORJSON = False
|
USE_ORJSON = False
|
||||||
import logging
|
import logging
|
||||||
import hashlib
|
import hashlib
|
||||||
import sys
|
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
from typing import Optional, Tuple, Dict, Any, List
|
from typing import Optional, Tuple, Dict, Any, List
|
||||||
@@ -48,6 +47,7 @@ from utils.key_validation import (
|
|||||||
|
|
||||||
from .vault import Vault
|
from .vault import Vault
|
||||||
from .backup import BackupManager
|
from .backup import BackupManager
|
||||||
|
from .errors import SeedPassError
|
||||||
|
|
||||||
|
|
||||||
# Instantiate the logger
|
# Instantiate the logger
|
||||||
@@ -148,7 +148,7 @@ class EntryManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error determining next index: {e}", exc_info=True)
|
logger.error(f"Error determining next index: {e}", exc_info=True)
|
||||||
print(colored(f"Error determining next index: {e}", "red"))
|
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(
|
def add_entry(
|
||||||
self,
|
self,
|
||||||
@@ -238,7 +238,7 @@ class EntryManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to add entry: {e}", exc_info=True)
|
logger.error(f"Failed to add entry: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to add entry: {e}", "red"))
|
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:
|
def get_next_totp_index(self) -> int:
|
||||||
"""Return the next available derivation index for TOTP secrets."""
|
"""Return the next available derivation index for TOTP secrets."""
|
||||||
|
30
src/seedpass/core/errors.py
Normal file
30
src/seedpass/core/errors.py
Normal 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"]
|
@@ -37,7 +37,7 @@ from .password_generation import PasswordGenerator
|
|||||||
from .backup import BackupManager
|
from .backup import BackupManager
|
||||||
from .vault import Vault
|
from .vault import Vault
|
||||||
from .portable_backup import export_backup, import_backup, PortableMode
|
from .portable_backup import export_backup, import_backup, PortableMode
|
||||||
from cryptography.fernet import InvalidToken
|
from .errors import SeedPassError, DecryptionError
|
||||||
from .totp import TotpManager
|
from .totp import TotpManager
|
||||||
from .entry_types import EntryType
|
from .entry_types import EntryType
|
||||||
from .pubsub import bus
|
from .pubsub import bus
|
||||||
@@ -102,6 +102,7 @@ from mnemonic import Mnemonic
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from utils.fingerprint_manager import FingerprintManager
|
from utils.fingerprint_manager import FingerprintManager
|
||||||
|
from utils.logging_utils import pause_logging_for_ui
|
||||||
|
|
||||||
# Import NostrClient
|
# Import NostrClient
|
||||||
from nostr.client import NostrClient
|
from nostr.client import NostrClient
|
||||||
@@ -559,7 +560,7 @@ class PasswordManager:
|
|||||||
print(
|
print(
|
||||||
colored(f"Error: Failed to initialize FingerprintManager: {e}", "red")
|
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:
|
def setup_parent_seed(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -601,7 +602,7 @@ class PasswordManager:
|
|||||||
choice = input("Select a seed profile by number: ").strip()
|
choice = input("Select a seed profile by number: ").strip()
|
||||||
if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints) + 1):
|
if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints) + 1):
|
||||||
print(colored("Invalid selection. Exiting.", "red"))
|
print(colored("Invalid selection. Exiting.", "red"))
|
||||||
sys.exit(1)
|
raise SeedPassError("Invalid selection.")
|
||||||
|
|
||||||
choice = int(choice)
|
choice = int(choice)
|
||||||
if choice == len(fingerprints) + 1:
|
if choice == len(fingerprints) + 1:
|
||||||
@@ -615,7 +616,7 @@ class PasswordManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during seed profile selection: {e}", exc_info=True)
|
logger.error(f"Error during seed profile selection: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to select seed profile: {e}", "red"))
|
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):
|
def add_new_fingerprint(self):
|
||||||
"""
|
"""
|
||||||
@@ -638,7 +639,7 @@ class PasswordManager:
|
|||||||
fingerprint = self.generate_new_seed()
|
fingerprint = self.generate_new_seed()
|
||||||
else:
|
else:
|
||||||
print(colored("Invalid choice. Exiting.", "red"))
|
print(colored("Invalid choice. Exiting.", "red"))
|
||||||
sys.exit(1)
|
raise SeedPassError("Invalid choice.")
|
||||||
|
|
||||||
if not fingerprint:
|
if not fingerprint:
|
||||||
return None
|
return None
|
||||||
@@ -661,7 +662,7 @@ class PasswordManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error adding new seed profile: {e}", exc_info=True)
|
logger.error(f"Error adding new seed profile: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to add new seed profile: {e}", "red"))
|
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(
|
def select_fingerprint(
|
||||||
self, fingerprint: str, *, password: Optional[str] = None
|
self, fingerprint: str, *, password: Optional[str] = None
|
||||||
@@ -678,7 +679,9 @@ class PasswordManager:
|
|||||||
"red",
|
"red",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
raise SeedPassError(
|
||||||
|
f"Seed profile directory for {fingerprint} not found."
|
||||||
|
)
|
||||||
# Setup the encryption manager and load parent seed
|
# Setup the encryption manager and load parent seed
|
||||||
self.setup_encryption_manager(self.fingerprint_dir, password)
|
self.setup_encryption_manager(self.fingerprint_dir, password)
|
||||||
# Initialize BIP85 and other managers
|
# Initialize BIP85 and other managers
|
||||||
@@ -692,7 +695,7 @@ class PasswordManager:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
print(colored(f"Error: Seed profile {fingerprint} not found.", "red"))
|
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(
|
def setup_encryption_manager(
|
||||||
self,
|
self,
|
||||||
@@ -748,13 +751,11 @@ class PasswordManager:
|
|||||||
):
|
):
|
||||||
self.config_manager.set_kdf_iterations(iter_try)
|
self.config_manager.set_kdf_iterations(iter_try)
|
||||||
break
|
break
|
||||||
except InvalidToken:
|
except DecryptionError:
|
||||||
seed_mgr = None
|
seed_mgr = None
|
||||||
|
|
||||||
if seed_mgr is None:
|
if seed_mgr is None:
|
||||||
msg = (
|
msg = "Incorrect password or corrupt file"
|
||||||
"Invalid password for selected seed profile. Please try again."
|
|
||||||
)
|
|
||||||
print(colored(msg, "red"))
|
print(colored(msg, "red"))
|
||||||
attempts += 1
|
attempts += 1
|
||||||
password = None
|
password = None
|
||||||
@@ -784,10 +785,10 @@ class PasswordManager:
|
|||||||
logger.error(f"Failed to set up EncryptionManager: {e}", exc_info=True)
|
logger.error(f"Failed to set up EncryptionManager: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to set up encryption: {e}", "red"))
|
print(colored(f"Error: Failed to set up encryption: {e}", "red"))
|
||||||
if exit_on_fail:
|
if exit_on_fail:
|
||||||
sys.exit(1)
|
raise SeedPassError(f"Failed to set up encryption: {e}") from e
|
||||||
return False
|
return False
|
||||||
if exit_on_fail:
|
if exit_on_fail:
|
||||||
sys.exit(1)
|
raise SeedPassError("Failed to set up encryption")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def load_parent_seed(
|
def load_parent_seed(
|
||||||
@@ -826,11 +827,16 @@ class PasswordManager:
|
|||||||
seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate()
|
seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate()
|
||||||
self.derive_key_hierarchy(seed_bytes)
|
self.derive_key_hierarchy(seed_bytes)
|
||||||
self.bip85 = BIP85(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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load parent seed: {e}", exc_info=True)
|
logger.error(f"Failed to load parent seed: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to load parent seed: {e}", "red"))
|
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
|
@requires_unlocked
|
||||||
def handle_switch_fingerprint(self, *, password: Optional[str] = None) -> bool:
|
def handle_switch_fingerprint(self, *, password: Optional[str] = None) -> bool:
|
||||||
return self.profile_service.handle_switch_fingerprint(password=password)
|
return self.profile_service.handle_switch_fingerprint(password=password)
|
||||||
@@ -891,6 +897,7 @@ class PasswordManager:
|
|||||||
self.update_activity()
|
self.update_activity()
|
||||||
self.start_background_sync()
|
self.start_background_sync()
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_existing_seed(self, *, password: Optional[str] = None) -> None:
|
def handle_existing_seed(self, *, password: Optional[str] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Handles the scenario where an existing parent seed file is found.
|
Handles the scenario where an existing parent seed file is found.
|
||||||
@@ -913,7 +920,9 @@ class PasswordManager:
|
|||||||
"red",
|
"red",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
raise SeedPassError(
|
||||||
|
"No seed profiles available. Please add a seed profile first."
|
||||||
|
)
|
||||||
|
|
||||||
print(colored("Available Seed Profiles:", "cyan"))
|
print(colored("Available Seed Profiles:", "cyan"))
|
||||||
for idx, fp in enumerate(fingerprints, start=1):
|
for idx, fp in enumerate(fingerprints, start=1):
|
||||||
@@ -927,7 +936,7 @@ class PasswordManager:
|
|||||||
choice = input("Select a seed profile by number: ").strip()
|
choice = input("Select a seed profile by number: ").strip()
|
||||||
if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)):
|
if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)):
|
||||||
print(colored("Invalid selection. Exiting.", "red"))
|
print(colored("Invalid selection. Exiting.", "red"))
|
||||||
sys.exit(1)
|
raise SeedPassError("Invalid selection.")
|
||||||
|
|
||||||
selected_fingerprint = fingerprints[int(choice) - 1]
|
selected_fingerprint = fingerprints[int(choice) - 1]
|
||||||
self.current_fingerprint = selected_fingerprint
|
self.current_fingerprint = selected_fingerprint
|
||||||
@@ -936,7 +945,7 @@ class PasswordManager:
|
|||||||
)
|
)
|
||||||
if not fingerprint_dir:
|
if not fingerprint_dir:
|
||||||
print(colored("Error: Seed profile directory not found.", "red"))
|
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
|
# Derive encryption key from password using selected fingerprint
|
||||||
iterations = (
|
iterations = (
|
||||||
@@ -966,15 +975,16 @@ class PasswordManager:
|
|||||||
if not self.validate_bip85_seed(self.parent_seed):
|
if not self.validate_bip85_seed(self.parent_seed):
|
||||||
logging.error("Decrypted seed is invalid. Exiting.")
|
logging.error("Decrypted seed is invalid. Exiting.")
|
||||||
print(colored("Error: Decrypted seed is invalid.", "red"))
|
print(colored("Error: Decrypted seed is invalid.", "red"))
|
||||||
sys.exit(1)
|
raise SeedPassError("Decrypted seed is invalid.")
|
||||||
|
|
||||||
self.initialize_bip85()
|
self.initialize_bip85()
|
||||||
logging.debug("Parent seed decrypted and validated successfully.")
|
logging.debug("Parent seed decrypted and validated successfully.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to decrypt parent seed: {e}", exc_info=True)
|
logging.error(f"Failed to decrypt parent seed: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to decrypt parent seed: {e}", "red"))
|
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:
|
def handle_new_seed_setup(self) -> None:
|
||||||
"""
|
"""
|
||||||
Handles the setup process when no existing parent seed is found.
|
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"
|
"2. Enter an existing seed one word at a time\n"
|
||||||
"3. Generate a new seed\n"
|
"3. Generate a new seed\n"
|
||||||
"4. Restore from Nostr\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()
|
).strip()
|
||||||
|
|
||||||
if choice == "1":
|
if choice == "1":
|
||||||
@@ -1001,9 +1012,18 @@ class PasswordManager:
|
|||||||
seed_phrase = masked_input("Enter your 12-word BIP-85 seed: ").strip()
|
seed_phrase = masked_input("Enter your 12-word BIP-85 seed: ").strip()
|
||||||
self.restore_from_nostr_with_guidance(seed_phrase)
|
self.restore_from_nostr_with_guidance(seed_phrase)
|
||||||
return
|
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:
|
else:
|
||||||
print(colored("Invalid choice. Exiting.", "red"))
|
print(colored("Invalid choice. Exiting.", "red"))
|
||||||
sys.exit(1)
|
raise SeedPassError("Invalid choice.")
|
||||||
|
|
||||||
# Some seed loading paths may not initialize managers; ensure they exist
|
# Some seed loading paths may not initialize managers; ensure they exist
|
||||||
if getattr(self, "config_manager", None) is None:
|
if getattr(self, "config_manager", None) is None:
|
||||||
@@ -1040,13 +1060,13 @@ class PasswordManager:
|
|||||||
if not self.validate_bip85_seed(parent_seed):
|
if not self.validate_bip85_seed(parent_seed):
|
||||||
logging.error("Invalid BIP-85 seed phrase. Exiting.")
|
logging.error("Invalid BIP-85 seed phrase. Exiting.")
|
||||||
print(colored("Error: Invalid BIP-85 seed phrase.", "red"))
|
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)
|
fingerprint = self._finalize_existing_seed(parent_seed, password=password)
|
||||||
return fingerprint
|
return fingerprint
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logging.info("Operation cancelled by user.")
|
logging.info("Operation cancelled by user.")
|
||||||
self.notify("Operation cancelled by user.", level="WARNING")
|
self.notify("Operation cancelled by user.", level="WARNING")
|
||||||
sys.exit(0)
|
raise SeedPassError("Operation cancelled by user.")
|
||||||
|
|
||||||
def setup_existing_seed_word_by_word(
|
def setup_existing_seed_word_by_word(
|
||||||
self, *, seed: Optional[str] = None, password: Optional[str] = None
|
self, *, seed: Optional[str] = None, password: Optional[str] = None
|
||||||
@@ -1079,7 +1099,9 @@ class PasswordManager:
|
|||||||
"red",
|
"red",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
raise SeedPassError(
|
||||||
|
"Failed to generate seed profile for the provided seed."
|
||||||
|
)
|
||||||
|
|
||||||
fingerprint_dir = self.fingerprint_manager.get_fingerprint_directory(
|
fingerprint_dir = self.fingerprint_manager.get_fingerprint_directory(
|
||||||
fingerprint
|
fingerprint
|
||||||
@@ -1088,7 +1110,7 @@ class PasswordManager:
|
|||||||
print(
|
print(
|
||||||
colored("Error: Failed to retrieve seed profile directory.", "red")
|
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.current_fingerprint = fingerprint
|
||||||
self.fingerprint_manager.current_fingerprint = fingerprint
|
self.fingerprint_manager.current_fingerprint = fingerprint
|
||||||
@@ -1142,7 +1164,7 @@ class PasswordManager:
|
|||||||
else:
|
else:
|
||||||
logging.error("Invalid BIP-85 seed phrase. Exiting.")
|
logging.error("Invalid BIP-85 seed phrase. Exiting.")
|
||||||
print(colored("Error: Invalid BIP-85 seed phrase.", "red"))
|
print(colored("Error: Invalid BIP-85 seed phrase.", "red"))
|
||||||
sys.exit(1)
|
raise SeedPassError("Invalid BIP-85 seed phrase.")
|
||||||
|
|
||||||
@requires_unlocked
|
@requires_unlocked
|
||||||
def generate_new_seed(self) -> Optional[str]:
|
def generate_new_seed(self) -> Optional[str]:
|
||||||
@@ -1187,7 +1209,7 @@ class PasswordManager:
|
|||||||
"red",
|
"red",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
raise SeedPassError("Failed to generate seed profile for the new seed.")
|
||||||
|
|
||||||
fingerprint_dir = self.fingerprint_manager.get_fingerprint_directory(
|
fingerprint_dir = self.fingerprint_manager.get_fingerprint_directory(
|
||||||
fingerprint
|
fingerprint
|
||||||
@@ -1196,7 +1218,7 @@ class PasswordManager:
|
|||||||
print(
|
print(
|
||||||
colored("Error: Failed to retrieve seed profile directory.", "red")
|
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
|
# Persist the assigned account index for the new profile
|
||||||
try:
|
try:
|
||||||
@@ -1224,7 +1246,7 @@ class PasswordManager:
|
|||||||
return fingerprint # Return the generated fingerprint
|
return fingerprint # Return the generated fingerprint
|
||||||
else:
|
else:
|
||||||
self.notify("Seed generation cancelled. Exiting.", level="WARNING")
|
self.notify("Seed generation cancelled. Exiting.", level="WARNING")
|
||||||
sys.exit(0)
|
raise SeedPassError("Seed generation cancelled.")
|
||||||
|
|
||||||
def validate_bip85_seed(self, seed: str) -> bool:
|
def validate_bip85_seed(self, seed: str) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -1261,11 +1283,11 @@ class PasswordManager:
|
|||||||
except Bip85Error as e:
|
except Bip85Error as e:
|
||||||
logging.error(f"Failed to generate BIP-85 seed: {e}", exc_info=True)
|
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"))
|
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:
|
except Exception as e:
|
||||||
logging.error(f"Failed to generate BIP-85 seed: {e}", exc_info=True)
|
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"))
|
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
|
@requires_unlocked
|
||||||
def save_and_encrypt_seed(
|
def save_and_encrypt_seed(
|
||||||
@@ -1338,7 +1360,7 @@ class PasswordManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to encrypt and save parent seed: {e}", exc_info=True)
|
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"))
|
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):
|
def initialize_bip85(self):
|
||||||
"""
|
"""
|
||||||
@@ -1367,7 +1389,7 @@ class PasswordManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to initialize BIP-85: {e}", exc_info=True)
|
logging.error(f"Failed to initialize BIP-85: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to initialize BIP-85: {e}", "red"))
|
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:
|
def initialize_managers(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -1407,7 +1429,7 @@ class PasswordManager:
|
|||||||
)
|
)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
print(colored(str(exc), "red"))
|
print(colored(str(exc), "red"))
|
||||||
sys.exit(1)
|
raise SeedPassError(str(exc))
|
||||||
|
|
||||||
self.entry_manager = EntryManager(
|
self.entry_manager = EntryManager(
|
||||||
vault=self.vault,
|
vault=self.vault,
|
||||||
@@ -1509,7 +1531,7 @@ class PasswordManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to initialize managers: {e}", exc_info=True)
|
logger.error(f"Failed to initialize managers: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to initialize managers: {e}", "red"))
|
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:
|
async def sync_index_from_nostr_async(self) -> None:
|
||||||
"""Always fetch the latest vault data from Nostr and update the local index."""
|
"""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")
|
self.notify("Starting with a new, empty vault.", level="INFO")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_add_password(self) -> None:
|
def handle_add_password(self) -> None:
|
||||||
self.entry_service.handle_add_password()
|
self.entry_service.handle_add_password()
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_add_totp(self) -> None:
|
def handle_add_totp(self) -> None:
|
||||||
"""Add a TOTP entry either derived from the seed or imported."""
|
"""Add a TOTP entry either derived from the seed or imported."""
|
||||||
try:
|
try:
|
||||||
@@ -2008,6 +2032,7 @@ class PasswordManager:
|
|||||||
print(colored(f"Error: Failed to add TOTP: {e}", "red"))
|
print(colored(f"Error: Failed to add TOTP: {e}", "red"))
|
||||||
pause()
|
pause()
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_add_ssh_key(self) -> None:
|
def handle_add_ssh_key(self) -> None:
|
||||||
"""Add an SSH key pair entry and display the derived keys."""
|
"""Add an SSH key pair entry and display the derived keys."""
|
||||||
try:
|
try:
|
||||||
@@ -2065,6 +2090,7 @@ class PasswordManager:
|
|||||||
print(colored(f"Error: Failed to add SSH key: {e}", "red"))
|
print(colored(f"Error: Failed to add SSH key: {e}", "red"))
|
||||||
pause()
|
pause()
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_add_seed(self) -> None:
|
def handle_add_seed(self) -> None:
|
||||||
"""Add a derived BIP-39 seed phrase entry."""
|
"""Add a derived BIP-39 seed phrase entry."""
|
||||||
try:
|
try:
|
||||||
@@ -2134,6 +2160,7 @@ class PasswordManager:
|
|||||||
print(colored(f"Error: Failed to add seed phrase: {e}", "red"))
|
print(colored(f"Error: Failed to add seed phrase: {e}", "red"))
|
||||||
pause()
|
pause()
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_add_pgp(self) -> None:
|
def handle_add_pgp(self) -> None:
|
||||||
"""Add a PGP key entry and display the generated key."""
|
"""Add a PGP key entry and display the generated key."""
|
||||||
try:
|
try:
|
||||||
@@ -2201,6 +2228,7 @@ class PasswordManager:
|
|||||||
print(colored(f"Error: Failed to add PGP key: {e}", "red"))
|
print(colored(f"Error: Failed to add PGP key: {e}", "red"))
|
||||||
pause()
|
pause()
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_add_nostr_key(self) -> None:
|
def handle_add_nostr_key(self) -> None:
|
||||||
"""Add a Nostr key entry and display the derived keys."""
|
"""Add a Nostr key entry and display the derived keys."""
|
||||||
try:
|
try:
|
||||||
@@ -2260,6 +2288,7 @@ class PasswordManager:
|
|||||||
print(colored(f"Error: Failed to add Nostr key: {e}", "red"))
|
print(colored(f"Error: Failed to add Nostr key: {e}", "red"))
|
||||||
pause()
|
pause()
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_add_key_value(self) -> None:
|
def handle_add_key_value(self) -> None:
|
||||||
"""Add a generic key/value entry."""
|
"""Add a generic key/value entry."""
|
||||||
try:
|
try:
|
||||||
@@ -2341,6 +2370,7 @@ class PasswordManager:
|
|||||||
print(colored(f"Error: Failed to add key/value entry: {e}", "red"))
|
print(colored(f"Error: Failed to add key/value entry: {e}", "red"))
|
||||||
pause()
|
pause()
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_add_managed_account(self) -> None:
|
def handle_add_managed_account(self) -> None:
|
||||||
"""Add a managed account seed entry."""
|
"""Add a managed account seed entry."""
|
||||||
try:
|
try:
|
||||||
@@ -3130,6 +3160,7 @@ class PasswordManager:
|
|||||||
print(colored("Error: Failed to retrieve the password.", "red"))
|
print(colored("Error: Failed to retrieve the password.", "red"))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_retrieve_entry(self) -> None:
|
def handle_retrieve_entry(self) -> None:
|
||||||
"""Prompt for an index and display the corresponding entry."""
|
"""Prompt for an index and display the corresponding entry."""
|
||||||
try:
|
try:
|
||||||
@@ -3164,6 +3195,7 @@ class PasswordManager:
|
|||||||
print(colored(f"Error: Failed to retrieve password: {e}", "red"))
|
print(colored(f"Error: Failed to retrieve password: {e}", "red"))
|
||||||
pause()
|
pause()
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_modify_entry(self) -> None:
|
def handle_modify_entry(self) -> None:
|
||||||
"""
|
"""
|
||||||
Handles modifying an existing password entry by prompting the user for the index number
|
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)
|
logging.error(f"Error during modifying entry: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to modify entry: {e}", "red"))
|
print(colored(f"Error: Failed to modify entry: {e}", "red"))
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_search_entries(self) -> None:
|
def handle_search_entries(self) -> None:
|
||||||
"""Prompt for a query, list matches and optionally show details."""
|
"""Prompt for a query, list matches and optionally show details."""
|
||||||
try:
|
try:
|
||||||
@@ -3813,6 +3846,7 @@ class PasswordManager:
|
|||||||
)
|
)
|
||||||
print("-" * 40)
|
print("-" * 40)
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_list_entries(self) -> None:
|
def handle_list_entries(self) -> None:
|
||||||
self.menu_handler.handle_list_entries()
|
self.menu_handler.handle_list_entries()
|
||||||
|
|
||||||
@@ -3853,6 +3887,7 @@ class PasswordManager:
|
|||||||
logging.error(f"Error during entry deletion: {e}", exc_info=True)
|
logging.error(f"Error during entry deletion: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to delete entry: {e}", "red"))
|
print(colored(f"Error: Failed to delete entry: {e}", "red"))
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_archive_entry(self) -> None:
|
def handle_archive_entry(self) -> None:
|
||||||
"""Archive an entry without deleting it."""
|
"""Archive an entry without deleting it."""
|
||||||
try:
|
try:
|
||||||
@@ -3871,6 +3906,7 @@ class PasswordManager:
|
|||||||
logging.error(f"Error archiving entry: {e}", exc_info=True)
|
logging.error(f"Error archiving entry: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to archive entry: {e}", "red"))
|
print(colored(f"Error: Failed to archive entry: {e}", "red"))
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_view_archived_entries(self) -> None:
|
def handle_view_archived_entries(self) -> None:
|
||||||
"""Display archived entries and optionally view or restore them."""
|
"""Display archived entries and optionally view or restore them."""
|
||||||
try:
|
try:
|
||||||
@@ -3938,9 +3974,11 @@ class PasswordManager:
|
|||||||
logging.error(f"Error viewing archived entries: {e}", exc_info=True)
|
logging.error(f"Error viewing archived entries: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to view archived entries: {e}", "red"))
|
print(colored(f"Error: Failed to view archived entries: {e}", "red"))
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_display_totp_codes(self) -> None:
|
def handle_display_totp_codes(self) -> None:
|
||||||
self.menu_handler.handle_display_totp_codes()
|
self.menu_handler.handle_display_totp_codes()
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_verify_checksum(self) -> None:
|
def handle_verify_checksum(self) -> None:
|
||||||
"""
|
"""
|
||||||
Handles verifying the script's checksum against the stored checksum to ensure integrity.
|
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)
|
logging.error(f"Error during checksum verification: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to verify checksum: {e}", "red"))
|
print(colored(f"Error: Failed to verify checksum: {e}", "red"))
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_update_script_checksum(self) -> None:
|
def handle_update_script_checksum(self) -> None:
|
||||||
"""Generate a new checksum for the manager script."""
|
"""Generate a new checksum for the manager script."""
|
||||||
if not confirm_action("Generate new script checksum? (Y/N): "):
|
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)
|
logging.error(f"Failed to restore backup: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to restore backup: {e}", "red"))
|
print(colored(f"Error: Failed to restore backup: {e}", "red"))
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_export_database(
|
def handle_export_database(
|
||||||
self,
|
self,
|
||||||
dest: Path | None = None,
|
dest: Path | None = None,
|
||||||
@@ -4167,6 +4207,7 @@ class PasswordManager:
|
|||||||
print(colored(f"Error: Failed to export database: {e}", "red"))
|
print(colored(f"Error: Failed to export database: {e}", "red"))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_import_database(self, src: Path) -> None:
|
def handle_import_database(self, src: Path) -> None:
|
||||||
"""Import a portable database file, replacing the current index."""
|
"""Import a portable database file, replacing the current index."""
|
||||||
|
|
||||||
@@ -4206,7 +4247,7 @@ class PasswordManager:
|
|||||||
src,
|
src,
|
||||||
parent_seed=self.parent_seed,
|
parent_seed=self.parent_seed,
|
||||||
)
|
)
|
||||||
except InvalidToken:
|
except DecryptionError:
|
||||||
logging.error("Invalid backup token during import", exc_info=True)
|
logging.error("Invalid backup token during import", exc_info=True)
|
||||||
print(
|
print(
|
||||||
colored(
|
colored(
|
||||||
@@ -4249,6 +4290,7 @@ class PasswordManager:
|
|||||||
print(colored("Database imported successfully.", "green"))
|
print(colored("Database imported successfully.", "green"))
|
||||||
self.sync_vault()
|
self.sync_vault()
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_export_totp_codes(self) -> Path | None:
|
def handle_export_totp_codes(self) -> Path | None:
|
||||||
"""Export all 2FA codes to a JSON file for other authenticator apps."""
|
"""Export all 2FA codes to a JSON file for other authenticator apps."""
|
||||||
try:
|
try:
|
||||||
@@ -4319,6 +4361,7 @@ class PasswordManager:
|
|||||||
print(colored(f"Error: Failed to export 2FA codes: {e}", "red"))
|
print(colored(f"Error: Failed to export 2FA codes: {e}", "red"))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_backup_reveal_parent_seed(
|
def handle_backup_reveal_parent_seed(
|
||||||
self, file: Path | None = None, *, password: Optional[str] = None
|
self, file: Path | None = None, *, password: Optional[str] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -4446,7 +4489,7 @@ class PasswordManager:
|
|||||||
else:
|
else:
|
||||||
logging.warning("Password verification failed.")
|
logging.warning("Password verification failed.")
|
||||||
return is_correct
|
return is_correct
|
||||||
except InvalidToken as e:
|
except DecryptionError as e:
|
||||||
logging.error(f"Failed to decrypt config: {e}")
|
logging.error(f"Failed to decrypt config: {e}")
|
||||||
print(
|
print(
|
||||||
colored(
|
colored(
|
||||||
|
@@ -10,6 +10,7 @@ from .entry_types import EntryType, ALL_ENTRY_TYPES
|
|||||||
import seedpass.core.manager as manager_module
|
import seedpass.core.manager as manager_module
|
||||||
from utils.color_scheme import color_text
|
from utils.color_scheme import color_text
|
||||||
from utils.terminal_utils import clear_header_with_notification
|
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
|
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||||
from .manager import PasswordManager
|
from .manager import PasswordManager
|
||||||
@@ -21,6 +22,7 @@ class MenuHandler:
|
|||||||
def __init__(self, manager: PasswordManager) -> None:
|
def __init__(self, manager: PasswordManager) -> None:
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_list_entries(self) -> None:
|
def handle_list_entries(self) -> None:
|
||||||
"""List entries and optionally show details."""
|
"""List entries and optionally show details."""
|
||||||
pm = self.manager
|
pm = self.manager
|
||||||
@@ -86,6 +88,7 @@ class MenuHandler:
|
|||||||
logging.error(f"Failed to list entries: {e}", exc_info=True)
|
logging.error(f"Failed to list entries: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to list entries: {e}", "red"))
|
print(colored(f"Error: Failed to list entries: {e}", "red"))
|
||||||
|
|
||||||
|
@pause_logging_for_ui
|
||||||
def handle_display_totp_codes(self) -> None:
|
def handle_display_totp_codes(self) -> None:
|
||||||
"""Display all stored TOTP codes with a countdown progress bar."""
|
"""Display all stored TOTP codes with a countdown progress bar."""
|
||||||
pm = self.manager
|
pm = self.manager
|
||||||
|
@@ -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."""
|
"""Raised when an operation requires an unlocked vault."""
|
||||||
|
|
||||||
pass
|
def __init__(self, message: str = "Vault is locked") -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["VaultLockedError", "SeedPassError"]
|
||||||
|
@@ -77,7 +77,7 @@ class DummyPM:
|
|||||||
set_offline_mode=lambda v: None,
|
set_offline_mode=lambda v: None,
|
||||||
get_secret_mode_enabled=lambda: True,
|
get_secret_mode_enabled=lambda: True,
|
||||||
get_clipboard_clear_delay=lambda: 30,
|
get_clipboard_clear_delay=lambda: 30,
|
||||||
get_offline_mode=lambda: False,
|
get_offline_mode=lambda: True,
|
||||||
)
|
)
|
||||||
self.secret_mode_enabled = True
|
self.secret_mode_enabled = True
|
||||||
self.clipboard_clear_delay = 30
|
self.clipboard_clear_delay = 30
|
||||||
|
@@ -7,7 +7,7 @@ from seedpass.cli import common as cli_common
|
|||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
def _make_pm(called, enabled=False):
|
def _make_pm(called, enabled=True):
|
||||||
cfg = SimpleNamespace(
|
cfg = SimpleNamespace(
|
||||||
get_offline_mode=lambda: enabled,
|
get_offline_mode=lambda: enabled,
|
||||||
set_offline_mode=lambda v: called.setdefault("enabled", v),
|
set_offline_mode=lambda v: called.setdefault("enabled", v),
|
||||||
@@ -24,10 +24,10 @@ def test_toggle_offline_updates(monkeypatch):
|
|||||||
called = {}
|
called = {}
|
||||||
pm = _make_pm(called)
|
pm = _make_pm(called)
|
||||||
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
|
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 result.exit_code == 0
|
||||||
assert called == {"enabled": True}
|
assert called == {"enabled": False}
|
||||||
assert "Offline mode enabled." in result.stdout
|
assert "Offline mode disabled." in result.stdout
|
||||||
|
|
||||||
|
|
||||||
def test_toggle_offline_keep(monkeypatch):
|
def test_toggle_offline_keep(monkeypatch):
|
||||||
|
23
src/tests/test_invalid_password_message.py
Normal file
23
src/tests/test_invalid_password_message.py
Normal 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
|
@@ -14,6 +14,7 @@ import gzip
|
|||||||
|
|
||||||
from seedpass.core.manager import PasswordManager, EncryptionMode
|
from seedpass.core.manager import PasswordManager, EncryptionMode
|
||||||
from seedpass.core.vault import Vault
|
from seedpass.core.vault import Vault
|
||||||
|
from seedpass.core.errors import SeedPassError
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_index_migrates(monkeypatch, tmp_path: Path):
|
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)
|
monkeypatch.setattr("seedpass.core.manager.confirm_action", fake_confirm)
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SeedPassError):
|
||||||
pm.initialize_managers()
|
pm.initialize_managers()
|
||||||
|
|
||||||
assert calls["confirm"] == 0
|
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)
|
monkeypatch.setattr("seedpass.core.manager.confirm_action", fake_confirm)
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SeedPassError):
|
||||||
pm.initialize_managers()
|
pm.initialize_managers()
|
||||||
|
|
||||||
assert calls["confirm"] == 0
|
assert calls["confirm"] == 0
|
||||||
|
@@ -74,6 +74,61 @@ def test_handle_new_seed_setup_restore_from_nostr(monkeypatch, tmp_path, capsys)
|
|||||||
assert labels == ["site1"]
|
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():
|
async def _no_snapshot():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import types
|
import types
|
||||||
|
import pytest
|
||||||
from utils import seed_prompt
|
from utils import seed_prompt
|
||||||
|
|
||||||
|
|
||||||
@@ -46,6 +47,37 @@ def test_masked_input_windows_space(monkeypatch, capsys):
|
|||||||
assert out.count("*") == 4
|
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):
|
def test_prompt_seed_words_valid(monkeypatch):
|
||||||
from mnemonic import Mnemonic
|
from mnemonic import Mnemonic
|
||||||
|
|
||||||
|
@@ -4,6 +4,7 @@ from types import SimpleNamespace
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import seedpass.core.manager as manager_module
|
import seedpass.core.manager as manager_module
|
||||||
|
from seedpass.core.errors import SeedPassError
|
||||||
from helpers import TEST_SEED
|
from helpers import TEST_SEED
|
||||||
from utils import seed_prompt
|
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(seed_prompt, "clear_screen", lambda *_a, **_k: None)
|
||||||
monkeypatch.setattr(builtins, "input", lambda *_: next(inputs))
|
monkeypatch.setattr(builtins, "input", lambda *_: next(inputs))
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SeedPassError):
|
||||||
pm.add_new_fingerprint()
|
pm.add_new_fingerprint()
|
||||||
|
|
||||||
assert pm.fingerprint_manager.current_fingerprint is None
|
assert pm.fingerprint_manager.current_fingerprint is None
|
||||||
|
62
src/utils/logging_utils.py
Normal file
62
src/utils/logging_utils.py
Normal 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
|
@@ -15,6 +15,7 @@ except ImportError: # pragma: no cover - POSIX only
|
|||||||
tty = None # type: ignore
|
tty = None # type: ignore
|
||||||
|
|
||||||
from utils.terminal_utils import clear_screen
|
from utils.terminal_utils import clear_screen
|
||||||
|
from utils.logging_utils import pause_console_logging, resume_console_logging
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_MAX_ATTEMPTS = 5
|
DEFAULT_MAX_ATTEMPTS = 5
|
||||||
@@ -58,6 +59,8 @@ def _masked_input_windows(prompt: str) -> str:
|
|||||||
buffer: list[str] = []
|
buffer: list[str] = []
|
||||||
while True:
|
while True:
|
||||||
ch = msvcrt.getwch()
|
ch = msvcrt.getwch()
|
||||||
|
if ch == "\x03":
|
||||||
|
raise KeyboardInterrupt
|
||||||
if ch in ("\r", "\n"):
|
if ch in ("\r", "\n"):
|
||||||
sys.stdout.write("\n")
|
sys.stdout.write("\n")
|
||||||
return "".join(buffer)
|
return "".join(buffer)
|
||||||
@@ -85,6 +88,8 @@ def _masked_input_posix(prompt: str) -> str:
|
|||||||
tty.setraw(fd)
|
tty.setraw(fd)
|
||||||
while True:
|
while True:
|
||||||
ch = sys.stdin.read(1)
|
ch = sys.stdin.read(1)
|
||||||
|
if ch == "\x03":
|
||||||
|
raise KeyboardInterrupt
|
||||||
if ch in ("\r", "\n"):
|
if ch in ("\r", "\n"):
|
||||||
sys.stdout.write("\n")
|
sys.stdout.write("\n")
|
||||||
return "".join(buffer)
|
return "".join(buffer)
|
||||||
@@ -103,10 +108,15 @@ def _masked_input_posix(prompt: str) -> str:
|
|||||||
def masked_input(prompt: str) -> str:
|
def masked_input(prompt: str) -> str:
|
||||||
"""Return input from the user while masking typed characters."""
|
"""Return input from the user while masking typed characters."""
|
||||||
func = _masked_input_windows if sys.platform == "win32" else _masked_input_posix
|
func = _masked_input_windows if sys.platform == "win32" else _masked_input_posix
|
||||||
|
pause_console_logging()
|
||||||
try:
|
try:
|
||||||
return func(prompt)
|
return func(prompt)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
raise
|
||||||
except Exception: # pragma: no cover - fallback when TTY operations fail
|
except Exception: # pragma: no cover - fallback when TTY operations fail
|
||||||
return input(prompt)
|
return input(prompt)
|
||||||
|
finally:
|
||||||
|
resume_console_logging()
|
||||||
|
|
||||||
|
|
||||||
def prompt_seed_words(count: int = 12, *, max_attempts: int | None = None) -> str:
|
def prompt_seed_words(count: int = 12, *, max_attempts: int | None = None) -> str:
|
||||||
|
Reference in New Issue
Block a user