604 Commits

Author SHA1 Message Date
thePR0M3TH3AN
fde09bd1a0 Merge pull request #850 from PR0M3TH3AN/codex/redirect-log-output-for-background-tasks
Refactor logging output handling
2025-08-22 21:56:33 -04:00
thePR0M3TH3AN
b307728c05 feat: support pausing console logs 2025-08-22 21:50:04 -04:00
thePR0M3TH3AN
8ade9e3028 Merge pull request #849 from PR0M3TH3AN/codex/handle-ctrl-c-in-seed_prompt.py
Handle Ctrl-C in masked input prompts
2025-08-22 21:40:47 -04:00
thePR0M3TH3AN
c0a6187478 Handle Ctrl-C in masked input 2025-08-22 21:32:18 -04:00
thePR0M3TH3AN
d9f76ee668 Merge pull request #848 from PR0M3TH3AN/codex/update-readme-installation-instructions
docs: document headless TUI installer
2025-08-22 10:30:18 -04:00
thePR0M3TH3AN
40a75adcb7 docs: document headless TUI installer 2025-08-22 10:22:29 -04:00
thePR0M3TH3AN
bd1588fba1 Merge pull request #847 from PR0M3TH3AN/codex/introduce-seedpasserror-and-replace-sys.exit-calls
Use SeedPassError instead of sys.exit
2025-08-22 10:08:38 -04:00
thePR0M3TH3AN
d5e0d61db4 Use custom SeedPassError instead of sys.exit 2025-08-22 10:01:14 -04:00
thePR0M3TH3AN
d795ac9006 Merge pull request #846 from PR0M3TH3AN/codex/add-restore-from-local-backup-option
feat: add local backup restore during seed setup
2025-08-22 09:46:51 -04:00
thePR0M3TH3AN
ee3d9d8e9d feat: add local backup restore during seed setup 2025-08-22 09:39:38 -04:00
thePR0M3TH3AN
2b68df9428 Merge pull request #845 from PR0M3TH3AN/codex/update-offline-mode-to-true
Default offline mode and docs clarify online sync opt-in
2025-08-22 09:22:12 -04:00
thePR0M3TH3AN
a2a663eed1 Default offline mode 2025-08-22 09:10:06 -04:00
thePR0M3TH3AN
ae59ede374 Merge pull request #844 from PR0M3TH3AN/codex/modify-optional-dependencies-in-pyproject.toml
Add platform-specific GUI extras
2025-08-22 09:09:37 -04:00
thePR0M3TH3AN
61b1aa6773 chore: update poetry.lock 2025-08-21 16:38:15 -04:00
thePR0M3TH3AN
428efd02b4 Add GUI extras for platform backends 2025-08-21 16:18:32 -04:00
thePR0M3TH3AN
cfb861b60a Merge pull request #843 from PR0M3TH3AN/codex/update-install-script-with-mode-flag
feat: add installer mode flag
2025-08-21 15:52:36 -04:00
thePR0M3TH3AN
ca533a3518 feat: add installer mode flag 2025-08-21 15:45:58 -04:00
thePR0M3TH3AN
a7da9b8971 Merge pull request #842 from PR0M3TH3AN/codex/update-agents.md-for-installation-guidance
docs: add installation quickstart for agents
2025-08-21 15:27:32 -04:00
thePR0M3TH3AN
a0ae414765 docs: add installation tips to AGENTS 2025-08-21 15:27:10 -04:00
thePR0M3TH3AN
45c112b26b Merge pull request #841 from PR0M3TH3AN/codex/format-entry-options-as-a-list
Improve readability of seed setup prompts
2025-08-20 22:40:10 -04:00
thePR0M3TH3AN
4df6ff639e List seed setup choices for readability 2025-08-20 22:33:23 -04:00
thePR0M3TH3AN
108fcfcb04 Merge pull request #840 from PR0M3TH3AN/codex/increment-nostr-account-derivation
feat: track nostr account index per seed
2025-08-20 22:22:19 -04:00
thePR0M3TH3AN
505cf1a950 feat: track nostr account index per seed 2025-08-20 22:15:35 -04:00
thePR0M3TH3AN
e701a1c1cb Merge pull request #839 from PR0M3TH3AN/codex/fix-graceful-failure-on-decryption-error
Handle configuration decryption errors gracefully
2025-08-20 21:53:24 -04:00
thePR0M3TH3AN
cb9a068e40 Handle config decryption errors gracefully 2025-08-20 21:46:09 -04:00
thePR0M3TH3AN
c13742f3f3 Merge pull request #838 from PR0M3TH3AN/codex/add-manual-export-without-encryption
Add option to export and import database without encryption
2025-08-20 21:08:49 -04:00
thePR0M3TH3AN
6c8b1928b8 Add option to export and import database without encryption 2025-08-20 21:00:12 -04:00
thePR0M3TH3AN
b1b31eeb8a Merge pull request #837 from PR0M3TH3AN/codex/add-first-run-warning-to-readme.md
Document offline default and add KDF strength slider
2025-08-20 20:59:57 -04:00
thePR0M3TH3AN
492bfba3fb tests: add offline default and kdf slider 2025-08-20 20:51:36 -04:00
thePR0M3TH3AN
b33565e7f3 Merge pull request #836 from PR0M3TH3AN/codex/update-class-docstring-and-documentation
Clarify best-effort memory zeroization
2025-08-20 20:37:20 -04:00
thePR0M3TH3AN
857b1ef0f9 Document memory zeroization caveat 2025-08-20 20:29:58 -04:00
thePR0M3TH3AN
7a039171a0 Merge pull request #835 from PR0M3TH3AN/codex/update-encryptionmanager-for-nonce-handling
Add nonce tracking and V3 encryption format
2025-08-20 20:02:07 -04:00
thePR0M3TH3AN
dd513cf964 Add nonce tracking and V3 encryption format 2025-08-20 19:43:45 -04:00
thePR0M3TH3AN
16de0a82c7 Merge pull request #834 from PR0M3TH3AN/codex/implement-token-generation-and-validation
Use bcrypt-hashed API tokens
2025-08-20 19:35:40 -04:00
thePR0M3TH3AN
d99af30d9f Switch API token to bcrypt 2025-08-20 19:29:08 -04:00
thePR0M3TH3AN
da37ec2e61 Merge pull request #833 from PR0M3TH3AN/codex/implement-new_manifest_id-in-snapshot.py
Use HMAC-based manifest IDs without fingerprint leakage
2025-08-20 19:18:14 -04:00
thePR0M3TH3AN
0315562d80 test: ensure manifest IDs omit fingerprint 2025-08-20 19:11:24 -04:00
thePR0M3TH3AN
e75e197270 Merge pull request #832 from PR0M3TH3AN/codex/implement-totp-secret-generation-feature
feat: support random and deterministic TOTP secrets
2025-08-20 18:57:00 -04:00
thePR0M3TH3AN
619226d336 feat: support random and deterministic TOTP secrets 2025-08-20 18:36:19 -04:00
thePR0M3TH3AN
15df3f10a6 Merge pull request #831 from PR0M3TH3AN/codex/update-agents.md-to-specify-deterministic-artifacts
docs: add deterministic artifact guidelines
2025-08-20 18:19:36 -04:00
thePR0M3TH3AN
b451097c65 docs: add deterministic artifact guidelines 2025-08-20 18:19:20 -04:00
thePR0M3TH3AN
9cacd1b13d Merge pull request #830 from PR0M3TH3AN/codex/implement-hkdf-helper-and-update-sub-key-usage
Refactor key derivation with hierarchical HKDF
2025-08-20 18:19:00 -04:00
thePR0M3TH3AN
b97d60778b Restore compatibility for key hierarchy 2025-08-20 18:12:02 -04:00
thePR0M3TH3AN
bbb26ca55a test: add key hierarchy tests 2025-08-20 17:57:50 -04:00
thePR0M3TH3AN
d6e03d5e7a Merge pull request #829 from PR0M3TH3AN/codex/implement-kdfconfig-dataclass-and-update-key-derivation
Introduce KDF config metadata and migration tests
2025-08-20 17:44:50 -04:00
thePR0M3TH3AN
26632c0e70 Fix KDF metadata handling and headless password prompts 2025-08-20 17:23:10 -04:00
thePR0M3TH3AN
06ca51993a Add KDF config with per-file metadata 2025-08-19 09:53:46 -04:00
thePR0M3TH3AN
1b6b0ab5c5 Merge pull request #827 from PR0M3TH3AN/codex/add-backup-restoration-feature
feat: add startup backup restore option
2025-08-19 09:04:29 -04:00
thePR0M3TH3AN
87999b1888 test: cover backup restore startup 2025-08-19 08:53:26 -04:00
thePR0M3TH3AN
6928b4ddbf Merge pull request #826 from PR0M3TH3AN/codex/add-tests-for-seed-word-flow
test: add word-by-word seed flow tests
2025-08-18 22:03:41 -04:00
thePR0M3TH3AN
73183d53a5 test: cover invalid word and fingerprint flows 2025-08-18 21:55:39 -04:00
thePR0M3TH3AN
c9ad16f150 Merge pull request #825 from PR0M3TH3AN/codex/extend-test-coverage-for-key/value-and-managed-accounts
test: add key-value and managed account entry tests
2025-08-18 21:18:20 -04:00
thePR0M3TH3AN
bd86bdbb3a test: add key-value and managed account entry tests 2025-08-18 21:12:32 -04:00
thePR0M3TH3AN
8d5374ef5b Merge pull request #824 from PR0M3TH3AN/codex/add-all_entry_types-constant-and-update-filters
Support listing all entry types
2025-08-18 19:22:05 -04:00
thePR0M3TH3AN
468608a369 Support listing all entry types 2025-08-18 19:12:55 -04:00
thePR0M3TH3AN
56e652089a Merge pull request #823 from PR0M3TH3AN/codex/update-documentation-for-installation-requirements
docs: note installer dependency checks
2025-08-18 18:19:33 -04:00
thePR0M3TH3AN
c353c04472 docs: note installer dependency checks 2025-08-18 18:18:07 -04:00
thePR0M3TH3AN
2559920a14 Merge pull request #822 from PR0M3TH3AN/codex/update-readme-and-examples-for-seedpass-commands
docs: update vault import/export commands
2025-08-18 18:11:18 -04:00
thePR0M3TH3AN
57935bdfc1 docs: update vault import/export commands 2025-08-18 18:09:43 -04:00
thePR0M3TH3AN
55fdee522c Merge pull request #821 from PR0M3TH3AN/codex/update-install.sh-argument-parsing
Default GUI install with opt-out flag
2025-08-18 18:00:14 -04:00
thePR0M3TH3AN
af4eb72385 Default to GUI install with opt-out flag 2025-08-18 17:50:18 -04:00
thePR0M3TH3AN
90c304ff6e Merge pull request #820 from PR0M3TH3AN/codex/add-post-install-python-check
Run CLI import check after pip install
2025-08-18 17:32:25 -04:00
thePR0M3TH3AN
7b1ef2abe2 Relax BIP85 cache benchmark 2025-08-18 17:22:34 -04:00
thePR0M3TH3AN
5194adf145 Check CLI import after installation 2025-08-18 17:02:23 -04:00
thePR0M3TH3AN
8f74ac27f4 Merge pull request #819 from PR0M3TH3AN/codex/add-dependency-installation-for-multiple-os
feat: expand install dependencies
2025-08-18 16:02:53 -04:00
thePR0M3TH3AN
1232630dba feat: expand install dependencies 2025-08-18 15:56:29 -04:00
thePR0M3TH3AN
62983df69c Merge pull request #817 from PR0M3TH3AN/codex/update-install.sh-for-stricter-error-handling
chore: harden installer script
2025-08-16 11:44:48 -04:00
thePR0M3TH3AN
b4238791aa chore: harden installer script 2025-08-16 11:30:48 -04:00
thePR0M3TH3AN
d1fccbc4f2 Merge pull request #816 from PR0M3TH3AN/codex/rename-seed_bytes-to-seed_or_xprv
Refine BIP85 initialization handling
2025-08-12 11:13:10 -04:00
thePR0M3TH3AN
50532597b8 Test BIP85 init with seed bytes and xprv 2025-08-12 11:01:17 -04:00
thePR0M3TH3AN
bb733bb194 Merge pull request #815 from PR0M3TH3AN/codex/remove-src/nostr/utils.py-and-its-imports
chore: remove unused nostr utils module
2025-08-12 10:34:30 -04:00
thePR0M3TH3AN
785acf938c chore: remove unused nostr utils 2025-08-12 10:26:51 -04:00
thePR0M3TH3AN
4973095a5c Merge pull request #814 from PR0M3TH3AN/codex/remove-logging_config.py-and-update-references
Remove obsolete Nostr logging configuration module
2025-08-12 10:21:18 -04:00
thePR0M3TH3AN
69f1619816 Remove obsolete logging configuration module 2025-08-12 10:03:54 -04:00
thePR0M3TH3AN
e1b821bc55 Merge pull request #813 from PR0M3TH3AN/codex/rename-parameters-and-update-documentation
refactor: rename entropy length parameter
2025-08-12 09:57:11 -04:00
thePR0M3TH3AN
a21efa91db refactor: rename entropy length parameter 2025-08-12 09:41:37 -04:00
thePR0M3TH3AN
5109f96ce7 Merge pull request #812 from PR0M3TH3AN/codex/implement-vault-locking-mechanism
Track vault lock state with explicit lock flag
2025-08-12 09:22:22 -04:00
thePR0M3TH3AN
19577163cf Add vault locked flag and enforce access checks 2025-08-12 08:48:19 -04:00
thePR0M3TH3AN
b0e4ab9bc6 Merge pull request #811 from PR0M3TH3AN/codex/refactor-passwordmanager-into-services
Refactor PasswordManager into composable services
2025-08-11 21:20:55 -04:00
thePR0M3TH3AN
3ff3e4e1d6 Use manager timed_input in MenuHandler 2025-08-11 21:13:44 -04:00
thePR0M3TH3AN
08c4453326 Add service classes and tests 2025-08-11 20:36:53 -04:00
thePR0M3TH3AN
fddc169433 Merge pull request #810 from PR0M3TH3AN/codex/remove-input-calls-from-decrypt_data
feat: raise legacy migration error
2025-08-11 20:11:33 -04:00
thePR0M3TH3AN
28f552313f test: align legacy migration handling 2025-08-11 19:35:03 -04:00
thePR0M3TH3AN
294eef9725 test: cover legacy migration error handling 2025-08-11 16:05:14 -04:00
thePR0M3TH3AN
5d9281156b Merge pull request #809 from PR0M3TH3AN/codex/refactor-eventhandler-to-use-nostr_sdk
Use nostr_sdk Event and update handler tests
2025-08-11 15:47:11 -04:00
thePR0M3TH3AN
c4297731b9 Use nostr_sdk Event and update handler tests 2025-08-11 15:40:43 -04:00
thePR0M3TH3AN
f4df398738 Merge pull request #808 from PR0M3TH3AN/codex/convert-functions-to-async-and-update-calls
Convert Nostr client operations to async
2025-08-10 21:06:21 -04:00
thePR0M3TH3AN
b0a2f17cc8 Make Nostr client async 2025-08-10 20:59:04 -04:00
thePR0M3TH3AN
b9525db9ae Merge pull request #807 from PR0M3TH3AN/codex/add-docstring-to-utils.py
Clarify placeholder utilities and clean imports
2025-08-08 15:56:51 -04:00
thePR0M3TH3AN
199d02ab72 Clarify placeholder utils and clean imports 2025-08-08 15:47:52 -04:00
thePR0M3TH3AN
c1a018e484 Merge pull request #806 from PR0M3TH3AN/codex/update-main-entrypoint-import
refactor: import GUI entrypoint from package
2025-08-08 15:35:45 -04:00
thePR0M3TH3AN
4a537b7063 refactor: import main from package 2025-08-08 15:27:43 -04:00
thePR0M3TH3AN
d7ff7b2354 Merge pull request #805 from PR0M3TH3AN/codex/remove-logger-level-check-in-init.py
Clean up logging in local BIP85 init
2025-08-08 14:50:37 -04:00
thePR0M3TH3AN
f57bfcd7fa Refine logging in local_bip85 init 2025-08-08 13:09:58 -04:00
thePR0M3TH3AN
d390bf8620 Merge pull request #804 from PR0M3TH3AN/codex/modify-hex_to_bech32-for-padding
Enable padding for hex_to_bech32 conversion
2025-08-08 13:00:20 -04:00
thePR0M3TH3AN
fdd2530635 Enable padding in hex_to_bech32 2025-08-08 12:53:24 -04:00
thePR0M3TH3AN
fd9d3fa51b Merge pull request #803 from PR0M3TH3AN/codex/update-selected_directories-in-saved_config.yaml
fix: remove stale password_manager directory
2025-08-08 12:18:56 -04:00
thePR0M3TH3AN
a5f719363e fix: remove stale password_manager directory 2025-08-08 12:18:33 -04:00
thePR0M3TH3AN
4c4394b026 Merge pull request #802 from PR0M3TH3AN/codex/update-requirements-files-with-version-ranges
feat: specify dependency version ranges
2025-08-08 11:25:23 -04:00
thePR0M3TH3AN
75df28dd60 feat: specify dependency version ranges 2025-08-08 11:14:26 -04:00
thePR0M3TH3AN
ccdc442bb8 Merge pull request #801 from PR0M3TH3AN/codex/refactor-error-handling-in-init.py
Gracefully handle BIP85 import errors
2025-08-08 10:44:10 -04:00
thePR0M3TH3AN
d492f76116 Handle optional BIP85 import 2025-08-08 10:36:52 -04:00
thePR0M3TH3AN
064292df01 Merge pull request #800 from PR0M3TH3AN/codex/remove-github-url-and-add-docstring
Add module docstring to package init
2025-08-08 10:28:54 -04:00
thePR0M3TH3AN
1d91044e59 chore: document package init 2025-08-08 10:22:26 -04:00
thePR0M3TH3AN
c95a69e562 Merge pull request #799 from PR0M3TH3AN/codex/create-platform-native-packages-with-briefcase
Add Briefcase packaging scripts and docs
2025-08-08 10:12:17 -04:00
thePR0M3TH3AN
593b173e95 Add Briefcase packaging docs and scripts 2025-08-08 09:24:25 -04:00
thePR0M3TH3AN
3ef0446e26 Merge pull request #798 from PR0M3TH3AN/codex/update-readme-for-optional-gui-installation
docs: document optional GUI install
2025-08-07 22:13:14 -04:00
thePR0M3TH3AN
b33dc5148d docs: note GUI development status 2025-08-07 22:12:49 -04:00
thePR0M3TH3AN
344c2c82e7 docs: document optional GUI install 2025-08-07 14:02:20 -04:00
thePR0M3TH3AN
d98a158c83 Merge pull request #797 from PR0M3TH3AN/codex/add-includegui-parameter-for-installation
feat: make GUI install optional on Windows
2025-08-07 13:20:07 -04:00
thePR0M3TH3AN
8737905e93 feat: make GUI install optional on Windows 2025-08-07 13:10:11 -04:00
thePR0M3TH3AN
d2c00eb0d6 Merge pull request #796 from PR0M3TH3AN/codex/add-gui-installation-flag-to-install.sh
feat: optional GUI installation flag
2025-08-07 13:01:18 -04:00
thePR0M3TH3AN
171f92167e feat: add optional GUI install flag 2025-08-07 12:05:12 -04:00
thePR0M3TH3AN
bbfe0c50a9 Merge pull request #795 from PR0M3TH3AN/codex/modify-pyproject.toml-for-extras-section
feat: make GUI dependencies optional
2025-08-07 11:22:03 -04:00
thePR0M3TH3AN
61e3c2accc fix: align requirements lock Python version 2025-08-07 11:04:20 -04:00
thePR0M3TH3AN
00edb44442 feat: make GUI dependencies optional 2025-08-07 10:51:13 -04:00
thePR0M3TH3AN
ba3c57ceb8 Merge pull request #794 from PR0M3TH3AN/codex/fix-seed-profile-creation-error
Fix duplicate fingerprint check when creating new seed profile
2025-08-07 08:54:00 -04:00
thePR0M3TH3AN
28382cc649 test seed profile creation 2025-08-06 23:14:53 -04:00
thePR0M3TH3AN
38a392a7c9 Merge pull request #793 from PR0M3TH3AN/codex/trace-and-test-add_new_fingerprint-functionality
Ensure add_new_fingerprint exits after seed setup
2025-08-06 22:54:41 -04:00
thePR0M3TH3AN
8c9e325c76 Add regression test for word-by-word seed setup 2025-08-06 22:34:11 -04:00
thePR0M3TH3AN
d77fb142a0 Merge pull request #792 from PR0M3TH3AN/codex/modify-fingerprint-handling-and-add-tests
Prevent duplicate seed profiles
2025-08-06 22:20:34 -04:00
thePR0M3TH3AN
bfc181c32b Handle missing fingerprint manager during seed save 2025-08-06 22:13:47 -04:00
thePR0M3TH3AN
26a4a74131 Test duplicate seed profile creation 2025-08-06 22:01:28 -04:00
thePR0M3TH3AN
5fe1b651a8 Merge pull request #791 from PR0M3TH3AN/codex/compare-fingerprints-before-selecting
Avoid redundant fingerprint decryption
2025-08-06 21:51:45 -04:00
thePR0M3TH3AN
051454ff2e Test avoiding decryption when selecting active profile 2025-08-06 21:45:21 -04:00
thePR0M3TH3AN
87f1e35487 Merge pull request #790 from PR0M3TH3AN/codex/improve-nostr-client-reliability-and-error-handling
Guard missing Nostr client in background sync
2025-08-06 21:40:19 -04:00
thePR0M3TH3AN
45304b41c2 Guard missing Nostr client in background sync 2025-08-06 21:33:16 -04:00
thePR0M3TH3AN
b15c0c17b7 Merge pull request #789 from PR0M3TH3AN/codex/add-lazy-initialization-guard-to-handle_toggle_quick_unlock
Initialize quick unlock config on demand
2025-08-06 20:23:09 -04:00
thePR0M3TH3AN
173a697b88 Mock fingerprint dir in CLI lock tests 2025-08-06 20:17:27 -04:00
thePR0M3TH3AN
5cdb4ecac5 Test quick unlock toggle after profile creation 2025-08-06 20:04:32 -04:00
thePR0M3TH3AN
d2b8b6cb65 Merge pull request #788 from PR0M3TH3AN/codex/reuse-initialization-logic-in-handle_toggle_offline_mode
Initialize config manager lazily for offline mode
2025-08-06 19:56:35 -04:00
thePR0M3TH3AN
7521014a61 Initialize config manager lazily for offline mode 2025-08-06 19:49:46 -04:00
thePR0M3TH3AN
0e0ea183c8 Merge pull request #787 from PR0M3TH3AN/codex/ensure-config-manager-initialization-after-profile-creation
Ensure config manager initialized for secret mode toggling
2025-08-06 19:41:49 -04:00
thePR0M3TH3AN
48e0632771 Avoid AttributeError when config manager missing 2025-08-06 19:34:41 -04:00
thePR0M3TH3AN
d92385eff9 Add tests for secret mode initialization 2025-08-06 19:23:56 -04:00
thePR0M3TH3AN
92142a3e1b Merge pull request #786 from PR0M3TH3AN/codex/update-password-change-logic-and-tests
Enhance password change flow with prompts and tests
2025-08-06 19:14:10 -04:00
thePR0M3TH3AN
ed7763195e Improve password change flow 2025-08-06 19:07:40 -04:00
thePR0M3TH3AN
8079cd05b9 Merge pull request #785 from PR0M3TH3AN/codex/update-ci-workflow-for-poetry
chore: migrate to Poetry and add test workflow
2025-08-06 17:43:08 -04:00
thePR0M3TH3AN
979ba6f678 fix: use bash for pip-audit step 2025-08-06 17:36:27 -04:00
thePR0M3TH3AN
af53e7f12c fix: add missing test dependencies 2025-08-06 17:23:10 -04:00
thePR0M3TH3AN
54314cc5b3 ci: treat pip-audit findings as warnings 2025-08-06 17:14:19 -04:00
thePR0M3TH3AN
fd419fb943 chore: add poetry and CI tests 2025-08-06 17:06:28 -04:00
thePR0M3TH3AN
f571ded60c Merge pull request #783 from PR0M3TH3AN/codex/introduce-auditlogger-in-manager.py
Add tamper-evident audit logging
2025-08-06 12:12:28 -04:00
thePR0M3TH3AN
b3b703985d test: cover audit logging 2025-08-06 12:07:09 -04:00
thePR0M3TH3AN
363b54b656 Merge pull request #782 from PR0M3TH3AN/codex/add-architecture-overview-to-readme
docs: add Nostr setup guide and architecture overview
2025-08-06 10:09:22 -04:00
thePR0M3TH3AN
1a35bb42bd docs: add Nostr setup guide and architecture overview 2025-08-06 10:09:05 -04:00
thePR0M3TH3AN
8cc2e75741 Merge pull request #781 from PR0M3TH3AN/codex/add-fuzz-tests-for-encryption-module
Calibrate Argon2 KDF and add encryption fuzz tests
2025-08-06 10:07:44 -04:00
thePR0M3TH3AN
edcf2787ee Add Argon2 calibration and encryption fuzz tests 2025-08-06 09:58:44 -04:00
thePR0M3TH3AN
072db52650 Merge pull request #780 from PR0M3TH3AN/codex/cache-bip-85-results-in-manager.py
Cache BIP85 derivations and skip unchanged snapshot chunks
2025-08-06 08:42:30 -04:00
thePR0M3TH3AN
a864da5751 Cache BIP85 derivations and incremental snapshots 2025-08-06 08:36:48 -04:00
thePR0M3TH3AN
37a1d4b4cf Merge pull request #779 from PR0M3TH3AN/codex/wrap-worker-functions-with-error-handling
Queue background errors
2025-08-05 23:32:42 -04:00
thePR0M3TH3AN
f0a7fb7da1 Queue background errors 2025-08-05 23:26:54 -04:00
thePR0M3TH3AN
b49e37b6e1 Merge pull request #778 from PR0M3TH3AN/codex/add-quick-unlock-configuration-flag
Add quick unlock flag and event logging
2025-08-05 23:10:19 -04:00
thePR0M3TH3AN
94d0b80dce Add quick unlock flag and event logging 2025-08-05 23:05:11 -04:00
thePR0M3TH3AN
4f11db5aa4 Merge pull request #777 from PR0M3TH3AN/codex/add-configurable-attempt-counter-with-backoff
Add configurable prompt attempt limit with exponential backoff
2025-08-05 22:53:56 -04:00
thePR0M3TH3AN
099c24921f feat: add configurable prompt backoff 2025-08-05 22:48:18 -04:00
thePR0M3TH3AN
7725701b50 Merge pull request #776 from PR0M3TH3AN/codex/raise-exception-if-xclip/xsel-is-missing
Gracefully handle missing clipboard utilities
2025-08-05 22:35:34 -04:00
thePR0M3TH3AN
b795d1236a Raise ClipboardUnavailableError on all platforms 2025-08-05 22:26:10 -04:00
thePR0M3TH3AN
6888fa2431 Remove duplicate import in test 2025-08-05 22:18:26 -04:00
thePR0M3TH3AN
1870614d8a Merge pull request #775 from PR0M3TH3AN/codex/create-test-fixtures-and-refactor-tests
Add test fixtures for manager and vault
2025-08-05 21:59:24 -04:00
thePR0M3TH3AN
34f19e1b2b Add test fixtures for manager and vault 2025-08-05 21:53:23 -04:00
thePR0M3TH3AN
41848fbcc3 Merge pull request #774 from PR0M3TH3AN/codex/refactor-code-organization-for-nostr-module
refactor: modularize nostr client
2025-08-05 21:44:07 -04:00
thePR0M3TH3AN
2aae6db22d fix: route nostr helpers through client module 2025-08-05 21:38:48 -04:00
thePR0M3TH3AN
f36c12122e Merge pull request #773 from PR0M3TH3AN/codex/add-dependency-auditing-and-checks
Add dependency vulnerability scan and optional dependency checks
2025-08-05 21:10:21 -04:00
thePR0M3TH3AN
68eaa34d76 Add dependency scanning and optional dependency checks 2025-08-05 21:04:50 -04:00
thePR0M3TH3AN
c2d80aa438 Merge pull request #772 from PR0M3TH3AN/codex/replace-except-exception-with-specific-exceptions
Refine exception handling in config and terminal utilities
2025-08-05 20:41:20 -04:00
thePR0M3TH3AN
87cf2d837b Refine exception handling 2025-08-05 20:34:26 -04:00
thePR0M3TH3AN
ade2d99572 Merge pull request #771 from PR0M3TH3AN/codex/convert-api-functions-to-async
Refactor API endpoints to async
2025-08-05 20:24:01 -04:00
thePR0M3TH3AN
91bea60928 Limit anyio tests to installed backends 2025-08-05 20:14:42 -04:00
thePR0M3TH3AN
dc7673c7e0 Refactor API endpoints to async 2025-08-05 20:03:56 -04:00
thePR0M3TH3AN
726a8f7aa4 Merge pull request #770 from PR0M3TH3AN/codex/catch-specific-exceptions-in-_reload_relays
Improve relay reload error handling
2025-08-05 19:52:11 -04:00
thePR0M3TH3AN
181f486afb Add test for relay reload logging 2025-08-05 19:46:14 -04:00
thePR0M3TH3AN
5e8375aad5 Merge pull request #769 from PR0M3TH3AN/codex/refactor-to-use-fastapi-dependencies
refactor: move api state to app
2025-08-05 19:19:58 -04:00
thePR0M3TH3AN
20ee8a891b refactor: move api state to app 2025-08-05 19:14:11 -04:00
thePR0M3TH3AN
fa4826fe2d Merge pull request #768 from PR0M3TH3AN/codex/refactor-cli.py-into-command-modules
refactor: modularize CLI commands
2025-08-05 18:57:16 -04:00
thePR0M3TH3AN
90b60a6682 refactor: modularize CLI commands 2025-08-05 18:51:36 -04:00
thePR0M3TH3AN
3744cf9f30 Merge pull request #767 from PR0M3TH3AN/codex/streamline-nostr-sync-notifications
Fix inconsistent Nostr sync notifications
2025-08-05 15:04:26 -04:00
thePR0M3TH3AN
2949cc22c9 Fix inconsistent Nostr sync notifications 2025-08-05 14:53:39 -04:00
thePR0M3TH3AN
9c5e6a12a0 Merge pull request #766 from PR0M3TH3AN/codex/modify-_display_live_stats-for-input-handling
Handle immediate Enter in stats view
2025-08-05 13:19:05 -04:00
thePR0M3TH3AN
89cbef1aa4 Flush stdin and exit stats screen on Enter 2025-08-05 13:13:31 -04:00
thePR0M3TH3AN
d21ad78a02 Merge pull request #765 from PR0M3TH3AN/codex/verify-list-refresh-logic-results
test: cover archived entries in summaries and view handler
2025-08-05 13:03:08 -04:00
thePR0M3TH3AN
6260e81eaa test: cover archived entries in summaries and view handler 2025-08-05 12:56:33 -04:00
thePR0M3TH3AN
a78d587307 Merge pull request #764 from PR0M3TH3AN/codex/recompute-summaries-in-loop-and-add-tests
Refresh entry summaries after edits
2025-08-05 12:46:13 -04:00
thePR0M3TH3AN
19881dbeeb Test entry summary refresh on edits 2025-08-05 12:40:39 -04:00
thePR0M3TH3AN
224143eb76 Merge pull request #763 from PR0M3TH3AN/codex/update-fingerprint-removal-logic-and-tests
Handle empty profile cleanup
2025-08-05 12:31:51 -04:00
thePR0M3TH3AN
1f669746db Handle empty profile cleanup 2025-08-05 12:24:53 -04:00
thePR0M3TH3AN
0d883b2736 Merge pull request #762 from PR0M3TH3AN/codex/update-readme-to-highlight-breaking-change
docs: document deterministic derivation breaking change
2025-08-05 10:56:22 -04:00
thePR0M3TH3AN
6a20728db4 docs: note deterministic derivation change 2025-08-05 10:55:56 -04:00
thePR0M3TH3AN
8e703e3282 Merge pull request #761 from PR0M3TH3AN/codex/fix-seed-decryption-failure
Add PBKDF2 iteration fallback
2025-08-04 15:13:35 -04:00
thePR0M3TH3AN
9cc7e4d0d7 Test PBKDF2 iteration fallback 2025-08-04 15:01:40 -04:00
thePR0M3TH3AN
036e2e59be Merge pull request #760 from PR0M3TH3AN/codex/update-migration-handling-and-tests
Gate sync prompt on confirmed migrations
2025-08-04 14:52:31 -04:00
thePR0M3TH3AN
3823603712 Handle migration flags for sync prompt 2025-08-04 14:46:21 -04:00
thePR0M3TH3AN
f16a771a6c Merge pull request #759 from PR0M3TH3AN/codex/modify-vault.load_index-migration-logic
Handle legacy index migration errors
2025-08-04 13:36:59 -04:00
thePR0M3TH3AN
1a194aec04 Handle legacy index migration errors 2025-08-04 13:36:44 -04:00
thePR0M3TH3AN
f70f70e749 Merge pull request #758 from PR0M3TH3AN/codex/refactor-encryptionmanager.decrypt_data
Refactor decrypt_data error handling
2025-08-04 13:11:07 -04:00
thePR0M3TH3AN
4d7f28b400 Refactor decrypt_data error handling 2025-08-04 13:10:49 -04:00
thePR0M3TH3AN
054ffd3383 Merge pull request #757 from PR0M3TH3AN/codex/update-decryption-for-multiple-pbkdf2-iterations
Support legacy PBKDF2 iteration migration
2025-08-04 13:00:49 -04:00
thePR0M3TH3AN
2b22fd7d5e Support multiple legacy PBKDF2 iterations 2025-08-04 12:38:04 -04:00
thePR0M3TH3AN
9cfd40ce7b Merge pull request #756 from PR0M3TH3AN/codex/update-migration-handling-in-manager.py
Handle migration flag during manager init
2025-08-04 11:49:13 -04:00
thePR0M3TH3AN
fdfdbc883b Use vault migration flag to prompt sync 2025-08-04 11:43:34 -04:00
thePR0M3TH3AN
264caff711 Merge pull request #755 from PR0M3TH3AN/codex/update-migration-handling-in-load_index
Persist schema migrations
2025-08-04 11:00:49 -04:00
thePR0M3TH3AN
b03530afba Persist schema migrations 2025-08-04 10:56:14 -04:00
thePR0M3TH3AN
8b8416c09f Merge pull request #754 from PR0M3TH3AN/codex/update-legacy-migration-tests
test: ensure migration sync is opt-in
2025-08-04 10:19:24 -04:00
thePR0M3TH3AN
9d71db0cf2 test: ensure migration sync is opt-in 2025-08-04 10:10:57 -04:00
thePR0M3TH3AN
68d8e03927 Merge pull request #753 from PR0M3TH3AN/codex/update-sync_index_from_nostr_async-logic
Prompt migration sync after Nostr index update
2025-08-04 09:37:54 -04:00
thePR0M3TH3AN
0dda7ebe5b feat: handle migration sync prompt 2025-08-04 09:37:36 -04:00
thePR0M3TH3AN
9dbe22d332 Merge pull request #752 from PR0M3TH3AN/codex/update-initialize_managers-method-logic
Prompt sync after migration
2025-08-04 09:27:29 -04:00
thePR0M3TH3AN
6d110679c5 Prompt sync after migration 2025-08-04 09:05:32 -04:00
thePR0M3TH3AN
30da26f086 Merge pull request #751 from PR0M3TH3AN/codex/modify-decrypt_data-method-in-encryptionmanager
Refine legacy migration prompt
2025-08-04 08:42:34 -04:00
thePR0M3TH3AN
d58c836fe6 Refine legacy migration prompt 2025-08-04 08:39:51 -04:00
thePR0M3TH3AN
c64ca912b8 Merge pull request #750 from PR0M3TH3AN/codex/add-legacy-vault-migration-test
test: ensure migration persistence
2025-08-03 21:08:18 -04:00
thePR0M3TH3AN
f8f43dc2b5 test: ensure migration persistence 2025-08-03 21:02:14 -04:00
thePR0M3TH3AN
b40a7416ab Merge pull request #749 from PR0M3TH3AN/codex/update-migration-handling-in-vault-and-tests
Track migrations and trigger sync
2025-08-03 20:56:29 -04:00
thePR0M3TH3AN
b5024d99de Track migrations and trigger sync 2025-08-03 20:56:17 -04:00
thePR0M3TH3AN
aeee3b91d9 Merge pull request #748 from PR0M3TH3AN/codex/remove-stray-seedpass-files-after-migration
Clean up legacy index files after migration
2025-08-03 20:39:13 -04:00
thePR0M3TH3AN
292b443158 Clean up legacy index files after migration 2025-08-03 20:39:00 -04:00
thePR0M3TH3AN
7fc098e8f2 Merge pull request #747 from PR0M3TH3AN/codex/update-checksum-generation-in-encryption.py
Fix checksum path stripping extensions and ensure legacy file removal
2025-08-03 20:32:30 -04:00
thePR0M3TH3AN
42f9f0c4bb Fix checksum file naming and add tests 2025-08-03 20:32:03 -04:00
thePR0M3TH3AN
bc8307f611 Merge pull request #746 from PR0M3TH3AN/codex/improve-error-message-display
Streamline legacy decryption error output
2025-08-03 20:11:26 -04:00
thePR0M3TH3AN
bf129e5dca chore: streamline legacy decryption error 2025-08-03 20:05:20 -04:00
thePR0M3TH3AN
2b959aa33f Merge pull request #745 from PR0M3TH3AN/codex/improve-fallback-error-handling
feat: add legacy migration prompt
2025-08-03 19:45:59 -04:00
thePR0M3TH3AN
cc077a9762 test: mock legacy choice prompts 2025-08-03 19:34:27 -04:00
thePR0M3TH3AN
d7a39c88d3 test: cover legacy migration prompt 2025-08-03 19:20:50 -04:00
thePR0M3TH3AN
1ca84ba946 Merge pull request #744 from PR0M3TH3AN/codex/fix-legacy-key-derivation-iterations-for-decryption
Fix legacy decryption iterations
2025-08-03 18:51:44 -04:00
thePR0M3TH3AN
738667ca2d Skip flakey backup corruption test on Windows 2025-08-03 18:46:20 -04:00
thePR0M3TH3AN
6fa9f0839e Fix legacy decryption iterations 2025-08-03 17:53:42 -04:00
thePR0M3TH3AN
2f95944318 Merge pull request #743 from PR0M3TH3AN/codex/fix-aes-gcm-decryption-error-due-to-key-mismatch
Add legacy decryption fallback and tests
2025-08-03 17:38:16 -04:00
thePR0M3TH3AN
6a31ec7e99 Test legacy password fallback in decrypt_data 2025-08-03 17:32:25 -04:00
thePR0M3TH3AN
f03a890776 Merge pull request #742 from PR0M3TH3AN/codex/make-initial-backup-check-more-reliable
Improve Nostr backup restoration reliability
2025-08-03 17:09:01 -04:00
thePR0M3TH3AN
942cb1d89a Improve Nostr backup restoration reliability 2025-08-03 17:01:49 -04:00
thePR0M3TH3AN
e655369eee Merge pull request #741 from PR0M3TH3AN/codex/update-decryption-method-for-legacy-support
feat: support legacy index decryption fallback
2025-08-03 16:51:42 -04:00
thePR0M3TH3AN
1301b79279 test: cover legacy index decryption fallback 2025-08-03 16:47:13 -04:00
thePR0M3TH3AN
e5ebfdcad4 Merge pull request #740 from PR0M3TH3AN/codex/update-restore-method-for-nostr
Use manager sync for Nostr restore with legacy notice
2025-08-03 16:38:46 -04:00
thePR0M3TH3AN
8e78a72257 Use manager sync for Nostr restore 2025-08-03 16:38:33 -04:00
thePR0M3TH3AN
041e40bc1b Merge pull request #739 from PR0M3TH3AN/codex/add-logging-for-manifest-filter-success
feat: add manifest identifier fallback logging
2025-08-03 16:31:59 -04:00
thePR0M3TH3AN
49675211e4 feat: add manifest identifier fallback logging 2025-08-03 16:28:05 -04:00
thePR0M3TH3AN
30261094d2 Merge pull request #738 from PR0M3TH3AN/codex/implement-legacy-key-fallback-in-snapshot-fetch
Add legacy key fallback when fetching Nostr snapshots
2025-08-03 15:56:28 -04:00
thePR0M3TH3AN
911fd6705d test: cover legacy key fallback 2025-08-03 15:55:45 -04:00
thePR0M3TH3AN
7bb67030cb Merge pull request #737 from PR0M3TH3AN/codex/add-legacy-nostr-key-generation-method
Add legacy Nostr key derivation
2025-08-03 15:08:29 -04:00
thePR0M3TH3AN
8568e38d36 Add legacy Nostr key derivation 2025-08-03 14:59:53 -04:00
thePR0M3TH3AN
675adfb84b Merge pull request #736 from PR0M3TH3AN/codex/fix-import-and-migration-for-older-index
nostr: support legacy manifest id
2025-08-03 14:38:01 -04:00
thePR0M3TH3AN
f0f7aee9e6 Merge pull request #735 from PR0M3TH3AN/codex/validate-path-argument-in-import-handler
Validate vault import path and extension
2025-08-03 14:32:43 -04:00
thePR0M3TH3AN
aa688bc49a test: add legacy manifest fallback test 2025-08-03 14:32:40 -04:00
thePR0M3TH3AN
77c4c33818 Validate import path and extension 2025-08-03 14:15:46 -04:00
thePR0M3TH3AN
d868d2204b Merge pull request #734 from PR0M3TH3AN/codex/add-password-authentication-for-specified-endpoints
Require password for sensitive read endpoints
2025-08-03 14:15:04 -04:00
thePR0M3TH3AN
3a19ef9c2a require password for sensitive read endpoints 2025-08-03 14:12:24 -04:00
thePR0M3TH3AN
68341db0fe Merge pull request #733 from PR0M3TH3AN/codex/use-hmac.compare_digest-for-token-verification
Use constant-time token comparison
2025-08-03 12:45:21 -04:00
thePR0M3TH3AN
3dc10ae448 Use constant-time token comparison 2025-08-03 12:39:43 -04:00
thePR0M3TH3AN
23a3ae3928 Merge pull request #732 from PR0M3TH3AN/codex/update-agents.md-with-migration-plan
docs: add migration guidance
2025-08-03 11:46:24 -04:00
thePR0M3TH3AN
f664a6c40f fix: migrate legacy nostr payloads 2025-08-03 11:41:06 -04:00
thePR0M3TH3AN
44ce005cdc test: cover legacy migration prompt and sync 2025-08-03 11:29:23 -04:00
thePR0M3TH3AN
01fe849f90 Merge pull request #731 from PR0M3TH3AN/codex/remove-sensitive-debug-logging
Remove sensitive debug logging
2025-08-03 11:05:13 -04:00
thePR0M3TH3AN
d75cc760d3 Remove sensitive debug logging 2025-08-03 11:02:36 -04:00
thePR0M3TH3AN
42aa945b00 Merge pull request #730 from PR0M3TH3AN/codex/update-readme-and-documentation
Clarify Nostr vault restoration requirements
2025-08-03 10:39:43 -04:00
thePR0M3TH3AN
a9c5deb800 Document restoration requirements 2025-08-03 10:38:55 -04:00
thePR0M3TH3AN
b72452a734 Merge pull request #729 from PR0M3TH3AN/codex/add-gitleaks-or-trufflehog-job
ci: add gitleaks scanning
2025-08-03 10:30:00 -04:00
thePR0M3TH3AN
2c44f51fc4 ci: add secret scanning 2025-08-03 10:27:13 -04:00
thePR0M3TH3AN
59c06041fd Merge pull request #728 from PR0M3TH3AN/codex/generate-new-requirements.lock-with-hashes
Use hashed requirements lock and enforce in CI
2025-08-03 10:20:32 -04:00
thePR0M3TH3AN
b0db9806b3 Regenerate lockfile with Python 3.11 2025-08-03 10:18:08 -04:00
thePR0M3TH3AN
6f885bd65e Use hashed requirements lock 2025-08-03 10:08:50 -04:00
thePR0M3TH3AN
c3ed4c08ee Merge pull request #727 from PR0M3TH3AN/codex/update-gui-to-install-pinned-toga-versions
feat: secure GUI backend installation
2025-08-03 09:59:32 -04:00
thePR0M3TH3AN
68f47052c3 feat: secure GUI backend installation 2025-08-03 09:57:32 -04:00
thePR0M3TH3AN
a16310b04b Merge pull request #726 from PR0M3TH3AN/codex/add-tests-for-key-derivation-with-fingerprints
Add fingerprint-based key derivation tests
2025-08-03 09:51:58 -04:00
thePR0M3TH3AN
1e544a7d41 test: add fingerprint consistency tests for key derivation 2025-08-03 09:49:21 -04:00
thePR0M3TH3AN
cb37783354 Merge pull request #725 from PR0M3TH3AN/codex/update-key-derivation-functions-to-use-fingerprint
Use fingerprint-based salt for password key derivation
2025-08-03 09:45:03 -04:00
thePR0M3TH3AN
5423c41b06 Include fingerprint salt in password key derivation 2025-08-03 09:37:59 -04:00
thePR0M3TH3AN
2794b67d83 Merge pull request #724 from PR0M3TH3AN/codex/replace-random.random-with-cryptographic-shuffle
Use HMAC-based deterministic shuffle
2025-08-03 09:27:11 -04:00
thePR0M3TH3AN
aad41929bf Use HMAC DRNG for RSA PGP keys 2025-08-03 09:24:50 -04:00
thePR0M3TH3AN
4f09ad5c26 Use HMAC-based deterministic shuffle 2025-08-03 09:15:43 -04:00
thePR0M3TH3AN
3cdf391742 Merge pull request #723 from PR0M3TH3AN/codex/switch-to-atomic-writes-in-fingerprint_manager-and-manager
Use atomic file writes for persistent data
2025-08-03 09:02:20 -04:00
thePR0M3TH3AN
032caed3d0 Add atomic write utility and tests 2025-08-03 08:57:04 -04:00
thePR0M3TH3AN
2294656f36 Merge pull request #722 from PR0M3TH3AN/codex/hash-jwt-in-start_server-and-update-checks
Hash JWT token in API
2025-08-03 08:51:15 -04:00
thePR0M3TH3AN
9d9f8a8bae Hash JWT token in API 2025-08-03 08:50:57 -04:00
thePR0M3TH3AN
9d80f7b607 Merge pull request #721 from PR0M3TH3AN/codex/add-rate-limiting-to-api
Add request rate limiting to API
2025-08-03 08:44:22 -04:00
thePR0M3TH3AN
e5f1158101 Test API rate limiting 2025-08-03 08:41:22 -04:00
thePR0M3TH3AN
c7df96aac5 Merge pull request #720 from PR0M3TH3AN/codex/set-up-dependabot-and-audit-workflow
chore: enable automated dependency auditing
2025-08-03 08:33:54 -04:00
thePR0M3TH3AN
5acd1d489d chore: add dependency auditing 2025-08-03 08:32:57 -04:00
thePR0M3TH3AN
f66e8b4776 Merge pull request #719 from PR0M3TH3AN/codex/update-dependencies-and-regenerate-lockfile
chore(deps): update aiohttp and python-multipart
2025-08-03 08:25:41 -04:00
thePR0M3TH3AN
10a03384d0 chore(deps): update aiohttp and python-multipart 2025-08-03 08:23:56 -04:00
thePR0M3TH3AN
7631d32bc6 Merge pull request #718 from PR0M3TH3AN/codex/replace-auto-install-logic-in-cli.gui
feat(cli): require manual GUI backend install
2025-08-03 08:17:57 -04:00
thePR0M3TH3AN
6dabbaa31e feat(cli): require manual GUI backend install 2025-08-03 08:17:41 -04:00
thePR0M3TH3AN
4228d82295 Merge pull request #717 from PR0M3TH3AN/codex/update-xclip-installation-method
docs: clarify manual clipboard dependencies
2025-08-03 08:12:43 -04:00
thePR0M3TH3AN
ccca399b09 docs: clarify manual clipboard dependencies 2025-08-03 08:12:25 -04:00
thePR0M3TH3AN
36061493ac Merge pull request #716 from PR0M3TH3AN/codex/normalize-and-validate-encryption-paths
Validate encryption paths to block traversal
2025-08-03 07:56:35 -04:00
thePR0M3TH3AN
f1bf65385c Validate encryption paths and block traversal 2025-08-03 07:54:07 -04:00
thePR0M3TH3AN
906e3921a2 Merge pull request #715 from PR0M3TH3AN/codex/remove-or-secure-api/v1/parent-seed-endpoint
Remove insecure parent seed endpoint
2025-08-02 22:02:15 -04:00
thePR0M3TH3AN
7aeba78245 Remove insecure parent seed endpoint 2025-08-02 22:01:38 -04:00
thePR0M3TH3AN
087b3bd657 Merge pull request #714 from PR0M3TH3AN/codex/refactor-start_server-for-security-enhancements
feat: add short-lived JWT auth and secure endpoints
2025-08-02 21:49:39 -04:00
thePR0M3TH3AN
186e39cc91 feat: add short-lived JWT auth and secure endpoints 2025-08-02 21:48:52 -04:00
thePR0M3TH3AN
8c9fe07609 Merge pull request #713 from PR0M3TH3AN/codex/replace-seed-validation-with-bip-39-check
Add BIP-39 seed validation
2025-08-02 21:38:45 -04:00
thePR0M3TH3AN
2f0eb44a44 Return error message from validate_seed 2025-08-02 21:38:16 -04:00
thePR0M3TH3AN
aeb146f862 Merge pull request #711 from PR0M3TH3AN/codex/add-password-mode-selection-and-quick-mode
feat: add quick password entry mode
2025-08-02 16:52:38 -04:00
thePR0M3TH3AN
7f503f0787 feat: add quick password entry mode 2025-08-02 16:43:43 -04:00
thePR0M3TH3AN
7a8c0aef86 Merge pull request #710 from PR0M3TH3AN/codex/add-entry-type-to-search-results
Include entry type in search results
2025-08-02 16:34:01 -04:00
thePR0M3TH3AN
dcd095d1af Include entry type in search results 2025-08-02 16:26:52 -04:00
thePR0M3TH3AN
b4f792ad67 Merge pull request #709 from PR0M3TH3AN/codex/add-key-validation-module-and-update-entrymanager
Add key validation helpers and enforce in entry management
2025-08-01 10:43:47 -04:00
thePR0M3TH3AN
cc8fba9f12 Add key validation utilities and integrate 2025-08-01 10:38:40 -04:00
thePR0M3TH3AN
20896812a4 Merge pull request #708 from PR0M3TH3AN/codex/fix-exit-error-in-seedpass
Fix crash when exiting without Nostr client
2025-08-01 09:50:58 -04:00
thePR0M3TH3AN
144751ef02 fix exit crash when nostr client missing 2025-08-01 09:28:49 -04:00
thePR0M3TH3AN
05d4bd94b6 Merge pull request #707 from PR0M3TH3AN/codex/fix-stats-screen-display-duration
Fix stats screen clearing bug
2025-08-01 08:49:15 -04:00
thePR0M3TH3AN
9adb539b84 Keep stats screen visible 2025-08-01 08:32:44 -04:00
thePR0M3TH3AN
64339b0c20 Merge pull request #706 from PR0M3TH3AN/codex/update-installation-script-and-documentation
Improve install script dependency handling
2025-07-31 22:06:30 -04:00
thePR0M3TH3AN
4ccdd2b3df feat(install): auto install gtk deps 2025-07-31 21:42:28 -04:00
thePR0M3TH3AN
61f1f5c02f Merge pull request #705 from PR0M3TH3AN/codex/revise-start_background_sync-implementation
Implement async sync worker cleanup
2025-07-31 21:37:14 -04:00
thePR0M3TH3AN
77757152d7 Add async background sync task management 2025-07-31 19:38:09 -04:00
thePR0M3TH3AN
265817b67d Merge pull request #704 from PR0M3TH3AN/codex/add-nostr-backup-restoration-features
Add Nostr restore guidance
2025-07-31 19:28:22 -04:00
thePR0M3TH3AN
e3bd669668 Add Nostr restore guidance and tests 2025-07-31 18:28:48 -04:00
thePR0M3TH3AN
f58ed03e6f Merge pull request #703 from PR0M3TH3AN/codex/add-statsmanager-module-and-integrate-functionality
Implement stats manager for one-time display
2025-07-31 16:11:56 -04:00
thePR0M3TH3AN
6d82ef1815 Add StatsManager for single display 2025-07-31 16:01:40 -04:00
thePR0M3TH3AN
a4ddd120c8 Merge pull request #702 from PR0M3TH3AN/codex/edit-manager.py-to-handle-import-errors
Improve database import validation
2025-07-31 15:53:07 -04:00
thePR0M3TH3AN
47da67b37c Improve import handling 2025-07-31 15:47:24 -04:00
thePR0M3TH3AN
27977612de Merge pull request #701 from PR0M3TH3AN/codex/verify-and-improve-manifest-identifier-handling
Fix manifest ID handling and add restore test
2025-07-31 14:49:52 -04:00
thePR0M3TH3AN
ceb2eb28ad Fix Nostr manifest handling 2025-07-31 14:42:53 -04:00
thePR0M3TH3AN
f83234c568 Merge pull request #700 from PR0M3TH3AN/codex/extend-handle_new_seed_setup-for-nostr-restore
Add Nostr restore option
2025-07-31 14:25:27 -04:00
thePR0M3TH3AN
37fc2779ad Add Nostr restore option and test 2025-07-31 13:26:25 -04:00
thePR0M3TH3AN
73f5a80b33 Merge pull request #699 from PR0M3TH3AN/codex/add-ssh-entry-modification-handling
Fix modify_entry for SSH and managed accounts
2025-07-31 13:13:23 -04:00
thePR0M3TH3AN
78bf5a4c64 Add SSH and managed account modify handling 2025-07-31 13:06:12 -04:00
thePR0M3TH3AN
db3a60fc5c Merge pull request #698 from PR0M3TH3AN/codex/update-linux-dependencies-in-install.sh
Add more lib deps to install script
2025-07-31 12:28:32 -04:00
thePR0M3TH3AN
365a098d70 Update Linux dependencies in install script 2025-07-31 12:21:59 -04:00
thePR0M3TH3AN
7cdb67e82e Merge pull request #697 from PR0M3TH3AN/codex/fix-display-of-notes-in-password-details
Fix password notes display
2025-07-31 10:43:03 -04:00
thePR0M3TH3AN
0a078bbcf4 Show notes when viewing password entries 2025-07-31 10:27:17 -04:00
thePR0M3TH3AN
8a5d1717f8 Merge pull request #696 from PR0M3TH3AN/codex/add-tests-for-password-special-modes
Add special character mode tests
2025-07-31 09:04:11 -04:00
thePR0M3TH3AN
05eae9bddd Add tests for password special character modes 2025-07-31 08:58:33 -04:00
thePR0M3TH3AN
3c1f6991a4 Merge pull request #695 from PR0M3TH3AN/codex/define-safe_special_chars-constant
Add SAFE_SPECIAL_CHARS constant usage
2025-07-31 08:50:24 -04:00
thePR0M3TH3AN
a7a2e70321 Import safe special chars constant 2025-07-31 08:37:42 -04:00
thePR0M3TH3AN
3020b681b8 Merge pull request #694 from PR0M3TH3AN/codex/update-documentation-for-new-policy-fields
Update docs for new password policy options
2025-07-30 22:15:14 -04:00
thePR0M3TH3AN
d3bd3b9e0a docs: document new password policy flags 2025-07-30 22:08:04 -04:00
thePR0M3TH3AN
4e787e362e Merge pull request #693 from PR0M3TH3AN/codex/update-models-and-fastapi-routes-for-new-policy-fields
Extend API password generation options
2025-07-30 21:47:39 -04:00
thePR0M3TH3AN
6d6a284096 Add password policy options to API 2025-07-30 21:23:18 -04:00
thePR0M3TH3AN
b76112f11b Merge pull request #692 from PR0M3TH3AN/codex/enhance-password-management-options
Add per-entry password policy prompts
2025-07-30 21:03:11 -04:00
thePR0M3TH3AN
b74c0993ca Prompt for per-entry password policy 2025-07-30 20:55:56 -04:00
thePR0M3TH3AN
d8cb21e527 Merge pull request #691 from PR0M3TH3AN/codex/update-entry_add-command-options-and-tests
Add password policy options
2025-07-30 20:30:48 -04:00
thePR0M3TH3AN
b57e19b657 feat: extend password options 2025-07-30 20:22:53 -04:00
thePR0M3TH3AN
3f169747d1 Merge pull request #690 from PR0M3TH3AN/codex/extend-entrymanager-to-support-policy-overrides
Add per-entry password policy overrides
2025-07-30 20:13:28 -04:00
thePR0M3TH3AN
b4d60782af Add per-entry password policy overrides 2025-07-30 19:57:38 -04:00
thePR0M3TH3AN
f664c4099c Merge pull request #689 from PR0M3TH3AN/codex/add-password-policy-config-options
Add password policy config options
2025-07-30 19:44:44 -04:00
thePR0M3TH3AN
7dba8e138d Add password policy config options 2025-07-30 19:10:10 -04:00
thePR0M3TH3AN
cfc7e455d5 Merge pull request #688 from PR0M3TH3AN/codex/extend-passwordpolicy-with-special-char-options
Extend password policy options
2025-07-30 18:52:13 -04:00
thePR0M3TH3AN
dcb5c6e805 Extend password policy and generator 2025-07-30 18:44:41 -04:00
thePR0M3TH3AN
d4d475438f Merge pull request #687 from PR0M3TH3AN/codex/add-edit-options-for-key/value-entry
Fix CLI edit options for key/value entries
2025-07-30 16:59:53 -04:00
thePR0M3TH3AN
627d69cf30 Add edit options for key/value entries 2025-07-30 16:51:53 -04:00
thePR0M3TH3AN
c15776e37e Merge pull request #686 from PR0M3TH3AN/codex/rename-label-field-and-add-key-field-editing
Add value editing for Key/Value entries
2025-07-30 16:33:17 -04:00
thePR0M3TH3AN
2f7b41c5dd Merge branch 'beta' into codex/rename-label-field-and-add-key-field-editing 2025-07-30 16:16:22 -04:00
thePR0M3TH3AN
bb1d692798 Add value editing for Key/Value entries 2025-07-30 16:05:01 -04:00
thePR0M3TH3AN
b621cffae6 Merge pull request #685 from PR0M3TH3AN/codex/rename-label-field-and-add-key-field-editing
Add key editing for Key/Value entries
2025-07-30 15:56:24 -04:00
thePR0M3TH3AN
3937ccfb75 Allow editing Key field in Key/Value entries 2025-07-30 15:50:48 -04:00
thePR0M3TH3AN
0dd5b1f301 Merge pull request #684 from PR0M3TH3AN/codex/fix-importerror-in-nostr_sdk-dependency
Fix nostr dependency
2025-07-30 15:31:35 -04:00
thePR0M3TH3AN
715952fba6 Ignore ecdsa advisory 2025-07-30 15:25:47 -04:00
thePR0M3TH3AN
acb1126287 Fix nostr dependency 2025-07-30 15:14:11 -04:00
thePR0M3TH3AN
bcd8002e1d Pin nostr-sdk to 0.42.x 2025-07-30 14:45:53 -04:00
thePR0M3TH3AN
3ad7c1ab94 Merge pull request #683 from PR0M3TH3AN/codex/add-key-field-to-key/value-pair-entry
Add key field to key/value entries
2025-07-28 15:11:06 -04:00
thePR0M3TH3AN
4a20817094 Add key field to key/value entries 2025-07-28 15:04:56 -04:00
thePR0M3TH3AN
d3f2cb8256 Merge pull request #682 from PR0M3TH3AN/codex/fix-stats-screen-refresh-data-pulling
Ensure stats refresh initiates Nostr sync
2025-07-28 14:47:21 -04:00
thePR0M3TH3AN
a3d45a117c Fix relay URL handling for nostr-sdk 0.43 2025-07-28 13:55:55 -04:00
thePR0M3TH3AN
2d39d7a5bd feat(stats): refresh triggers background sync 2025-07-28 13:31:15 -04:00
thePR0M3TH3AN
f677f4b445 Merge pull request #680 from PR0M3TH3AN/codex/find-cause-of-install-script-syntax-error
Clarify beta installer command
2025-07-25 19:14:00 -04:00
thePR0M3TH3AN
5b0051f76f docs: clarify beta installer command 2025-07-25 19:08:06 -04:00
thePR0M3TH3AN
0490583fee Merge pull request #679 from PR0M3TH3AN/codex/add-test-for-concurrent-vault-modifications
Add sync race condition test
2025-07-24 21:52:39 -04:00
thePR0M3TH3AN
0edf4e5c83 test: add race condition sync test 2025-07-24 21:47:04 -04:00
thePR0M3TH3AN
01fa1f4997 Merge pull request #678 from PR0M3TH3AN/codex/extend-state-manager-for-manifest_id-and-delta_since
Persist manifest and delta state
2025-07-24 21:16:25 -04:00
thePR0M3TH3AN
ea2451f4a0 Persist manifest state 2025-07-24 21:04:04 -04:00
thePR0M3TH3AN
5eb5e29094 Merge pull request #677 from PR0M3TH3AN/codex/add-modified_ts-management-for-vault-entries
Add per-entry timestamps and merge logic
2025-07-24 20:36:18 -04:00
thePR0M3TH3AN
747e2be5a9 Add modified_ts tracking and merge logic 2025-07-24 20:28:21 -04:00
thePR0M3TH3AN
db90d9caf0 Merge pull request #676 from PR0M3TH3AN/codex/add-manifest-version-check-before-publishing
Add manifest consistency check before publishing
2025-07-24 20:10:34 -04:00
thePR0M3TH3AN
cb1e18c8ba Add manifest consistency check and tests 2025-07-24 20:05:30 -04:00
thePR0M3TH3AN
cf3803c422 Merge pull request #675 from PR0M3TH3AN/codex/update-sync_index_from_nostr_async-method
Handle multiple deltas during sync
2025-07-24 19:48:30 -04:00
thePR0M3TH3AN
24a606fb70 Apply sequential deltas from Nostr 2025-07-24 19:41:35 -04:00
thePR0M3TH3AN
5850b68c9a Merge pull request #674 from PR0M3TH3AN/codex/add-thread-safety-to-nostrclient-state
Add lock-protected state updates in NostrClient
2025-07-24 19:34:45 -04:00
thePR0M3TH3AN
529eb5a0a6 Ensure thread-safe NostrClient state reads 2025-07-24 19:28:35 -04:00
thePR0M3TH3AN
e0318c7850 Merge pull request #673 from PR0M3TH3AN/codex/add-thread-safety-to-nostrclient-states
Add thread-safe state to NostrClient
2025-07-24 19:10:31 -04:00
thePR0M3TH3AN
64a84c59d7 Add thread-safe state access in NostrClient 2025-07-24 19:05:31 -04:00
thePR0M3TH3AN
aaf7b79e59 Merge pull request #672 from PR0M3TH3AN/codex/add-configurable-max-retries-and-retry-delay
Implement Nostr retry backoff
2025-07-24 19:00:20 -04:00
thePR0M3TH3AN
8e7224dfd2 Add configurable Nostr retry backoff 2025-07-24 18:53:45 -04:00
thePR0M3TH3AN
61ffb073b5 Merge pull request #671 from PR0M3TH3AN/codex/define-manifest-id-constant-and-update-functions
Use replaceable manifest identifiers
2025-07-24 18:11:44 -04:00
thePR0M3TH3AN
3e83616a4f Update fastapi and lockfile for starlette patch 2025-07-24 18:01:55 -04:00
thePR0M3TH3AN
85ce777333 Bump starlette to address security alert 2025-07-24 17:45:37 -04:00
thePR0M3TH3AN
b70585c55e Add manifest identifier constant and update Nostr client 2025-07-24 17:23:57 -04:00
thePR0M3TH3AN
39114b0b8a Merge pull request #668 from PR0M3TH3AN/codex/add-integration-tests-for-seedpass-cli
Add CLI integration test
2025-07-19 22:47:35 -04:00
thePR0M3TH3AN
98f8bfa679 Fix CLI integration test 2025-07-19 22:40:34 -04:00
thePR0M3TH3AN
b7042a70db Add CLI integration test 2025-07-19 22:20:58 -04:00
thePR0M3TH3AN
ed3f0da731 Merge pull request #667 from PR0M3TH3AN/codex/update-readme-with-windows-sync-issues
Add Windows Nostr sync troubleshooting
2025-07-19 20:11:52 -04:00
thePR0M3TH3AN
f5653c9bb1 docs: add Windows Nostr troubleshooting 2025-07-19 19:47:35 -04:00
thePR0M3TH3AN
e542248778 Merge pull request #665 from PR0M3TH3AN/codex/update-python-ci-workflow-for-cross-platform-testing
Run GUI desktop tests on all platforms
2025-07-19 19:30:16 -04:00
thePR0M3TH3AN
3b0825633c Fix TOTP viewer test for new Row API 2025-07-19 19:24:34 -04:00
thePR0M3TH3AN
375fd571fa Merge pull request #666 from PR0M3TH3AN/codex/enhance-error-reporting-in-nostrclient-and-passwordmanager
Improve sync error reporting
2025-07-19 19:19:19 -04:00
thePR0M3TH3AN
59dbb885aa Improve sync error reporting 2025-07-19 19:14:27 -04:00
thePR0M3TH3AN
160a8fac51 CI: run desktop GUI tests 2025-07-19 19:11:59 -04:00
thePR0M3TH3AN
bf5281804e Merge pull request #664 from PR0M3TH3AN/32y2sb-codex/add-background-vault-sync-method
Add vault sync scheduling to GUI
2025-07-19 18:22:33 -04:00
thePR0M3TH3AN
557af9745a test: clean up GUI sync tests 2025-07-19 18:17:39 -04:00
thePR0M3TH3AN
ec52d2eda0 Add vault sync trigger to GUI 2025-07-19 18:14:47 -04:00
thePR0M3TH3AN
49544c0200 Merge pull request #662 from PR0M3TH3AN/codex/modify-entrydialog.save-to-update-entry-source
Update entry dialog to modify list source directly
2025-07-19 17:22:56 -04:00
thePR0M3TH3AN
e6ca36b8b7 Update EntryDialog to modify entry source 2025-07-19 17:18:46 -04:00
thePR0M3TH3AN
398f8fa59f Merge pull request #661 from PR0M3TH3AN/codex/refactor-entry-source-and-update-sync-methods
Switch entry service to background sync
2025-07-19 15:57:54 -04:00
thePR0M3TH3AN
ff1f8bb4e1 Use background sync in entry service 2025-07-19 15:50:52 -04:00
thePR0M3TH3AN
47f26292b1 Merge pull request #660 from PR0M3TH3AN/codex/enhance-install-scripts-for-seedpass-detection
Enhance install scripts stale command detection
2025-07-19 15:26:42 -04:00
thePR0M3TH3AN
bdf6a038c2 Enhance install scripts to warn about stale executables 2025-07-19 15:17:16 -04:00
thePR0M3TH3AN
411c5df4b4 Merge pull request #659 from PR0M3TH3AN/codex/update-error-handling-in-gui-command
Add automatic BeeWare backend installation
2025-07-19 15:00:46 -04:00
thePR0M3TH3AN
9fc117b105 feat(cli): auto install GUI backend 2025-07-19 14:46:16 -04:00
thePR0M3TH3AN
63a5cd3190 Merge pull request #658 from PR0M3TH3AN/codex/fix-install-script-errors-for-all-platforms
Fix linux install dependencies
2025-07-19 14:23:02 -04:00
thePR0M3TH3AN
c80495eca6 fix linux dependencies 2025-07-19 14:21:24 -04:00
thePR0M3TH3AN
1507ba9553 Merge pull request #657 from PR0M3TH3AN/codex/fix-install-script-errors-for-linux-and-mac
Fix pygobject deps in install script
2025-07-19 14:11:53 -04:00
thePR0M3TH3AN
a01e0f0037 fix: install missing deps for pygobject 2025-07-19 14:11:34 -04:00
thePR0M3TH3AN
a9d1d2f242 Merge pull request #656 from PR0M3TH3AN/codex/fix-install-script-errors-for-all-os
Fix install script missing cairo
2025-07-19 13:57:42 -04:00
thePR0M3TH3AN
64c174c385 Fix CLI modify error on mac 2025-07-19 13:53:02 -04:00
thePR0M3TH3AN
04862bce48 install: ensure cairo installed 2025-07-19 13:43:49 -04:00
thePR0M3TH3AN
2832b0150e Merge pull request #655 from PR0M3TH3AN/codex/update-readme-and-gui-adapter-documentation
Update GUI setup docs
2025-07-19 13:23:50 -04:00
thePR0M3TH3AN
bfe65c9707 docs: mention automatic BeeWare backend install 2025-07-19 13:19:20 -04:00
thePR0M3TH3AN
c1f1adbb8f Merge pull request #654 from PR0M3TH3AN/codex/edit-install.ps1-to-add-toga-winforms
Update Windows installer with Toga backend
2025-07-19 13:13:59 -04:00
thePR0M3TH3AN
d7df6679bd Install toga-winforms backend on Windows 2025-07-19 13:09:31 -04:00
thePR0M3TH3AN
856ef3dc32 Merge pull request #653 from PR0M3TH3AN/codex/update-install.sh-for-os-specific-packages
Install Toga backend via OS detection
2025-07-19 13:05:03 -04:00
thePR0M3TH3AN
ad3f1bc80c Add platform-specific Toga installation 2025-07-19 13:00:35 -04:00
thePR0M3TH3AN
78c398ba4b Merge pull request #652 from PR0M3TH3AN/codex/fix-script-checksum-mismatch-warning
Clarify GUI setup and checksum mismatch
2025-07-19 12:24:33 -04:00
thePR0M3TH3AN
a94762e8a1 Merge branch 'beta' into codex/fix-script-checksum-mismatch-warning 2025-07-19 12:18:26 -04:00
thePR0M3TH3AN
70e05970f0 Document GUI backend requirement and handle missing backend 2025-07-19 12:16:30 -04:00
thePR0M3TH3AN
ade572815a Merge pull request #651 from PR0M3TH3AN/codex/update-gui-getting-started-documentation
Add Toga backend note
2025-07-19 12:15:16 -04:00
thePR0M3TH3AN
c6a87e000d docs: clarify GUI backend requirement 2025-07-19 12:09:39 -04:00
thePR0M3TH3AN
6855c85329 Merge pull request #650 from PR0M3TH3AN/codex/add-gui-event-listening-section-to-docs
Update GUI adapter docs
2025-07-18 23:11:59 -04:00
thePR0M3TH3AN
c3e2ff4b4b docs: document GUI event subscriptions 2025-07-18 23:06:07 -04:00
thePR0M3TH3AN
c2512319ab Merge pull request #649 from PR0M3TH3AN/codex/update-gui-tests-for-new-entry-types
Add GUI entry type tests
2025-07-18 23:00:56 -04:00
thePR0M3TH3AN
fedd0c352a test: cover new gui entry types 2025-07-18 22:56:20 -04:00
thePR0M3TH3AN
4dbfde4bf5 Merge pull request #648 from PR0M3TH3AN/codex/add-totp-viewer-window-implementation
Add TOTP viewer window to GUI
2025-07-18 22:47:27 -04:00
thePR0M3TH3AN
c2809032fd Add TOTP viewer window 2025-07-18 22:41:53 -04:00
thePR0M3TH3AN
e508ff900a Merge pull request #647 from PR0M3TH3AN/codex/update-entry-creation-and-editing-in-app.py
Enable multiple entry types in GUI
2025-07-18 22:31:44 -04:00
thePR0M3TH3AN
ea579aaa5d GUI: support multiple entry types 2025-07-18 22:26:18 -04:00
thePR0M3TH3AN
3aa944bb49 Merge pull request #646 from PR0M3TH3AN/codex/update-readme-with-briefcase-installation-steps
Add briefcase packaging instructions
2025-07-18 22:16:29 -04:00
thePR0M3TH3AN
f31e2663b6 docs: add briefcase packaging section 2025-07-18 22:11:35 -04:00
thePR0M3TH3AN
a6e18ae9c5 update 2025-07-18 21:56:23 -04:00
thePR0M3TH3AN
c6d4b937cb Merge pull request #645 from PR0M3TH3AN/codex/create-github-actions-workflow-for-tags
Add Briefcase workflow
2025-07-18 21:14:22 -04:00
thePR0M3TH3AN
615be7d325 Add Briefcase build workflow and update README 2025-07-18 18:01:05 -04:00
thePR0M3TH3AN
8561e68d36 Merge pull request #644 from PR0M3TH3AN/codex/implement-relaymanagerdialog-and-other-updates
Add relay manager and session lock handling
2025-07-18 17:43:28 -04:00
thePR0M3TH3AN
5fce7836d9 Remove binary docs images 2025-07-18 17:38:07 -04:00
thePR0M3TH3AN
3502e34428 Merge pull request #643 from PR0M3TH3AN/codex/implement-observer-pattern-in-pubsub-module
Add event bus and sync progress events
2025-07-18 17:06:24 -04:00
thePR0M3TH3AN
724c0b883f Add pubsub event system and integrate sync notifications 2025-07-18 17:02:05 -04:00
thePR0M3TH3AN
20dfc35f7e Merge pull request #642 from PR0M3TH3AN/codex/implement-export-and-import-profile-commands
Add vault profile export/import feature
2025-07-18 16:12:34 -04:00
thePR0M3TH3AN
29690d7c7b Add vault profile export/import 2025-07-18 16:05:00 -04:00
thePR0M3TH3AN
876d98a785 Merge pull request #641 from PR0M3TH3AN/codex/implement-authguard-and-inactivity-lock
Add inactivity lock guard and CLI lock command
2025-07-18 15:52:31 -04:00
thePR0M3TH3AN
11b3707087 Add AuthGuard for inactivity locking and CLI lock command 2025-07-18 15:39:19 -04:00
thePR0M3TH3AN
d529877888 Merge pull request #640 from PR0M3TH3AN/codex/modify-entry-listing-with-sort-and-filter
Update entry listing options
2025-07-18 15:29:41 -04:00
thePR0M3TH3AN
b42ad0561c Add sorting options to entry listing 2025-07-18 15:22:26 -04:00
thePR0M3TH3AN
64664cb0bb Merge pull request #639 from PR0M3TH3AN/codex/extend-search_entries-functionality
Add kind filter to entry search
2025-07-18 15:04:26 -04:00
thePR0M3TH3AN
b0ba723bdd Extend entry search filtering 2025-07-18 14:54:10 -04:00
thePR0M3TH3AN
5eab7f879c Merge pull request #638 from PR0M3TH3AN/codex/implement-statemanager-and-update-relay-handling
Add StateManager for relay persistence
2025-07-18 14:41:19 -04:00
thePR0M3TH3AN
ddfe17b77b Add StateManager and relay CLI 2025-07-18 14:34:12 -04:00
thePR0M3TH3AN
8fe79a012b Merge pull request #637 from PR0M3TH3AN/codex/update-readme-and-cli-help-messages
Clarify secret mode clipboard handling
2025-07-18 13:57:46 -04:00
thePR0M3TH3AN
d71a4912bd docs: clarify secret mode clipboard behavior 2025-07-18 13:51:18 -04:00
thePR0M3TH3AN
98f841790a Merge pull request #636 from PR0M3TH3AN/codex/create-tests-for-handle_add_password
Add password manager test
2025-07-18 13:43:58 -04:00
thePR0M3TH3AN
d89fa7f707 Add test for handle_add_password 2025-07-18 13:31:42 -04:00
thePR0M3TH3AN
3d754b50f1 Merge pull request #635 from PR0M3TH3AN/codex/fix-install-script-failure-on-linux
Fix editable install
2025-07-18 13:15:03 -04:00
thePR0M3TH3AN
4491dd35df Add build-system section 2025-07-18 13:01:31 -04:00
thePR0M3TH3AN
e175e29813 Merge pull request #634 from PR0M3TH3AN/codex/fix-password-generation-issue-in-cli
Fix unlock flow without explicit password
2025-07-18 12:49:36 -04:00
thePR0M3TH3AN
a83001a799 Allow unlock without explicit password 2025-07-18 12:38:09 -04:00
thePR0M3TH3AN
c1f195d74f Merge pull request #633 from PR0M3TH3AN/codex/update-gui-section-in-documentation
Update GUI docs
2025-07-18 11:50:03 -04:00
thePR0M3TH3AN
edea2d3a6f docs: expand GUI start options 2025-07-18 11:45:22 -04:00
thePR0M3TH3AN
462cef20c3 Merge pull request #632 from PR0M3TH3AN/codex/remove-src/run_gui.py-and-verify
Remove unused run_gui.py
2025-07-18 11:37:19 -04:00
thePR0M3TH3AN
726e88de19 Remove unused run_gui script 2025-07-18 11:30:39 -04:00
thePR0M3TH3AN
29243a37b8 Merge pull request #631 from PR0M3TH3AN/codex/extend-pyproject.toml-for-seedpass-gui
Add Briefcase packaging config
2025-07-18 11:17:56 -04:00
thePR0M3TH3AN
66dfb9d205 Add Briefcase config and update docs 2025-07-18 11:11:13 -04:00
thePR0M3TH3AN
c83dab7793 Merge pull request #630 from PR0M3TH3AN/codex/add-typer-command-for-beeware-gui
Add GUI command
2025-07-18 11:03:09 -04:00
thePR0M3TH3AN
7ef7246361 Add GUI command 2025-07-18 10:57:00 -04:00
thePR0M3TH3AN
b8a2e29f21 Merge pull request #629 from PR0M3TH3AN/codex/create-__main__.py-for-seedpass_gui
Add seedpass_gui module entry point
2025-07-18 10:49:12 -04:00
thePR0M3TH3AN
37c78f608a Add module entry point 2025-07-18 10:39:52 -04:00
thePR0M3TH3AN
c8d09d6294 Merge pull request #628 from PR0M3TH3AN/codex/update-documentation-for-gui-usage
Add GUI getting started docs
2025-07-18 09:54:47 -04:00
thePR0M3TH3AN
dcda452da8 Add GUI getting started docs 2025-07-18 09:48:13 -04:00
thePR0M3TH3AN
eb1ab7662d Merge pull request #627 from PR0M3TH3AN/codex/implement-seedpass-gui-with-services
Implement GUI app skeleton with headless tests
2025-07-18 09:39:54 -04:00
thePR0M3TH3AN
2874bf0f82 Install headless GUI dependencies 2025-07-18 08:59:41 -04:00
thePR0M3TH3AN
d93f47e3ee Add basic GUI windows and headless tests 2025-07-18 08:46:34 -04:00
thePR0M3TH3AN
bc2c22ac10 Merge pull request #626 from PR0M3TH3AN/codex/refactor-password-handling-in-manager.py
Refactor manager input arguments
2025-07-18 08:30:51 -04:00
thePR0M3TH3AN
d679d52b66 Refactor manager to accept provided credentials 2025-07-18 08:25:07 -04:00
thePR0M3TH3AN
ae26190928 Merge pull request #625 from PR0M3TH3AN/codex/create-architecture-documentation-with-chart
Add architecture overview documentation
2025-07-17 22:25:44 -04:00
thePR0M3TH3AN
474f2d134b Add architecture documentation 2025-07-17 22:25:34 -04:00
thePR0M3TH3AN
9976e5473f Merge pull request #624 from PR0M3TH3AN/codex/add-note-to-api-reference-and-reformat
Update API docs with GUI adapter note
2025-07-17 22:21:28 -04:00
thePR0M3TH3AN
df0279ac03 docs: reference gui adapter and core services 2025-07-17 22:21:09 -04:00
thePR0M3TH3AN
6512b2f501 Merge pull request #623 from PR0M3TH3AN/codex/edit-landing/index.html-to-add-mermaid-chart
Add modular chart to landing page
2025-07-17 22:12:21 -04:00
thePR0M3TH3AN
beb690ba72 landing: add modular architecture diagram 2025-07-17 22:11:58 -04:00
thePR0M3TH3AN
9ee1bfad17 Merge pull request #622 from PR0M3TH3AN/codex/add-documentation-for-gui-adapter-and-services
Add BeeWare GUI adapter docs
2025-07-17 22:04:13 -04:00
thePR0M3TH3AN
3ff9843750 docs: explain BeeWare adapter 2025-07-17 22:03:53 -04:00
thePR0M3TH3AN
450ff06d4e Merge pull request #621 from PR0M3TH3AN/codex/update-documentation-with-mermaid-diagram
Add platform diagram
2025-07-17 21:57:34 -04:00
thePR0M3TH3AN
728c4be6e1 docs: add architecture diagram 2025-07-17 21:57:22 -04:00
thePR0M3TH3AN
9f3a65b5b4 Merge pull request #620 from PR0M3TH3AN/codex/update-readme.md-with-architecture-overview
Add architecture overview
2025-07-17 21:50:35 -04:00
thePR0M3TH3AN
47ea11b533 docs: describe architecture 2025-07-17 21:50:18 -04:00
thePR0M3TH3AN
d14385c4d2 Merge pull request #619 from PR0M3TH3AN/codex/add-tests-for-seedpass.core-services
Add core service and CLI regression tests
2025-07-17 21:38:46 -04:00
thePR0M3TH3AN
cd0f9624ae Add service layer and CLI regression tests 2025-07-17 21:33:06 -04:00
thePR0M3TH3AN
51233dff9b Merge pull request #618 from PR0M3TH3AN/codex/add-run_gui.py-and-update-pyproject.toml
Add Briefcase doc and GUI script
2025-07-17 21:26:14 -04:00
thePR0M3TH3AN
fac31cd99f Add GUI runner and packaging docs 2025-07-17 21:21:05 -04:00
thePR0M3TH3AN
ecf8f4e722 Merge pull request #617 from PR0M3TH3AN/codex/add-toga-application-windows-and-functionality
Add basic Toga GUI
2025-07-17 21:15:02 -04:00
thePR0M3TH3AN
6c22e28512 Add basic Toga GUI 2025-07-17 21:09:12 -04:00
thePR0M3TH3AN
025fee3045 Merge pull request #616 from PR0M3TH3AN/codex/refactor-cli.py-to-use-api-methods
Refactor CLI to call core API services
2025-07-17 20:57:08 -04:00
thePR0M3TH3AN
5019c99c11 Refactor CLI to use service layer 2025-07-17 20:42:23 -04:00
thePR0M3TH3AN
e7021f480f Merge pull request #615 from PR0M3TH3AN/codex/convert-nostr-methods-to-async
Make vault sync async
2025-07-17 20:26:58 -04:00
thePR0M3TH3AN
6c54d4d8e3 Convert vault sync to async 2025-07-17 20:21:23 -04:00
thePR0M3TH3AN
f68adf9170 Merge pull request #614 from PR0M3TH3AN/codex/create-service-layer-for-password-manager
Add service layer for vault operations
2025-07-17 19:59:54 -04:00
thePR0M3TH3AN
72faee02b6 Add service layer and update CLI 2025-07-17 19:54:54 -04:00
thePR0M3TH3AN
6fca250856 Merge pull request #613 from PR0M3TH3AN/codex/refactor-input-handling-in-manager.py
Refactor password change and unlock
2025-07-17 19:42:46 -04:00
thePR0M3TH3AN
8bd9a75629 Parametrize password actions 2025-07-17 19:38:28 -04:00
thePR0M3TH3AN
7af92195c4 Merge pull request #612 from PR0M3TH3AN/usksil-codex/refactor-seedpass-structure-and-imports
Move password manager core
2025-07-17 19:26:44 -04:00
thePR0M3TH3AN
c23b2e4913 Refactor password manager modules 2025-07-17 19:21:10 -04:00
thePR0M3TH3AN
87149517d8 Merge pull request #611 from PR0M3TH3AN/codex/add-non-interactive-password-handling
Enable non-interactive vault unlock
2025-07-17 17:34:01 -04:00
thePR0M3TH3AN
c4bb8dfa64 Allow non-interactive unlock 2025-07-17 17:23:49 -04:00
thePR0M3TH3AN
0818408f48 Merge pull request #609 from PR0M3TH3AN/codex/add-tests-for-sync-and-vault-handling
Add Nostr sync edge case tests
2025-07-17 15:44:17 -04:00
thePR0M3TH3AN
576437223a Add tests for sync and snapshot functionality 2025-07-17 15:39:05 -04:00
thePR0M3TH3AN
e5858cba38 Merge pull request #608 from PR0M3TH3AN/codex/update-passwordmanager.sync_vault-return-format
Expose all event IDs when syncing vault
2025-07-17 15:30:40 -04:00
thePR0M3TH3AN
c787651899 Return all Nostr event IDs 2025-07-17 15:21:08 -04:00
thePR0M3TH3AN
f3c223a9a1 Merge pull request #607 from PR0M3TH3AN/codex/implement-initial-sync-in-passwordmanager
Introduce attempt_initial_sync
2025-07-17 15:10:29 -04:00
thePR0M3TH3AN
dfa85ad863 Add attempt_initial_sync and update sync logic 2025-07-17 15:00:38 -04:00
thePR0M3TH3AN
1f7c538015 Merge pull request #606 from PR0M3TH3AN/codex/add-fetch-chunks-with-retry-helper
Add nostr snapshot retries and fallback
2025-07-17 14:49:48 -04:00
thePR0M3TH3AN
8579cf7f3d Add snapshot retrieval retries and fallback 2025-07-17 14:43:04 -04:00
thePR0M3TH3AN
cca03b2f2e Merge pull request #605 from PR0M3TH3AN/codex/extend-chunkmeta-with-event_id-support
Support chunk event IDs in nostr snapshot
2025-07-17 14:25:21 -04:00
thePR0M3TH3AN
49a5329bf6 Add event_id tracking for Nostr chunks 2025-07-17 14:04:32 -04:00
thePR0M3TH3AN
28f27de8e8 Merge pull request #603 from PR0M3TH3AN/codex/fix-windows-workflow-error-oserror-22
Fix Windows workflow test hang
2025-07-17 11:01:10 -04:00
thePR0M3TH3AN
e4093d7334 Fix posix seed prompt test for Windows 2025-07-17 10:56:48 -04:00
thePR0M3TH3AN
b83ec2621e Fix Windows test hang by patching timed input 2025-07-17 10:48:01 -04:00
thePR0M3TH3AN
b3c7d796e1 Merge pull request #601 from PR0M3TH3AN/codex/mask-sensitive-data-with-asterisks
Use masked input for passwords
2025-07-17 10:18:41 -04:00
thePR0M3TH3AN
764631b8ba Use masked input for all sensitive prompts 2025-07-17 10:04:06 -04:00
thePR0M3TH3AN
09d1bf51fc Merge pull request #600 from PR0M3TH3AN/codex/update-readme-with-new-workflow-instructions
Update README with development workflow
2025-07-17 09:16:01 -04:00
thePR0M3TH3AN
d415eca8bd docs: outline development workflow 2025-07-17 09:13:07 -04:00
thePR0M3TH3AN
176ba6befd Merge pull request #599 from PR0M3TH3AN/codex/create-executable-build-documentation
Add PyInstaller spec and build instructions
2025-07-17 09:07:29 -04:00
thePR0M3TH3AN
87215a10cc Add PyInstaller spec and documentation 2025-07-17 09:06:54 -04:00
thePR0M3TH3AN
e0b253ea63 Merge pull request #598 from PR0M3TH3AN/codex/add-vendor-path-to-sys.path-in-main.py
Add vendor path to main
2025-07-17 08:48:26 -04:00
thePR0M3TH3AN
5826e18189 Add vendor directory to sys.path 2025-07-17 08:48:07 -04:00
thePR0M3TH3AN
9d5593a1f5 Merge pull request #597 from PR0M3TH3AN/codex/add-vendor_dependencies.sh-script
Add vendoring script
2025-07-17 08:39:36 -04:00
thePR0M3TH3AN
f7eaf2897f Add vendor dependencies script 2025-07-17 08:39:22 -04:00
thePR0M3TH3AN
96cad49e55 Merge pull request #596 from PR0M3TH3AN/codex/add-runtime_requirements.txt-for-packaging
Add packaging-only runtime requirements file
2025-07-17 08:33:38 -04:00
thePR0M3TH3AN
182085b639 Clarify runtime requirements comment 2025-07-17 08:33:23 -04:00
thePR0M3TH3AN
5fdfc7ca5a Merge pull request #595 from PR0M3TH3AN/codex/add-runtime_requirements.txt-for-packaging
Add runtime requirements file
2025-07-16 23:05:00 -04:00
thePR0M3TH3AN
97bdd2483d Add runtime requirements file 2025-07-16 22:09:58 -04:00
thePR0M3TH3AN
a3caf16dd4 Merge pull request #593 from PR0M3TH3AN/codex/update-key/value-label-to-indicate-key
Clarify key/value label
2025-07-16 20:31:29 -04:00
thePR0M3TH3AN
6336fb3fe4 Clarify key value label 2025-07-16 20:29:29 -04:00
thePR0M3TH3AN
f47baf4132 Merge pull request #592 from PR0M3TH3AN/codex/fix-workflow-failure-on-macos
Fix nondeterministic checksum test
2025-07-16 19:55:27 -04:00
thePR0M3TH3AN
30dd09b0b4 Fix BIP-85 checksum validation test 2025-07-16 19:38:32 -04:00
thePR0M3TH3AN
26f1ba4482 Merge pull request #589 from PR0M3TH3AN/codex/add-ci-test-script-and-update-workflow
Add run_ci_tests script and update workflow
2025-07-16 17:59:36 -04:00
thePR0M3TH3AN
479c034573 fix windows ci hangs 2025-07-16 17:50:08 -04:00
thePR0M3TH3AN
0741744f99 Handle missing timeout in CI script 2025-07-16 16:06:21 -04:00
thePR0M3TH3AN
b88a93df29 Add CI test script and update workflow 2025-07-16 15:57:33 -04:00
thePR0M3TH3AN
ae9e6ba0d4 Merge pull request #588 from PR0M3TH3AN/codex/modify-decryption-method-for-strict-handling
Improve Nostr sync failure handling
2025-07-16 15:24:20 -04:00
thePR0M3TH3AN
b80abff895 Handle decryption failures when syncing index 2025-07-16 15:19:48 -04:00
thePR0M3TH3AN
387bfad220 Merge pull request #587 from PR0M3TH3AN/codex/update-entrymanager-to-include-verbose-parameter
Add verbose flag for listing entries
2025-07-16 15:09:48 -04:00
thePR0M3TH3AN
073b8c4d47 Add verbose flag to list_entries and update archived view 2025-07-16 15:05:11 -04:00
thePR0M3TH3AN
e8ade741ad Merge pull request #585 from PR0M3TH3AN/codex/add-seed-entry-note-to-readme-and-docs
Update docs: clarify seed entry
2025-07-16 13:35:33 -04:00
thePR0M3TH3AN
ea9665383e docs: clarify hidden entry behaviour 2025-07-16 13:35:15 -04:00
thePR0M3TH3AN
e5a8dde59d Merge pull request #584 from PR0M3TH3AN/codex/modify-test_setup_existing_seed_words
Update seed setup test
2025-07-16 12:57:36 -04:00
thePR0M3TH3AN
78368c0e2f Fix seed setup test to patch masked input 2025-07-16 12:57:19 -04:00
thePR0M3TH3AN
c6398b3c99 Merge pull request #583 from PR0M3TH3AN/codex/update-test-cases-for-seed_prompt
Update seed prompt tests
2025-07-16 12:50:09 -04:00
thePR0M3TH3AN
6b7815f28e Update seed prompt tests 2025-07-16 12:48:56 -04:00
thePR0M3TH3AN
07f1843739 Merge pull request #582 from PR0M3TH3AN/codex/refactor-seed_prompt.py-for-masked-input
Improve seed input flow
2025-07-16 12:43:36 -04:00
thePR0M3TH3AN
04548c44f5 Enhance seed entry prompts with masking and clear screen 2025-07-16 12:37:58 -04:00
thePR0M3TH3AN
144447fb3d Merge pull request #581 from PR0M3TH3AN/codex/update-add_new_fingerprint-prompt
Refine seed profile setup prompt
2025-07-16 12:02:12 -04:00
thePR0M3TH3AN
6f21c5cb9d Update profile management test for new seed prompt 2025-07-16 11:58:19 -04:00
thePR0M3TH3AN
a610272552 Update seed profile creation prompt 2025-07-16 11:25:56 -04:00
thePR0M3TH3AN
76bdd4fde0 Merge pull request #580 from PR0M3TH3AN/codex/add-tests-for-seed-validation-and-setup
Add seed setup tests
2025-07-16 06:20:17 -04:00
thePR0M3TH3AN
cc68f05130 test: seed setup interactions 2025-07-16 04:16:36 -04:00
thePR0M3TH3AN
1e5d115f80 Merge pull request #579 from PR0M3TH3AN/codex/update-seed-profile-instructions-in-docs
Update seed profile instructions
2025-07-16 04:11:15 -04:00
thePR0M3TH3AN
0a011f108b docs: clarify seed profile setup 2025-07-16 04:10:57 -04:00
thePR0M3TH3AN
54aa609b62 Merge pull request #578 from PR0M3TH3AN/codex/add-method-parameter-to-setup_existing_seed
Add method param for seed setup
2025-07-16 04:06:13 -04:00
thePR0M3TH3AN
f701124fb1 Add method parameter to seed setup 2025-07-16 04:04:13 -04:00
thePR0M3TH3AN
8370dec5c3 Merge pull request #577 from PR0M3TH3AN/codex/update-seed-setup-prompt-and-routing
Add word-by-word seed setup option
2025-07-16 03:58:24 -04:00
thePR0M3TH3AN
6cca270bd6 Update seed setup prompt with word-by-word option 2025-07-16 03:51:32 -04:00
thePR0M3TH3AN
73898972f1 Merge pull request #576 from PR0M3TH3AN/codex/implement-word-prompt-helper-function
Implement seed phrase entry helper
2025-07-16 03:43:00 -04:00
thePR0M3TH3AN
04dc4e05da Add interactive seed word prompt 2025-07-16 03:40:24 -04:00
thePR0M3TH3AN
9369bac70f Merge pull request #575 from PR0M3TH3AN/codex/add-masked-input-function-with-tests
Add masked input utility
2025-07-16 03:34:03 -04:00
thePR0M3TH3AN
d7547810fe Add cross-platform masked input utility with tests 2025-07-16 03:32:06 -04:00
thePR0M3TH3AN
bceaa99228 Merge pull request #574 from PR0M3TH3AN/codex/update-validate_bip85_seed-validation-logic
Improve BIP‑85 seed validation
2025-07-16 03:24:49 -04:00
thePR0M3TH3AN
f46de144a9 Validate BIP-85 seeds using Mnemonic 2025-07-16 03:22:47 -04:00
thePR0M3TH3AN
23f672575e Merge pull request #573 from PR0M3TH3AN/codex/add-optional-customization-for-seed-profile
Add custom Seed Profile names
2025-07-15 21:58:28 -04:00
thePR0M3TH3AN
113fd1181a Add custom seed profile names 2025-07-15 21:56:31 -04:00
thePR0M3TH3AN
40bd009b6e Merge pull request #572 from PR0M3TH3AN/beta
update
2025-07-15 15:33:46 -04:00
thePR0M3TH3AN
bcb38ce79f update 2025-07-15 15:33:20 -04:00
thePR0M3TH3AN
d831f1b1a2 Merge pull request #571 from PR0M3TH3AN/beta
Beta
2025-07-15 15:32:54 -04:00
thePR0M3TH3AN
dbd051a1b0 Merge pull request #570 from PR0M3TH3AN/codex/remove-duplicate-sensitive-info-check
Remove redundant prompt for sensitive info
2025-07-15 15:10:03 -04:00
thePR0M3TH3AN
27d8b8ffa1 Remove redundant sensitive info prompt 2025-07-15 15:07:39 -04:00
thePR0M3TH3AN
bfc0331057 Merge pull request #569 from PR0M3TH3AN/codex/edit-passwordmanager-handle_retrieve_entry
Fix retrieve entry pause order
2025-07-15 14:01:53 -04:00
thePR0M3TH3AN
503159ff6d Adjust retrieve entry flow 2025-07-15 14:00:05 -04:00
thePR0M3TH3AN
22cc302288 Merge pull request #568 from PR0M3TH3AN/beta
Beta
2025-07-15 13:09:02 -04:00
thePR0M3TH3AN
d10f5288c3 Merge pull request #567 from PR0M3TH3AN/codex/fix-entry_type-handling-in-password-manager
Fix entry type enum handling
2025-07-15 12:49:00 -04:00
thePR0M3TH3AN
5a3b80b4f6 Handle EntryType objects when loading 2025-07-15 12:47:01 -04:00
thePR0M3TH3AN
0bfc641815 Merge pull request #566 from PR0M3TH3AN/codex/update-documentation-for-entry-details-handling
Update docs for entry detail screen
2025-07-15 12:30:08 -04:00
thePR0M3TH3AN
3e004d3932 docs: describe entry detail view 2025-07-15 12:19:15 -04:00
thePR0M3TH3AN
2705adf90b Merge pull request #565 from PR0M3TH3AN/codex/add-parameterized-tests-for-entry-types
Add parameterized sensitive entry display test
2025-07-15 11:49:59 -04:00
thePR0M3TH3AN
dfa560a270 Add parameterized sensitive entry display test 2025-07-15 11:45:54 -04:00
thePR0M3TH3AN
d4b3db7386 Merge pull request #564 from PR0M3TH3AN/codex/update-display_entry_details-for-fingerprints
Add fingerprint display improvements
2025-07-15 11:18:55 -04:00
thePR0M3TH3AN
754dce086c Show more fingerprints 2025-07-15 11:17:13 -04:00
thePR0M3TH3AN
ca67cf1f92 Merge pull request #563 from PR0M3TH3AN/codex/modify-show_entry_details_by_index
Add sensitive info confirmation to entry detail view
2025-07-15 11:05:12 -04:00
thePR0M3TH3AN
3de84ec484 feat: prompt sensitive view in entry details 2025-07-15 11:02:21 -04:00
thePR0M3TH3AN
2eae65872f Merge pull request #562 from PR0M3TH3AN/codex/refactor-handle_retrieve_entry-method
Streamline retrieval flow
2025-07-15 10:56:01 -04:00
thePR0M3TH3AN
6d24ffb2ec refactor: streamline retrieve flow 2025-07-15 10:53:50 -04:00
thePR0M3TH3AN
e52e2629fe Merge pull request #561 from PR0M3TH3AN/codex/add-display_sensitive_entry_info-method
Refactor retrieval display logic
2025-07-15 10:45:34 -04:00
thePR0M3TH3AN
3bcf3312df refactor: extract sensitive entry display helper 2025-07-15 10:43:06 -04:00
thePR0M3TH3AN
a61a064d2e Merge pull request #560 from PR0M3TH3AN/codex/update-display_entry_details-method
Improve entry detail display
2025-07-15 09:00:42 -04:00
thePR0M3TH3AN
fffd287032 Enhance entry detail display and tests 2025-07-15 08:58:26 -04:00
thePR0M3TH3AN
b8e6ae3e36 Merge pull request #559 from PR0M3TH3AN/qechl7-codex/update-show_entry_details_by_index-method
Add pause between entry details and action menu
2025-07-14 22:36:03 -04:00
thePR0M3TH3AN
4d559d0339 pause after showing entry details 2025-07-14 22:34:04 -04:00
thePR0M3TH3AN
bdf83fabd8 Merge pull request #558 from PR0M3TH3AN/codex/update-show_entry_details_by_index-method
Improve entry details workflow
2025-07-14 22:21:51 -04:00
thePR0M3TH3AN
8fca2b3346 Add entry details workflow and tests 2025-07-14 22:16:54 -04:00
thePR0M3TH3AN
1b4e4773f1 Merge pull request #556 from PR0M3TH3AN/8xl3f9-codex/fix-missing-password-entry-type
Fix entry details pause
2025-07-14 21:54:49 -04:00
thePR0M3TH3AN
d4bcc7e726 Merge branch 'beta' into 8xl3f9-codex/fix-missing-password-entry-type 2025-07-14 21:53:00 -04:00
thePR0M3TH3AN
0b4eec55a0 Pause after displaying entry details 2025-07-14 21:46:31 -04:00
thePR0M3TH3AN
31265edc69 Merge pull request #553 from PR0M3TH3AN/codex/fix-missing-password-entry-type
Fix entry detail display
2025-07-14 21:27:25 -04:00
thePR0M3TH3AN
c946f30258 Fix entry details display 2025-07-14 21:07:05 -04:00
thePR0M3TH3AN
7ececbccbb Merge pull request #552 from PR0M3TH3AN/codex/fix-qr-code-display-issue
Fix QR menu disappearing
2025-07-14 20:51:13 -04:00
thePR0M3TH3AN
000a607bbc fix qr menu pause 2025-07-14 20:45:17 -04:00
thePR0M3TH3AN
984e61de8f Merge pull request #551 from PR0M3TH3AN/codex/fix-qr-code-key-display-options
Fix QR option visibility for case-insensitive entry types
2025-07-14 20:28:14 -04:00
thePR0M3TH3AN
513f6df459 Fix QR menu option case handling 2025-07-14 20:26:17 -04:00
thePR0M3TH3AN
3719797013 Merge pull request #550 from PR0M3TH3AN/codex/fix-entry-clearing-and-qr-code-options
Fix retrieval display clearing and show QR actions
2025-07-14 18:22:13 -04:00
thePR0M3TH3AN
db85caceda fix retrieval screen clearing and add pause tests 2025-07-14 18:15:37 -04:00
thePR0M3TH3AN
41cf6830a8 Update README.md 2025-07-14 17:41:29 -04:00
300 changed files with 23382 additions and 5020 deletions

10
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

29
.github/workflows/briefcase.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Build GUI
on:
push:
tags:
- 'seedpass-gui*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pip-tools briefcase
pip-compile --generate-hashes --output-file=requirements.lock src/requirements.txt
git diff --exit-code requirements.lock
pip install --require-hashes -r requirements.lock
- name: Build with Briefcase
run: briefcase build
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: seedpass-gui
path: dist/**

27
.github/workflows/dependency-audit.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Dependency Audit
on:
schedule:
- cron: '0 0 * * 0'
workflow_dispatch:
permissions:
contents: read
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pip-tools pip-audit
pip-compile --generate-hashes --output-file=requirements.lock src/requirements.txt
git diff --exit-code requirements.lock
pip install --require-hashes -r requirements.lock
- name: Run pip-audit
run: pip-audit -r requirements.lock --ignore-vuln GHSA-wj6h-64fc-37mp

View File

@@ -9,6 +9,20 @@ on:
- cron: '0 3 * * *'
jobs:
secret-scan:
name: Secret Scan
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' || github.event_name == 'schedule'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_CONFIG: .gitleaks.toml
build:
strategy:
matrix:
@@ -59,18 +73,18 @@ jobs:
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('src/requirements.txt') }}
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.lock') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Set up Python dependencies
id: deps
- name: Verify lockfile and install dependencies
run: |
python -m pip install --upgrade pip
pip install -r src/requirements.txt
- name: Run pip-audit
run: |
pip install pip-audit
pip-audit -r requirements.lock
pip install pip-tools
pip-compile --generate-hashes --output-file=requirements.lock src/requirements.txt
git diff --exit-code requirements.lock
pip install --require-hashes -r requirements.lock
- name: Run dependency scan
run: scripts/dependency_scan.sh --ignore-vuln GHSA-wj6h-64fc-37mp
- name: Determine stress args
shell: bash
run: |
@@ -81,10 +95,27 @@ jobs:
if: github.ref == 'refs/heads/main' || github.event_name == 'schedule'
run: echo "NOSTR_E2E=1" >> $GITHUB_ENV
- name: Run tests with coverage
timeout-minutes: 16
shell: bash
run: |
pytest ${STRESS_ARGS} --cov=src --cov-report=xml --cov-report=term-missing \
--cov-fail-under=20 src/tests
run: scripts/run_ci_tests.sh
- name: Run desktop tests
timeout-minutes: 10
shell: bash
env:
TOGA_BACKEND: toga_dummy
run: scripts/run_gui_tests.sh
- name: Upload pytest log
if: always()
uses: actions/upload-artifact@v4
with:
name: pytest-log-${{ matrix.os }}
path: pytest.log
- name: Upload GUI pytest log
if: always()
uses: actions/upload-artifact@v4
with:
name: gui-pytest-log-${{ matrix.os }}
path: pytest_gui.log
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:

40
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Tests
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install Poetry
run: pipx install poetry
- name: Install dependencies
run: poetry install
- name: Check formatting
run: poetry run black --check .
- name: Run security audit
run: |
poetry run pip-audit || echo "::warning::pip-audit found vulnerabilities"
shell: bash
- name: Run tests with coverage
run: |
poetry run coverage run -m pytest
poetry run coverage xml
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.os }}-py${{ matrix.python-version }}
path: coverage.xml

5
.gitignore vendored
View File

@@ -40,4 +40,7 @@ src/seedpass.egg-info/PKG-INFO
src/seedpass.egg-info/SOURCES.txt
src/seedpass.egg-info/dependency_links.txt
src/seedpass.egg-info/entry_points.txt
src/seedpass.egg-info/top_level.txt
src/seedpass.egg-info/top_level.txt
# Allow vendored dependencies to be committed
!src/vendor/

8
.gitleaks.toml Normal file
View File

@@ -0,0 +1,8 @@
title = "SeedPass gitleaks config"
[allowlist]
description = "Paths and patterns to ignore when scanning for secrets"
# Add file paths that contain test data or other non-sensitive strings
paths = []
# Add regular expressions that match false positive secrets
regexes = []

View File

@@ -2,6 +2,60 @@
This project is written in **Python**. Follow these instructions when working with the code base.
## Installation Quickstart for AI Agents
### Prerequisites
Ensure the system has the required build tools and Python headers. Examples:
```bash
# Ubuntu/Debian
sudo apt update && sudo apt install -y \
build-essential \
libffi-dev \
pkg-config \
python3.11-dev \
curl \
git
# CentOS/RHEL
sudo yum install -y gcc gcc-c++ libffi-devel pkgconfig python3-devel curl git
# macOS
brew install python@3.11 libffi pkg-config git
```
### Installation
Run the installer script to fetch the latest release:
```bash
# Stable release
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)"
# Beta branch
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)" _ -b beta
```
### Environment Layout
- Virtual environment: `~/.seedpass/app/venv/`
- Entry point: `~/.seedpass/app/src/main.py`
### Verification
```bash
cd ~/.seedpass/app && source venv/bin/activate
cd src && python main.py --version # Expected: SeedPass v[version]
```
### Running SeedPass
```bash
cd ~/.seedpass/app && source venv/bin/activate
cd src && python main.py
```
## Running Tests
1. Set up a virtual environment and install dependencies:
@@ -9,7 +63,7 @@ This project is written in **Python**. Follow these instructions when working wi
```bash
python3 -m venv venv
source venv/bin/activate
pip install -r src/requirements.txt
pip install --require-hashes -r requirements.lock
```
2. Run the test suite using **pytest**:
@@ -39,6 +93,19 @@ This project is written in **Python**. Follow these instructions when working wi
Following these practices helps keep the code base consistent and secure.
## Deterministic Artifact Generation
- All generated artifacts (passwords, keys, TOTP secrets, etc.) must be fully deterministic across runs and platforms.
- Randomness is only permitted for security primitives (e.g., encryption nonces, in-memory keys) and must never influence derived artifacts.
## Legacy Index Migration
- Always provide a migration path for index archives and import/export routines.
- Support older SeedPass versions whose indexes lacked salts or password-based encryption by detecting legacy formats and upgrading them to the current schema.
- Ensure migrations unlock older account indexes and allow Nostr synchronization.
- Add regression tests covering these migrations whenever the index format or encryption changes.
## Integrating New Entry Types
SeedPass supports multiple `kind` values in its JSON entry files. When adding a

385
README.md
View File

@@ -12,6 +12,14 @@
This software was not developed by an experienced security expert and should be used with caution. There may be bugs and missing features. Each vault chunk is limited to 50 KB and SeedPass periodically publishes a new snapshot to keep accumulated deltas small. The security of the program's memory management and logs has not been evaluated and may leak sensitive information. Loss or exposure of the parent seed places all derived passwords, accounts, and other artifacts at risk.
**🚨 Breaking Change**
Recent releases derive passwords and other artifacts using a fully deterministic algorithm that behaves consistently across Python versions. This improvement means artifacts generated with earlier versions of SeedPass will not match those produced now. Regenerate any previously derived data or retain the old version if you need to reproduce older passwords or keys.
**⚠️ 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. 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
@@ -21,17 +29,23 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
## Table of Contents
- [Features](#features)
- [Architecture Overview](#architecture-overview)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [1. Clone the Repository](#1-clone-the-repository)
- [2. Create a Virtual Environment](#2-create-a-virtual-environment)
- [3. Activate the Virtual Environment](#3-activate-the-virtual-environment)
- [4. Install Dependencies](#4-install-dependencies)
- [Optional GUI](#optional-gui)
- [Usage](#usage)
- [Running the Application](#running-the-application)
- [Managing Multiple Seeds](#managing-multiple-seeds)
- [Additional Entry Types](#additional-entry-types)
- [Recovery](#recovery)
- [Building a standalone executable](#building-a-standalone-executable)
- [Packaging with Briefcase](#packaging-with-briefcase)
- [Security Considerations](#security-considerations)
- [Dependency Updates](#dependency-updates)
- [Contributing](#contributing)
- [License](#license)
- [Contact](#contact)
@@ -40,7 +54,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
- **Deterministic Password Generation:** Utilize BIP-85 for generating deterministic and secure passwords.
- **Encrypted Storage:** All seeds, login passwords, and sensitive index data are encrypted locally.
- **Nostr Integration:** Post and retrieve your encrypted password index to/from the Nostr network.
- **Nostr Integration:** Post and retrieve your encrypted password index to/from the Nostr network. See [Nostr Setup](docs/nostr_setup.md) for relay configuration and event details.
- **Chunked Snapshots:** Encrypted vaults are compressed and split into 50 KB chunks published as `kind 30071` events with a `kind 30070` manifest and `kind 30072` deltas. The manifest's `delta_since` field stores the UNIX timestamp of the latest delta event.
- **Automatic Checksum Generation:** The script generates and verifies a SHA-256 checksum to detect tampering.
- **Multiple Seed Profiles:** Manage separate seed profiles and switch between them seamlessly.
@@ -53,8 +67,9 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
- **Optional External Backup Location:** Configure a second directory where backups are automatically copied.
- **Auto-Lock on Inactivity:** Vault locks after a configurable timeout for additional security.
- **Quick Unlock:** Optionally skip the password prompt after verifying once.
- **Secret Mode:** Copy retrieved passwords directly to your clipboard and automatically clear it after a delay.
- **Secret Mode:** When enabled, newly generated and retrieved passwords are copied to your clipboard and automatically cleared after a delay.
- **Tagging Support:** Organize entries with optional tags and find them quickly via search.
- **Typed Search Results:** Results now display each entry's type for quicker identification.
- **Manual Vault Export/Import:** Create encrypted backups or restore them using the CLI or API.
- **Parent Seed Backup:** Securely save an encrypted copy of the master seed.
- **Manual Vault Locking:** Instantly clear keys from memory when needed.
@@ -64,9 +79,40 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
- **Relay Management:** List, add, remove or reset configured Nostr relays.
- **Offline Mode:** Disable all Nostr communication for local-only operation.
A small on-screen notification area now shows queued messages for 10 seconds
before fading.
## Architecture Overview
SeedPass follows a layered design. The **`seedpass.core`** package exposes the
`PasswordManager` along with service classes (e.g. `VaultService` and
`EntryService`) that implement the main API used across interfaces. Both the
command line tool in **`seedpass.cli`** and the FastAPI server in
**`seedpass.api`** delegate operations to this core. The BeeWare desktop
interface (`seedpass_gui.app`) and an optional browser extension reuse these
services, with the extension communicating through the API layer.
Nostr synchronisation lives in the **`nostr`** modules. The core services call
into these modules to publish or retrieve encrypted snapshots and deltas from
configured relays.
```mermaid
graph TD
cli["CLI"]
api["FastAPI server"]
core["seedpass.core"]
nostr["Nostr client"]
relays["Nostr relays"]
cli --> core
api --> core
core --> nostr
nostr --> relays
```
See `docs/ARCHITECTURE.md` and [Nostr Setup](docs/nostr_setup.md) for details.
## Prerequisites
- **Python 3.8+** (3.11 or 3.12 recommended): Install Python from [python.org](https://www.python.org/downloads/) and be sure to check **"Add Python to PATH"** during setup. Using Python 3.13 is currently discouraged because some dependencies do not ship wheels for it yet, which can cause build failures on Windows unless you install the Visual C++ Build Tools.
@@ -77,24 +123,55 @@ before fading.
### Quick Installer
Use the automated installer to download SeedPass and its dependencies in one step.
The default `tui` mode installs only the text interface, so it runs headlessly and works well in CI or other automation. GUI backends are optional and must be explicitly requested (`--mode gui` or `--mode both` on Linux/macOS, `-IncludeGui` on Windows). If the GTK `gi` bindings are missing, the installer attempts to install the
necessary system packages using `apt`, `yum`, `pacman`, or Homebrew. When no display server is detected, GUI components are skipped automatically.
**Linux and macOS:**
```bash
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)"
# TUI-only/agent install (headless default)
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)" _ --mode tui
```
*Install the beta branch:*
```bash
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)" _ -b beta
```
Make sure the command ends right after `-b beta` with **no trailing parenthesis**.
*Install with GUI support:*
```bash
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)" _ --mode gui
```
**Windows (PowerShell):**
```powershell
# TUI-only/agent install (default)
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; $scriptContent = (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.ps1'); & ([scriptblock]::create($scriptContent))
```
*Install with the optional GUI:*
```powershell
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)) -IncludeGui
```
Before running the script, install **Python 3.11** or **3.12** from [python.org](https://www.python.org/downloads/windows/) and tick **"Add Python to PATH"**. You should also install the [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the **C++ build tools** workload so dependencies compile correctly.
The Windows installer will attempt to install Git automatically if it is not already available. It also tries to install Python 3 using `winget`, `choco`, or `scoop` when Python is missing and recognizes the `py` launcher if `python` isn't on your PATH. If these tools are unavailable you'll see a link to download Python directly from <https://www.python.org/downloads/windows/>. When Python 3.13 or newer is detected without the Microsoft C++ build tools, the installer now attempts to download Python 3.12 automatically so you don't have to compile packages from source.
**Note:** If this fallback fails, install Python 3.12 manually or install the [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and rerun the installer.
#### Installer Dependency Checks
The installer verifies that core build tooling—C/C++ build tools, Rust, CMake, and the imaging/GTK libraries—are available before completing. Use `--mode gui` to install only the graphical interface or `--mode both` to install both interfaces (default: `tui`). On Linux, ensure `xclip` or `wl-clipboard` is installed for clipboard support.
#### Windows Nostr Sync Troubleshooting
When backing up or restoring from Nostr on Windows, a few issues are common:
* **Event loop errors** Messages like `RuntimeError: Event loop is closed` usually mean the async runtime failed to initialize. Running SeedPass with `--verbose` provides more detail about which coroutine failed.
* **Permission problems** If you see `Access is denied` when writing to `~/.seedpass`, launch your terminal with "Run as administrator" so the app can create files in your profile directory.
* **Missing dependencies** Ensure `websockets` and other requirements are installed inside your virtual environment:
```bash
pip install websockets
```
Using increased log verbosity helps diagnose sync issues and confirm that the WebSocket connections to your configured relays succeed.
### Uninstall
Run the matching uninstaller if you need to remove a previous installation or clean up an old `seedpass` command:
@@ -142,7 +219,7 @@ Follow these steps to set up SeedPass on your local machine.
```bash
python -m pip install --upgrade pip
python -m pip install -r src/requirements.txt
python -m pip install --require-hashes -r requirements.lock
python -m pip install -e .
```
// 🔧 merged conflicting changes from codex/locate-command-usage-issue-in-seedpass vs beta
@@ -150,10 +227,57 @@ After reinstalling, run `which seedpass` on Linux/macOS or `where seedpass` on W
#### Linux Clipboard Support
On Linux, `pyperclip` relies on external utilities like `xclip` or `xsel`. SeedPass will attempt to install **xclip** automatically if neither tool is available. If the automatic installation fails, you can install it manually:
On Linux, `pyperclip` relies on external utilities like `xclip` or `xsel`. SeedPass no longer installs these tools automatically. To enable clipboard features such as secret mode, install **xclip** manually:
```bash
sudo apt-get install xclip
sudo apt install xclip
```
After installing `xclip`, restart SeedPass to enable clipboard support.
### Optional GUI
SeedPass ships with a GTK-based desktop interface that is still in development
and not currently functional. GUI backends are optional—run the installer with
`--mode gui` or install the Python extras below to add them. Install the packages
for your platform before adding the Python GUI dependencies.
- **Debian/Ubuntu**
```bash
sudo apt install libgirepository1.0-dev libcairo2-dev libpango1.0-dev libwebkit2gtk-4.0-dev
```
- **Fedora**
```bash
sudo dnf install gobject-introspection-devel cairo-devel pango-devel webkit2gtk4.0-devel
```
- **Arch Linux**
```bash
sudo pacman -S gobject-introspection cairo pango webkit2gtk
```
- **macOS (Homebrew)**
```bash
brew install pygobject3 gtk+3 adwaita-icon-theme librsvg webkitgtk
```
With the system requirements in place, install the Python GUI extras for your
platform:
```bash
# Linux
pip install .[gui-gtk]
# Windows
pip install .[gui-win]
# macOS
pip install .[gui-mac]
```
CLI-only users can skip these steps and install just the core package for a
lightweight, headless setup compatible with CI/automation:
```bash
pip install .
```
## Quick Start
@@ -172,15 +296,16 @@ You can then launch SeedPass and create a backup:
seedpass
# Export your index
seedpass export --file "~/seedpass_backup.json"
seedpass vault export --file "~/seedpass_backup.json"
# Later you can restore it
seedpass import --file "~/seedpass_backup.json"
seedpass vault import --file "~/seedpass_backup.json"
# Quickly find or retrieve entries
seedpass search "github"
seedpass search --tags "work,personal"
seedpass get "github"
# Search results show the entry type, e.g. "1: Password - GitHub"
# Retrieve a TOTP entry
seedpass entry get "email"
# The code is printed and copied to your clipboard
@@ -188,6 +313,8 @@ seedpass entry get "email"
# Sort or filter the list view
seedpass list --sort label
seedpass list --filter totp
# Generate a password with the safe character set defined by `SAFE_SPECIAL_CHARS`
seedpass util generate-password --length 20 --special-mode safe --exclude-ambiguous
# Use the **Settings** menu to configure an extra backup directory
# on an external drive.
@@ -195,6 +322,59 @@ seedpass list --filter totp
For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md). Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md).
### Getting Started with the GUI
SeedPass also ships with a simple BeeWare desktop interface. Launch it from
your virtual environment using any of the following commands:
```bash
seedpass gui
python -m seedpass_gui
seedpass-gui
```
GUI dependencies are optional. Install them alongside SeedPass with the
extra for your platform:
```bash
# Linux
pip install "seedpass[gui-gtk]"
# Windows
pip install "seedpass[gui-win]"
# macOS
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.
```mermaid
graph TD
core["seedpass.core"]
cli["CLI"]
api["FastAPI server"]
gui["BeeWare GUI"]
ext["Browser Extension"]
cli --> core
gui --> core
api --> core
ext --> api
```
### Vault JSON Layout
The encrypted index file `seedpass_entries_db.json.enc` begins with `schema_version` `2` and stores an `entries` map keyed by entry numbers.
@@ -272,6 +452,16 @@ For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). Th
```
*(or `python src/main.py` when running directly from the repository)*
To restore a previously backed up index at launch, provide the backup path
and fingerprint:
```bash
seedpass --restore-backup /path/to/backup.json.enc --fingerprint <fp>
```
Without the flag, the startup prompt offers a **Restore from backup** option
before the vault is initialized.
2. **Follow the Prompts:**
- **Seed Profile Selection:** If you have existing seed profiles, you'll be prompted to select one or add a new one.
@@ -304,6 +494,15 @@ When choosing **Add Entry**, you can now select from:
- **Key/Value**
- **Managed Account**
### Adding a Password Entry
After selecting **Password**, SeedPass asks you to pick a mode:
1. **Quick** prompts only for a label, username, URL, desired length, and whether to include special characters. Default values are used for notes, tags, and policy settings.
2. **Advanced** walks through the full set of prompts for notes, tags, custom fields, and detailed password policy options.
Both modes generate the password, display it (or copy it to the clipboard in Secret Mode), and save the entry to your encrypted vault.
### Adding a 2FA Entry
1. From the main menu choose **Add Entry** and select **2FA (TOTP)**.
@@ -325,11 +524,20 @@ When choosing **Add Entry**, you can now select from:
### Using Secret Mode
When **Secret Mode** is enabled, SeedPass copies retrieved passwords directly to your clipboard instead of displaying them on screen. The clipboard clears automatically after the delay you choose.
When **Secret Mode** is enabled, SeedPass copies newly generated and retrieved passwords directly to your clipboard instead of displaying them on screen. The clipboard clears automatically after the delay you choose.
1. From the main menu open **Settings** and select **Toggle Secret Mode**.
2. Choose how many seconds to keep passwords on the clipboard.
3. Retrieve an entry and SeedPass will confirm the password was copied.
3. Generate or retrieve an entry and SeedPass will confirm the password was copied.
### Viewing Entry Details
Selecting an item from **List Entries** or **Search Entries** first displays the
entry's metadata such as the label, username, tags and notes. Passwords, seed
phrases and other sensitive fields remain hidden until you choose to reveal
them. When you opt to show the secret, the details view presents the same action
menu as **Retrieve Entry** so you can edit, archive or display QR codes for the
entry.
### Additional Entry Types
@@ -351,7 +559,7 @@ The table below summarizes the extra fields stored for each entry type. Every en
| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes`, optional `tags` |
| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes`, optional `tags` |
| Nostr Key Pair | `index`, `archived`, optional `notes`, optional `tags` |
| Key/Value | `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` |
| Key/Value | `key`, `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` |
| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes`, optional `tags` |
### Managing Multiple Seeds
@@ -360,8 +568,9 @@ SeedPass allows you to manage multiple seed profiles (previously referred to as
- **Add a New Seed Profile:**
1. From the main menu, select **Settings** then **Profiles** and choose "Add a New Seed Profile".
2. Choose to enter an existing seed or generate a new one.
3. If generating a new seed, you'll be provided with a 12-word BIP-85 seed phrase. **Ensure you write this down and store it securely.**
2. Choose to paste in a full seed, enter one word at a time, or generate a new seed.
3. If you enter the seed word by word, each word is hidden with `*` and the screen refreshes after every entry for clarity. SeedPass then shows the completed phrase for confirmation so you can fix any mistakes before it is stored.
4. If generating a new seed, you'll be provided with a 12-word BIP-85 seed phrase. **Ensure you write this down and store it securely.**
- **Switch Between Seed Profiles:**
1. From the **Profiles** menu, select "Switch Seed Profile".
@@ -369,8 +578,12 @@ SeedPass allows you to manage multiple seed profiles (previously referred to as
3. Enter the number corresponding to the seed profile you wish to switch to.
4. Enter the master password associated with that seed profile.
- **List All Seed Profiles:**
- **List All Seed Profiles:**
In the **Profiles** menu, choose "List All Seed Profiles" to view all existing profiles.
- **Set Seed Profile Name:**
In the **Profiles** menu, choose "Set Seed Profile Name" to assign an optional
label to the currently selected profile. The name is stored locally and shown
alongside the fingerprint in menus.
**Note:** The term "seed profile" is used to represent different sets of seeds you can manage within SeedPass. This provides an intuitive way to handle multiple identities or sets of passwords.
@@ -427,12 +640,31 @@ seedpass config set nostr_retry_delay 1
The default configuration uses **50,000** PBKDF2 iterations. Increase this value for stronger password hashing or lower it for faster startup (not recommended). Offline Mode skips all Nostr communication, keeping your data local until you re-enable syncing. Quick Unlock stores a hashed copy of your password in the encrypted config so that after the initial unlock, subsequent operations won't prompt for the password until you exit the program. Avoid enabling Quick Unlock on shared machines.
### Recovery
If you previously backed up your vault to Nostr you can restore it during the
initial setup. You must provide both your 12word master seed and the master
password that encrypted the vault; without the correct password the retrieved
data cannot be decrypted.
Alternatively, a local backup file can be loaded at startup. Launch the
application with `--restore-backup <file> --fingerprint <fp>` or choose the
**Restore from backup** option presented before the vault initializes.
1. Start SeedPass and choose option **4** when prompted to set up a seed.
2. Paste your BIP85 seed phrase when asked.
3. Enter the master password associated with that seed.
4. SeedPass initializes the profile and attempts to download the encrypted
vault from the configured relays.
5. A success message confirms the vault was restored. If no data is found a
failure message is shown and a new empty vault is created.
## Running Tests
SeedPass includes a small suite of unit tests located under `src/tests`. **Before running `pytest`, be sure to install the test requirements.** Activate your virtual environment and run `pip install -r src/requirements.txt` to ensure all testing dependencies are available. Then run the tests with **pytest**. Use `-vv` to see INFO-level log messages from each passing test:
SeedPass includes a small suite of unit tests located under `src/tests`. **Before running `pytest`, be sure to install the test requirements.** Activate your virtual environment and run `pip install --require-hashes -r requirements.lock` to ensure all testing dependencies are available. Then run the tests with **pytest**. Use `-vv` to see INFO-level log messages from each passing test:
```bash
pip install -r src/requirements.txt
pip install --require-hashes -r requirements.lock
pytest -vv
```
@@ -459,7 +691,9 @@ subfolder (or adjust `APP_DIR` in `constants.py`) if you want to load it with
the main application. The fingerprint is printed after creation and the
encrypted index is published to Nostr. Use that same seed phrase to load
SeedPass. The app checks Nostr on startup and pulls any newer snapshot so your
vault stays in sync across machines.
vault stays in sync across machines. If no snapshot exists or the download
cannot be decrypted (for example when using a brand-new seed), SeedPass
automatically initializes an empty index instead of exiting.
### Automatically Updating the Script Checksum
@@ -477,6 +711,10 @@ If the checksum file is missing, generate it manually:
python scripts/update_checksum.py
```
If SeedPass prints a "script checksum mismatch" warning on startup, regenerate
the checksum with `seedpass util update-checksum` or select "Generate Script
Checksum" from the Settings menu.
To run mutation tests locally, generate coverage data first and then execute `mutmut`:
```bash
@@ -486,6 +724,72 @@ python -m mutmut results
```
Mutation testing is disabled in the GitHub workflow due to reliability issues and should be run on a desktop environment instead.
## Development Workflow
1. Install all development dependencies:
```bash
pip install --require-hashes -r requirements.lock
```
2. When `src/runtime_requirements.txt` changes, rerun:
```bash
scripts/vendor_dependencies.sh
```
Commit the updated `src/vendor/` directory. The application automatically adds this folder to `sys.path` so the bundled packages are found.
3. Before committing, format and test the code:
```bash
black .
pytest
```
## Building a standalone executable
1. Run the vendoring script to bundle runtime dependencies:
```bash
scripts/vendor_dependencies.sh
```
2. Build the binary with PyInstaller:
```bash
pyinstaller SeedPass.spec
```
You can also produce packaged installers for the GUI with BeeWare's Briefcase:
```bash
briefcase build
```
Pre-built installers are published for each `seedpass-gui` tag. Visit the
project's **Actions** or **Releases** page on GitHub to download the latest
package for your platform.
The standalone executable will appear in the `dist/` directory. This process works on Windows, macOS and Linux but you must build on each platform for a native binary.
## Packaging with Briefcase
For step-by-step instructions see [docs/docs/content/01-getting-started/05-briefcase.md](docs/docs/content/01-getting-started/05-briefcase.md).
Install Briefcase and create a platform-specific scaffold:
```bash
python -m pip install briefcase
briefcase create
```
Build and run the packaged GUI:
```bash
briefcase build
briefcase run
```
You can also launch the GUI directly with `seedpass gui` or `seedpass-gui`.
## Security Considerations
@@ -494,16 +798,61 @@ Mutation testing is disabled in the GitHub workflow due to reliability issues an
- **Backup Your Data:** Regularly back up your encrypted data and checksum files to prevent data loss.
- **Backup the Settings PIN:** Your settings PIN is stored in the encrypted configuration file. Keep a copy of this file or remember the PIN, as losing it will require deleting the file and reconfiguring your relays.
- **Protect Your Passwords:** Do not share your master password or seed phrases with anyone and ensure they are strong and unique.
- **Revealing the Parent Seed:** The `vault reveal-parent-seed` command and `/api/v1/parent-seed` endpoint print your seed in plain text. Run them only in a secure environment.
- **Backing Up the Parent Seed:** Use the CLI `vault reveal-parent-seed` command or the `/api/v1/vault/backup-parent-seed` endpoint with explicit confirmation to create an encrypted backup. The API does not return the seed directly.
- **No PBKDF2 Salt Needed:** 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.
- **Checksum Verification:** Always verify the script's checksum to ensure its integrity and protect against unauthorized modifications.
- **Potential Bugs and Limitations:** Be aware that the software may contain bugs and lacks certain features. Snapshot chunks are capped at 50 KB and the client rotates snapshots after enough delta events accumulate. The security of memory management and logs has not been thoroughly evaluated and may pose risks of leaking sensitive information.
- **Best-Effort Memory Zeroization:** Sensitive data is wiped from memory when possible, but Python may retain copies of decrypted values.
- **Multiple Seeds Management:** While managing multiple seeds adds flexibility, it also increases the responsibility to secure each seed and its associated password.
- **No PBKDF2 Salt Required:** SeedPass deliberately omits an explicit PBKDF2 salt. Every password is derived from a unique 512-bit BIP-85 child seed, which already provides stronger per-password uniqueness than a conventional 128-bit salt.
- **Default KDF Iterations:** New profiles start with 50,000 PBKDF2 iterations. Adjust this with `seedpass config set kdf_iterations`.
- **KDF Iteration Caution:** Lowering `kdf_iterations` makes password cracking easier, while a high `backup_interval` leaves fewer recent backups.
- **Offline Mode:** When enabled, SeedPass skips all Nostr operations so your vault stays local until syncing is turned back on.
- **Quick Unlock:** Stores a hashed copy of your password in the encrypted config so you only need to enter it once per session. Avoid this on shared computers.
- **Prompt Rate Limiting:** Seed and password prompts enforce a configurable attempt limit with exponential backoff to slow brute-force attacks. Adjust or disable the limit for testing via the `--max-prompt-attempts` CLI option or the `SEEDPASS_MAX_PROMPT_ATTEMPTS` environment variable.
### Secure Deployment
Always deploy SeedPass behind HTTPS. Place a TLSterminating reverse proxy such as Nginx in front of the FastAPI server or configure Uvicorn with certificate files. Example Nginx snippet:
```
server {
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
For local testing, Uvicorn can run with TLS directly:
```
uvicorn seedpass.api:app --ssl-certfile=cert.pem --ssl-keyfile=key.pem
```
## Dependency Updates
Automated dependency updates are handled by [Dependabot](https://docs.github.com/en/code-security/dependabot).
Every week, Dependabot checks Python packages and GitHub Actions used by this repository and opens pull requests when updates are available.
To review and merge these updates:
1. Review the changelog and release notes in the Dependabot pull request.
2. Run the test suite locally:
```bash
python3 -m venv venv
source venv/bin/activate
pip install --require-hashes -r requirements.lock
pytest
```
3. Merge the pull request once all checks pass.
A scheduled **Dependency Audit** workflow also runs [`pip-audit`](https://github.com/pypa/pip-audit) weekly to detect vulnerable packages. Address any reported issues promptly to keep dependencies secure.
## Contributing

38
SeedPass.spec Normal file
View File

@@ -0,0 +1,38 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['src/main.py'],
pathex=['src', 'src/vendor'],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='SeedPass',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

View File

@@ -1,93 +0,0 @@
### SeedPass Road-to-1.0 — Detailed Development Plan
*(Assumes today = 1 July 2025, team of 1-3 devs, weekly release cadence)*
| Phase | Goal | Key Deliverables | Target Window |
| ------------------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
| **0 Vision Lock-in** | Be explicit about where youre going so every later trade-off is easy. | • 2-page “north-star” doc covering product scope, security promises, platforms, and **“CLI is source of truth”** principle. <br>• Public roadmap Kanban board. | **Week 0** |
| **1 Package-ready Codebase** | Turn loose `src/` tree into a pip-installable library + console script. | • `pyproject.toml` with PEP-621 metadata, `setuptools-scm` dynamic version. <br>• Restructure to `seedpass/` (or keep `src/` but list `packages = ["seedpass"]`). <br>• Entry-point: `seedpass = "seedpass.main:cli"`. <br>• Dev extras: `pytest-cov`, `ruff`, `mypy`, `pre-commit`. <br>• Split pure business logic from I/O (e.g., encryption, BIP-85, vault ops) so GUI can reuse. | **Weeks 0-2** |
| **2 Local Quality Net** | Fail fast before CI runs. | • `make test` / `tox` quick matrix (3.103.12). <br>• 90 % line coverage gate. <br>• Static checks in pre-commit (black, ruff, mypy). | **Weeks 1-3** |
| **3 CI / Release Automation** | One Git tag → everything ships. | • GitHub Actions matrix (Ubuntu, macOS, Windows). <br>• Steps: install → unit tests → build wheels (`python -m build`) → PyInstaller one-file artefacts → upload to Release. <br>• Secrets for PyPI / code-signing left empty until 1.0. | **Weeks 2-4** |
| **4 OS-Native Packages** | Users can “apt install / brew install / flatpak install / download .exe”. | **Linux**`stdeb``.deb`, `reprepro` mini-APT repo. <br>**Flatpak** • YAML manifest + GitHub Action to build & push to Flathub beta repo. <br>**Windows** • PyInstaller `--onefile` → NSIS installer. <br>**macOS** • Briefcase → notarised `.pkg` or `.dmg` (signing cert later). | **Weeks 4-8** |
| **5 Experimental GUI Track** | Ship a GUI **without** slowing CLI velocity. | • Decide stack (recommend **Textual** first; upgrade later to Toga or PySide). <br>• Create `seedpass.gui` package calling existing APIs; flag with `--gui`. <br>• Feature flag via env var `SEEDPASS_GUI=1` or CLI switch. <br>• Separate workflow that builds GUI artefacts, but does **not** block CLI releases. | **Weeks 6-12** (parallel) |
| **6 Plugin / Extensibility Layer** | Keep core slim while allowing future features. | • Define `entry_points={"seedpass.plugins": …}`. <br>• Document simple example plugin (e.g., custom password rule). <br>• Load plugins lazily to avoid startup cost. | **Weeks 10-14** |
| **7 Security & Hardening** | Turn security assumptions into guarantees before 1.0 | • SAST scan (Bandit, Semgrep). <br>• Threat-model doc: key-storage, BIP-85 determinism, Nostr backup flow. <br>• Repro-build check for PyInstaller artefacts. <br>• Signed releases (Sigstore, minisign). | **Weeks 12-16** |
| **8 1.0 Launch Prep** | Final polish + docs. | • User manual (MkDocs, `docs.seedpass.org`). <br>• In-app `--check-update` hitting GitHub API. <br>• Blog post & template release notes. | **Weeks 16-18** |
---
### Ongoing Practices to Keep Development Nimble
| Practice | What to do |
| ----------------------- | ------------------------------------------------------------------------------------------- |
| **Dynamic versioning** | Keep `version` dynamic via `setuptools-scm` / `hatch-vcs`; tag and push nothing else. |
| **Trunk-based dev** | Short-lived branches, PRs < 300 LOC; merge when tests pass. |
| **Feature flags** | `seedpass.config.is_enabled("X")` so unfinished work can ship dark. |
| **Fast feedback loops** | Local editable install; `invoke run --watch` (or `uvicorn --reload` for GUI) to hot-reload. |
| **Weekly beta release** | Even during heavy GUI work, cut beta tags weekly; real users shake out regressions early. |
---
### First 2-Week Sprint (Concrete To-Dos)
1. **Bootstrap packaging**
```bash
pip install --upgrade pip build setuptools_scm
poetry init # if you prefer Poetry, else stick with setuptools
```
Add `pyproject.toml`, move code to `seedpass/`.
2. **Console entry-point**
In `seedpass/__main__.py` add `from .main import cli; cli()`.
3. **Editable dev install**
`pip install -e .[dev]` → run `seedpass --help`.
4. **Set up pre-commit**
`pre-commit install` with ruff + black + mypy hooks.
5. **GitHub Action skeleton** (`.github/workflows/ci.yml`)
```yaml
jobs:
test:
strategy:
matrix: os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ['3.12', '3.11']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: {python-version: ${{ matrix.python-version }}}
- run: pip install --upgrade pip
- run: pip install -e .[dev]
- run: pytest -n auto
```
6. **Smoke PyInstaller locally**
`pyinstaller --onefile seedpass/main.py` fix missing data/hooks; check binary runs.
When thats green, cut tag `v0.1.0-beta` and let CI build artefacts automatically.
---
### Choosing the GUI Path (decision by Week 6)
| If you value | Choose |
| ---------------------------------- | ---------------------------- |
| Terminal-first UX, live coding | **Textual (Rich-TUI)** |
| Native look, single code base | **Toga / Briefcase** |
| Advanced widgets, designer tooling | **PySide-6 / Qt for Python** |
Prototype one screen (vault list + Add dialog) and benchmark bundle size + startup time with PyInstaller before committing.
---
## Recap
* **Packaging & CI first** lets every future feature ride an established release train.
* **GUI lives in its own layer** CLI stays stable; dev cycles remain quick.
* **Security & signing** land after functionality is stable, before v1.0 marketing push.
Follow the phase table, keep weekly betas flowing, and youll reach a polished, installer-ready, GUI-enhanced 1.0 in roughly four months without sacrificing day-to-day agility.

41
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,41 @@
# SeedPass Architecture
SeedPass follows a layered design that keeps the security-critical logic isolated in a reusable core package. Interfaces like the command line tool, REST API and graphical client act as thin adapters around this core.
## Core Components
- **`seedpass.core`** houses all encryption, key derivation and vault management code.
- **`VaultService`** and **`EntryService`** thread-safe wrappers exposing the main API.
- **`PasswordManager`** orchestrates vault operations, migrations and Nostr sync.
## Adapters
- **CLI/TUI** implemented in [`seedpass.cli`](src/seedpass/cli.py). The [Advanced CLI](docs/docs/content/01-getting-started/01-advanced_cli.md) guide details all commands.
- **FastAPI server** defined in [`seedpass.api`](src/seedpass/api.py). See the [API Reference](docs/docs/content/01-getting-started/02-api_reference.md) for endpoints.
- **BeeWare GUI** located in [`seedpass_gui`](src/seedpass_gui/app.py) and explained in the [GUI Adapter](docs/docs/content/01-getting-started/06-gui_adapter.md) page.
## Planned Extensions
SeedPass is built to support additional adapters. Planned or experimental options include:
- A browser extension communicating with the API
- Automation scripts using the CLI
- Additional vault import/export helpers described in [JSON Entries](docs/docs/content/01-getting-started/03-json_entries.md)
## Overview Diagram
```mermaid
graph TD
core["seedpass.core"]
cli["CLI / TUI"]
api["FastAPI server"]
gui["BeeWare GUI"]
ext["Browser extension"]
cli --> core
api --> core
gui --> core
ext --> api
```
All adapters depend on the same core, allowing features to evolve without duplicating logic across interfaces.

44
docs/SPEC.md Normal file
View File

@@ -0,0 +1,44 @@
# SeedPass Specification
## Key Hierarchy
SeedPass derives a hierarchy of keys from a single BIP-39 parent seed using HKDF:
- **Master Key** `HKDF(seed, "seedpass:v1:master")`
- **KEY_STORAGE** used to encrypt vault data.
- **KEY_INDEX** protects the metadata index.
- **KEY_PW_DERIVE** deterministic password generation.
- **KEY_TOTP_DET** deterministic TOTP secrets.
Each context string keeps derived keys domain separated.
## KDF Parameters
Passwords are protected with **PBKDF2-HMAC-SHA256**. The default work factor is
**50,000 iterations** but may be adjusted via the settings slider. The config
stores a `KdfConfig` structure with the chosen iteration count, algorithm name,
and the current spec version (`CURRENT_KDF_VERSION = 1`). Argon2 is available
with a default `time_cost` of 2 when selected.
## Message Formats
SeedPass synchronizes profiles over Nostr using three event kinds:
- **Manifest (`30070`)** high level snapshot description and current version.
- **Snapshot Chunk (`30071`)** compressed, encrypted portions of the vault.
- **Delta (`30072`)** incremental changes since the last snapshot.
Events encode JSON and include tags for checksums, fingerprints, and timestamps.
## Versioning
Configuration and KDF schemas are versioned so clients can migrate older
profiles. Nostr events carry a version field in the manifest, and the software
follows semantic versioning for releases.
## Memory Protection
SeedPass encrypts sensitive values in memory and attempts to wipe them when no
longer needed. This zeroization is best-effort only; Python's memory management
may retain copies of decrypted data. Critical cryptographic operations may move
to a Rust/WASM module in the future to provide stronger guarantees.

View File

@@ -49,15 +49,15 @@ Manage individual entries within a vault.
| List entries | `entry list` | `seedpass entry list --sort label` |
| Search for entries | `entry search` | `seedpass entry search "GitHub"` |
| Retrieve an entry's secret (password or TOTP code) | `entry get` | `seedpass entry get "GitHub"` |
| Add a password entry | `entry add` | `seedpass entry add Example --length 16` |
| Add a password entry | `entry add` | `seedpass entry add Example --length 16 --no-special --exclude-ambiguous` |
| Add a TOTP entry | `entry add-totp` | `seedpass entry add-totp Email --secret JBSW...` |
| Add an SSH key entry | `entry add-ssh` | `seedpass entry add-ssh Server --index 0` |
| Add a PGP key entry | `entry add-pgp` | `seedpass entry add-pgp Personal --user-id me@example.com` |
| Add a Nostr key entry | `entry add-nostr` | `seedpass entry add-nostr Chat` |
| Add a seed phrase entry | `entry add-seed` | `seedpass entry add-seed Backup --words 24` |
| Add a key/value entry | `entry add-key-value` | `seedpass entry add-key-value "API Token" --value abc123` |
| Add a key/value entry | `entry add-key-value` | `seedpass entry add-key-value "API Token" --key api --value abc123` |
| Add a managed account entry | `entry add-managed-account` | `seedpass entry add-managed-account Trading` |
| Modify an entry | `entry modify` | `seedpass entry modify 1 --username alice` |
| Modify an entry | `entry modify` | `seedpass entry modify 1 --key new --value updated` |
| Archive an entry | `entry archive` | `seedpass entry archive 1` |
| Unarchive an entry | `entry unarchive` | `seedpass entry unarchive 1` |
| Export all TOTP secrets | `entry export-totp` | `seedpass entry export-totp --file totp.json` |
@@ -78,7 +78,7 @@ Manage the entire vault for a profile.
### Nostr Commands
Interact with the Nostr network for backup and synchronization.
Interact with the Nostr network for backup and synchronization. Offline mode is enabled by default, so disable it with `seedpass config toggle-offline` before using these commands.
| Action | Command | Examples |
| :--- | :--- | :--- |
@@ -112,10 +112,14 @@ Miscellaneous helper commands.
| Action | Command | Examples |
| :--- | :--- | :--- |
| Generate a password | `util generate-password` | `seedpass util generate-password --length 24` |
| Generate a password | `util generate-password` | `seedpass util generate-password --length 24 --special-mode safe --exclude-ambiguous` |
| Verify script checksum | `util verify-checksum` | `seedpass util verify-checksum` |
| Update script checksum | `util update-checksum` | `seedpass util update-checksum` |
If you see a startup warning about a script checksum mismatch,
run `seedpass util update-checksum` or choose "Generate Script Checksum"
from the Settings menu to update the stored value.
### API Commands
Run or stop the local HTTP API.
@@ -123,7 +127,7 @@ Run or stop the local HTTP API.
| Action | Command | Examples |
| :--- | :--- | :--- |
| Start the API | `api start` | `seedpass api start --host 0.0.0.0 --port 8000` |
| Stop the API | `api stop` | `seedpass api stop` |
| Stop the API | `api stop --token TOKEN` | `seedpass api stop --token <token>` |
---
@@ -132,17 +136,17 @@ Run or stop the local HTTP API.
### `entry` Commands
- **`seedpass entry list`** List entries in the vault, optionally sorted or filtered.
- **`seedpass entry search <query>`** Search across labels, usernames, URLs and notes.
- **`seedpass entry search <query>`** Search across labels, usernames, URLs and notes. Results show the entry type before each label.
- **`seedpass entry get <query>`** Retrieve the password or TOTP code for one matching entry, depending on the entry's type.
- **`seedpass entry add <label>`** Create a new password entry. Use `--length` to set the password length and optional `--username`/`--url` values.
- **`seedpass entry add <label>`** Create a new password entry. Use `--length` and flags like `--no-special`, `--special-mode safe`, or `--exclude-ambiguous` to override the global policy.
- **`seedpass entry add-totp <label>`** Create a TOTP entry. Use `--secret` to import an existing secret or `--index` to derive from the seed.
- **`seedpass entry add-ssh <label>`** Create an SSH key entry derived from the seed.
- **`seedpass entry add-pgp <label>`** Create a PGP key entry. Provide `--user-id` and `--key-type` as needed.
- **`seedpass entry add-nostr <label>`** Create a Nostr key entry for decentralised chat.
- **`seedpass entry add-seed <label>`** Store a derived seed phrase. Use `--words` to set the word count.
- **`seedpass entry add-key-value <label>`** Store arbitrary data with `--value`.
- **`seedpass entry add-key-value <label>`** Store arbitrary data with `--key` and `--value`.
- **`seedpass entry add-managed-account <label>`** Store a BIP85 derived account seed.
- **`seedpass entry modify <id>`** Update an entry's label, username, URL or notes.
- **`seedpass entry modify <id>`** Update an entry's fields. For key/value entries you can change the label, key and value.
- **`seedpass entry archive <id>`** Mark an entry as archived so it is hidden from normal lists.
- **`seedpass entry unarchive <id>`** Restore an archived entry.
- **`seedpass entry export-totp --file <path>`** Export all stored TOTP secrets to a JSON file.
@@ -156,6 +160,14 @@ $ seedpass entry get "email"
Code: 123456
```
### Viewing Entry Details
Picking an entry from `entry list` or `entry search` displays its metadata first
so you can review the label, username and notes. Sensitive fields are hidden
until you confirm you want to reveal them. After showing the secret, the details
view offers the same actions as `entry get`—edit the entry, archive it or show
QR codes for supported types.
### `vault` Commands
- **`seedpass vault export`** Export the entire vault to an encrypted JSON file.
@@ -173,7 +185,7 @@ Code: 123456
### `config` Commands
- **`seedpass config get <key>`** Retrieve a configuration value such as `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `clipboard_clear_delay`, `additional_backup_path`, `relays`, `quick_unlock`, `nostr_max_retries`, `nostr_retry_delay`, or password policy fields like `min_uppercase`.
- **`seedpass config set <key> <value>`** Update a configuration option. Example: `seedpass config set kdf_iterations 200000`. Use keys like `min_uppercase`, `min_lowercase`, `min_digits`, `min_special`, `nostr_max_retries`, `nostr_retry_delay`, or `quick_unlock` to adjust settings.
- **`seedpass config set <key> <value>`** Update a configuration option. Example: `seedpass config set kdf_iterations 200000`. Use keys like `min_uppercase`, `min_lowercase`, `min_digits`, `min_special`, `include_special_chars`, `allowed_special_chars`, `special_mode`, `exclude_ambiguous`, `nostr_max_retries`, `nostr_retry_delay`, or `quick_unlock` to adjust settings.
- **`seedpass config toggle-secret-mode`** Interactively enable or disable Secret Mode and set the clipboard delay.
- **`seedpass config toggle-offline`** Enable or disable offline mode to skip Nostr operations.
@@ -202,7 +214,7 @@ Set the `SEEDPASS_CORS_ORIGINS` environment variable to a commaseparated list
SEEDPASS_CORS_ORIGINS=http://localhost:3000 seedpass api start
```
Shut down the server with `seedpass api stop`.
Shut down the server with `seedpass api stop --token <token>`.
---
@@ -211,5 +223,5 @@ Shut down the server with `seedpass api stop`.
- Use the `--help` flag for details on any command.
- Set a strong master password and regularly export encrypted backups.
- Adjust configuration values like `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `nostr_max_retries`, `nostr_retry_delay`, or `quick_unlock` through the `config` commands.
- Customize password complexity with `config set min_uppercase 3`, `config set min_digits 4`, and similar commands.
- Customize the global password policy with commands like `config set min_uppercase 3` or `config set special_mode safe`. When adding a password interactively you can override these values, choose a safe special-character set, and exclude ambiguous characters.
- `entry get` is scriptfriendly and can be piped into other commands.

View File

@@ -2,21 +2,24 @@
This guide covers how to start the SeedPass API, authenticate requests, and interact with the available endpoints.
**Note:** All UI layers, including the CLI, BeeWare GUI, and future adapters, consume this REST API through service classes in `seedpass.core`. See [docs/gui_adapter.md](docs/gui_adapter.md) for more details on the GUI integration.
## Starting the API
Run `seedpass api start` from your terminal. The command prints a onetime token used for authentication:
Run `seedpass api start` from your terminal. The command prints a shortlived JWT token used for authentication:
```bash
$ seedpass api start
API token: abcdef1234567890
API token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
```
Keep this token secret. Every request must include it in the `Authorization` header using the `Bearer` scheme.
Keep this token secret and avoid logging it. Tokens expire after a few minutes and every request must include one in the `Authorization` header using the `Bearer` scheme.
## Endpoints
- `GET /api/v1/entry?query=<text>` Search entries matching a query.
- `GET /api/v1/entry/{id}` Retrieve a single entry by its index.
- `GET /api/v1/entry/{id}` Retrieve a single entry by its index. Requires an `X-SeedPass-Password` header.
- `POST /api/v1/entry` Create a new entry of any supported type.
- `PUT /api/v1/entry/{id}` Modify an existing entry.
- `PUT /api/v1/config/{key}` Update a configuration value.
@@ -28,18 +31,17 @@ Keep this token secret. Every request must include it in the `Authorization` hea
- `POST /api/v1/fingerprint` Add a new seed fingerprint.
- `DELETE /api/v1/fingerprint/{fp}` Remove a fingerprint.
- `POST /api/v1/fingerprint/select` Switch the active fingerprint.
- `GET /api/v1/totp/export` Export all TOTP entries as JSON.
- `GET /api/v1/totp` Return current TOTP codes and remaining time.
- `GET /api/v1/totp/export` Export all TOTP entries as JSON. Requires an `X-SeedPass-Password` header.
- `GET /api/v1/totp` Return current TOTP codes and remaining time. Requires an `X-SeedPass-Password` header.
- `GET /api/v1/stats` Return statistics about the active seed profile.
- `GET /api/v1/notifications` Retrieve and clear queued notifications. Messages appear in the persistent notification box but remain queued until fetched.
- `GET /api/v1/parent-seed` Reveal the parent seed or save it with `?file=`.
- `GET /api/v1/nostr/pubkey` Fetch the Nostr public key for the active seed.
- `POST /api/v1/checksum/verify` Verify the checksum of the running script.
- `POST /api/v1/checksum/update` Update the stored script checksum.
- `POST /api/v1/change-password` Change the master password for the active profile.
- `POST /api/v1/vault/import` Import a vault backup from a file or path.
- `POST /api/v1/vault/export` Export the vault and download the encrypted file.
- `POST /api/v1/vault/backup-parent-seed` Save an encrypted backup of the parent seed.
- `POST /api/v1/vault/export` Export the vault and download the encrypted file. Requires an additional `X-SeedPass-Password` header.
- `POST /api/v1/vault/backup-parent-seed` Save an encrypted backup of the parent seed. Requires a `confirm` flag in the request body and an `X-SeedPass-Password` header.
- `POST /api/v1/vault/lock` Lock the vault and clear sensitive data from memory.
- `GET /api/v1/relays` List configured Nostr relays.
- `POST /api/v1/relays` Add a relay URL.
@@ -47,7 +49,30 @@ Keep this token secret. Every request must include it in the `Authorization` hea
- `POST /api/v1/relays/reset` Reset the relay list to defaults.
- `POST /api/v1/shutdown` Stop the server gracefully.
**Security Warning:** Accessing `/api/v1/parent-seed` exposes your master seed in plain text. Use it only from a trusted environment.
## Secure Deployment
Always run the API behind HTTPS. Use a reverse proxy such as Nginx or Caddy to terminate TLS and forward requests to SeedPass. Example Nginx configuration:
```
server {
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
For local testing, Uvicorn can serve TLS directly:
```
uvicorn seedpass.api:app --ssl-certfile=cert.pem --ssl-keyfile=key.pem
```
## Example Requests
@@ -55,7 +80,7 @@ Send requests with the token in the header:
```bash
curl -H "Authorization: Bearer <token>" \
"http://127.0.0.1:8000/api/v1/entry?query=email"
"https://127.0.0.1:8000/api/v1/entry?query=email"
```
### Creating an Entry
@@ -146,8 +171,9 @@ curl -X POST http://127.0.0.1:8000/api/v1/fingerprint/select \
Download an encrypted vault backup via `POST /api/v1/vault/export`:
```bash
curl -X POST http://127.0.0.1:8000/api/v1/vault/export \
curl -X POST https://127.0.0.1:8000/api/v1/vault/export \
-H "Authorization: Bearer <token>" \
-H "X-SeedPass-Password: <master-password>" \
-o backup.json
```
@@ -177,8 +203,9 @@ Trigger an encrypted seed backup with `/api/v1/vault/backup-parent-seed`:
```bash
curl -X POST http://127.0.0.1:8000/api/v1/vault/backup-parent-seed \
-H "Authorization: Bearer <token>" \
-H "X-SeedPass-Password: <master password>" \
-H "Content-Type: application/json" \
-d '{"path": "seed_backup.enc"}'
-d '{"path": "seed_backup.enc", "confirm": true}'
```
### Retrieving Vault Statistics

View File

@@ -95,10 +95,22 @@ Each entry is stored within `seedpass_entries_db.json.enc` under the `entries` d
- **custom_fields** (`array`, optional): Additional user-defined fields.
- **origin** (`string`, optional): Source identifier for imported data.
- **value** (`string`, optional): For `key_value` entries, stores the secret value.
- **key** (`string`, optional): Name of the key for `key_value` entries.
- **index** (`integer`, optional): BIP-85 derivation index for entries that derive material from a seed.
- **word_count** (`integer`, managed_account only): Number of words in the child seed. Managed accounts always use `12`.
- **fingerprint** (`string`, managed_account only): Identifier of the child profile, used for its directory name.
- **tags** (`array`, optional): Category labels to aid in organization and search.
#### Password Policy Fields
- **min_uppercase** (`integer`, default `2`): Minimum required uppercase letters.
- **min_lowercase** (`integer`, default `2`): Minimum required lowercase letters.
- **min_digits** (`integer`, default `2`): Minimum required digits.
- **min_special** (`integer`, default `2`): Minimum required special characters.
- **include_special_chars** (`boolean`, default `true`): Enable or disable any punctuation in generated passwords.
- **allowed_special_chars** (`string`, optional): Restrict punctuation to this exact set.
- **special_mode** (`string`, default `"standard"`): Choose `"safe"` for the `SAFE_SPECIAL_CHARS` set (`!@#$%^*-_+=?`), otherwise the full `string.punctuation` is used.
- **exclude_ambiguous** (`boolean`, default `false`): Omit confusing characters like `O0Il1`.
Example:
```json
@@ -160,6 +172,17 @@ Each entry is stored within `seedpass_entries_db.json.enc` under the `entries` d
}
```
#### Password Entry with Policy Overrides
```json
{
"label": "Custom Policy",
"length": 16,
"include_special_chars": false,
"exclude_ambiguous": true
}
```
#### 3. Managed User
```json

View File

@@ -3,6 +3,8 @@
SeedPass stores its password index in an encrypted JSON file. Each index contains
a `schema_version` field so the application knows how to upgrade older files.
> **Note:** Recent releases derive passwords and other artifacts using a new deterministic algorithm that works consistently across Python versions. Artifacts produced with older versions will not match outputs from this release and must be regenerated.
## How migrations work
When the vault loads the index, `Vault.load_index()` checks the version and

View File

@@ -0,0 +1,29 @@
# Packaging the GUI with Briefcase
This project uses [BeeWare's Briefcase](https://beeware.org) to generate
platformnative installers. Once your development environment is set up,
package the GUI by running the following commands from the repository root:
```bash
# Create the application scaffold for your platform
briefcase create
# Compile dependencies and produce a distributable bundle
briefcase build
# Run the packaged application
briefcase run
```
## Command Overview
- **`briefcase create`** — generates the project scaffold for your
operating system. Run this once per platform.
- **`briefcase build`** — compiles dependencies and produces the
distributable bundle.
- **`briefcase run`** — launches the packaged application so you can test
it locally.
After the initial creation step you can repeatedly run `briefcase build`
followed by `briefcase run` to test your packaged application on Windows,
macOS or Linux.

View File

@@ -0,0 +1,165 @@
# BeeWare GUI Adapter
SeedPass ships with a proof-of-concept graphical interface built using [BeeWare](https://beeware.org). The GUI interacts with the same core services as the CLI by instantiating wrappers around `PasswordManager`.
## Getting Started with the GUI
After installing the project dependencies, launch the desktop interface with one
of the following commands:
```bash
seedpass gui
python -m seedpass_gui
seedpass-gui
```
GUI dependencies are optional. Install them alongside SeedPass with:
```bash
pip install "seedpass[gui]"
# or when working from a local checkout
pip install -e .[gui]
```
After installing the optional GUI extras, add the BeeWare backend for your
platform:
```bash
# Linux
pip install toga-gtk
# If installation fails with cairo errors, install libcairo2-dev or the
# cairo development package using your distro's package manager.
# Windows
pip install toga-winforms
# macOS
pip install toga-cocoa
```
The GUI shares the same encrypted vault and configuration as the command line tool.
To generate a packaged binary, run `briefcase build` (after the initial `briefcase create`).
```mermaid
graph TD
core["seedpass.core"]
cli["CLI"]
api["FastAPI server"]
gui["BeeWare GUI"]
ext["Browser Extension"]
cli --> core
gui --> core
api --> core
ext --> api
```
## VaultService and EntryService
`VaultService` provides thread-safe access to vault operations like exporting, importing, unlocking and locking the vault. `EntryService` exposes methods for listing, searching and modifying entries. Both classes live in `seedpass.core.api` and hold a `PasswordManager` instance protected by a `threading.Lock` to ensure safe concurrent access.
```python
class VaultService:
"""Thread-safe wrapper around vault operations."""
def __init__(self, manager: PasswordManager) -> None:
self._manager = manager
self._lock = Lock()
```
```python
class EntryService:
"""Thread-safe wrapper around entry operations."""
def __init__(self, manager: PasswordManager) -> None:
self._manager = manager
self._lock = Lock()
```
## BeeWare Windows
The GUI defines two main windows in `src/seedpass_gui/app.py`. `LockScreenWindow` prompts for the master password and then opens `MainWindow` to display the vault entries.
```python
class LockScreenWindow(toga.Window):
"""Window prompting for the master password."""
def __init__(self, app: SeedPassApp, vault: VaultService, entries: EntryService) -> None:
super().__init__("Unlock Vault")
self.app = app
self.vault = vault
self.entries = entries
...
```
```python
class MainWindow(toga.Window):
"""Main application window showing vault entries."""
def __init__(self, app: SeedPassApp, vault: VaultService, entries: EntryService) -> None:
super().__init__("SeedPass")
self.app = app
self.vault = vault
self.entries = entries
...
```
Each window receives the service instances and calls methods such as `vault.unlock()` or `entries.add_entry()` when buttons are pressed. This keeps the UI thin while reusing the core logic.
## Asynchronous Synchronization
`PasswordManager` performs network synchronization with Nostr using `asyncio`. Methods like `start_background_vault_sync()` create a coroutine that calls `sync_vault_async()` in a background thread or task without blocking the UI.
```python
async def sync_vault_async(self, alt_summary: str | None = None) -> dict[str, list[str] | str] | None:
"""Publish the current vault contents to Nostr and return event IDs."""
...
```
```python
def start_background_vault_sync(self, alt_summary: str | None = None) -> None:
if getattr(self, "offline_mode", False):
return
def _worker() -> None:
asyncio.run(self.sync_vault_async(alt_summary=alt_summary))
try:
loop = asyncio.get_running_loop()
except RuntimeError:
threading.Thread(target=_worker, daemon=True).start()
else:
asyncio.create_task(self.sync_vault_async(alt_summary=alt_summary))
```
This approach ensures synchronization happens asynchronously whether the GUI is running inside or outside an existing event loop.
## Relay Manager and Status Bar
The *Relays* button opens a dialog for adding or removing Nostr relay URLs. The
status bar at the bottom of the main window shows when the last synchronization
completed. It updates automatically when `sync_started` and `sync_finished`
events are published on the internal pubsub bus.
When a ``vault_locked`` event is emitted, the GUI automatically returns to the
lock screen so the session can be reopened with the master password.
## Event Handling
The GUI subscribes to a few core events so the interface reacts automatically when the vault changes state. When `MainWindow` is created it registers callbacks for `sync_started`, `sync_finished` and `vault_locked` on the global pubsub `bus`:
```python
bus.subscribe("sync_started", self.sync_started)
bus.subscribe("sync_finished", self.sync_finished)
bus.subscribe("vault_locked", self.vault_locked)
```
Each handler updates the status bar or returns to the lock screen. The `cleanup` method removes these hooks when the window closes:
```python
def cleanup(self, *args: object, **kwargs: object) -> None:
bus.unsubscribe("sync_started", self.sync_started)
bus.unsubscribe("sync_finished", self.sync_finished)
bus.unsubscribe("vault_locked", self.vault_locked)
```
The [TOTP window](../../02-api_reference.md#totp) demonstrates how such events keep the UI fresh: it shows live two-factor codes that reflect the latest vault data after synchronization.

View File

@@ -10,12 +10,32 @@
This software was not developed by an experienced security expert and should be used with caution. There may be bugs and missing features. Each vault chunk is limited to 50KB and SeedPass periodically publishes a new snapshot to keep accumulated deltas small. The security of the program's memory management and logs has not been evaluated and may leak sensitive information. Loss or exposure of the parent seed places all derived passwords, accounts, and other artifacts at risk.
**🚨 Breaking Change**
Recent releases derive passwords and other artifacts using a fully deterministic algorithm that behaves consistently across Python versions. This improvement means artifacts generated with earlier versions of SeedPass will not match those produced now. Regenerate any previously derived data or retain the old version if you need to reproduce older passwords or keys.
---
### Supported OS
✔ Windows 10/11 • macOS 12+ • Any modern Linux
SeedPass now uses the `portalocker` library for cross-platform file locking. No WSL or Cygwin required.
```mermaid
graph TD
core(seedpass.core)
cli(CLI/TUI)
gui(BeeWare GUI)
ext(Browser extension)
cli --> core
gui --> core
ext --> core
```
SeedPass uses a modular design with a single core library that handles all
security-critical logic. The current CLI/TUI adapter communicates with
`seedpass.core`, and future interfaces like a BeeWare GUI and a browser
extension can hook into the same layer. This architecture keeps the codebase
maintainable while enabling a consistent experience on multiple platforms.
## Table of Contents
@@ -30,6 +50,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
- [Running the Application](#running-the-application)
- [Managing Multiple Seeds](#managing-multiple-seeds)
- [Additional Entry Types](#additional-entry-types)
- [Recovery](#recovery)
- [Security Considerations](#security-considerations)
- [Contributing](#contributing)
- [License](#license)
@@ -54,6 +75,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
- **Quick Unlock:** Optionally skip the password prompt after verifying once. Startup delay is unaffected.
- **Secret Mode:** Copy retrieved passwords directly to your clipboard and automatically clear it after a delay.
- **Tagging Support:** Organize entries with optional tags and find them quickly via search.
- **Typed Search Results:** Searches display each entry's type for easier scanning.
- **Manual Vault Export/Import:** Create encrypted backups or restore them using the CLI or API.
- **Parent Seed Backup:** Securely save an encrypted copy of the master seed.
- **Manual Vault Locking:** Instantly clear keys from memory when needed.
@@ -61,7 +83,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
- **Change Master Password:** Rotate your encryption password at any time.
- **Checksum Verification Utilities:** Verify or regenerate the script checksum.
- **Relay Management:** List, add, remove or reset configured Nostr relays.
- **Offline Mode:** Disable network sync to work entirely locally.
- **Offline Mode (default):** SeedPass runs without network sync until you explicitly enable it.
## Prerequisites
@@ -74,6 +96,8 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
### Quick Installer
Use the automated installer to download SeedPass and its dependencies in one step.
If GTK packages are missing, the installer will try to install them using your
system's package manager (`apt`, `yum`, `pacman`, or Homebrew).
**Linux and macOS:**
```bash
@@ -96,6 +120,11 @@ isn't on your PATH. If these tools are unavailable you'll see a link to download
the installer now attempts to download Python 3.12 automatically so you don't have to compile packages from source.
**Note:** If this fallback fails, install Python 3.12 manually or install the [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and rerun the installer.
#### Installer Dependency Checks
The installer verifies that core build tooling—C/C++ build tools, Rust, CMake, and the imaging/GTK libraries—are available before completing. Pass `--no-gui` to skip installing GUI packages. On Linux, ensure `xclip` or `wl-clipboard` is installed for clipboard support.
### Uninstall
Run the matching uninstaller if you need to remove a previous installation or clean up an old `seedpass` command:
@@ -166,20 +195,22 @@ When upgrading pip, use `python -m pip` inside the virtual environment so that p
```bash
python -m pip install --upgrade pip
python -m pip install -r src/requirements.txt
python -m pip install --require-hashes -r requirements.lock
python -m pip install -e .
```
#### Linux Clipboard Support
On Linux, `pyperclip` relies on external utilities like `xclip` or `xsel`.
SeedPass will attempt to install **xclip** automatically if neither tool is
available. If the automatic installation fails, you can install it manually:
SeedPass does not install these tools automatically. To use clipboard features
such as secret mode, install **xclip** manually:
```bash
sudo apt-get install xclip
sudo apt install xclip
```
After installing `xclip`, restart SeedPass to enable clipboard support.
## Quick Start
After installing dependencies, activate your virtual environment and install
@@ -191,16 +222,17 @@ create a backup:
seedpass
# Export your index
seedpass export --file "~/seedpass_backup.json"
seedpass vault export --file "~/seedpass_backup.json"
# Later you can restore it
seedpass import --file "~/seedpass_backup.json"
seedpass vault import --file "~/seedpass_backup.json"
# Import also performs a Nostr sync to pull any changes
# Quickly find or retrieve entries
seedpass search "github"
seedpass search --tags "work,personal"
seedpass get "github"
# Search results show the entry type, e.g. "1: Password - GitHub"
# Retrieve a TOTP entry
seedpass entry get "email"
# The code is printed and copied to your clipboard
@@ -296,6 +328,15 @@ When choosing **Add Entry**, you can now select from:
- **Key/Value**
- **Managed Account**
### Adding a Password Entry
After selecting **Password**, SeedPass asks you to choose a mode:
1. **Quick** enter only a label, username, URL, desired length, and whether to include special characters. All other fields use defaults.
2. **Advanced** continue through prompts for notes, tags, custom fields, and detailed password policy settings.
Both modes generate the password, display it (or copy it to the clipboard in Secret Mode), and save the entry to your encrypted vault.
### Adding a 2FA Entry
1. From the main menu choose **Add Entry** and select **2FA (TOTP)**.
@@ -347,7 +388,7 @@ entry includes a `label`, while only password entries track a `url`.
| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes`, optional `tags` |
| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes`, optional `tags` |
| Nostr Key Pair| `index`, `archived`, optional `notes`, optional `tags` |
| Key/Value | `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` |
| Key/Value | `key`, `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` |
| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes`, optional `tags` |
@@ -357,7 +398,8 @@ SeedPass allows you to manage multiple seed profiles (previously referred to as
- **Add a New Seed Profile:**
- From the main menu, select **Settings** then **Profiles** and choose "Add a New Seed Profile".
- Choose to enter an existing seed or generate a new one.
- Choose to paste in a full seed, enter one word at a time, or generate a new seed.
- When entering a seed word by word, each word is hidden with `*` and the screen refreshes after every entry for clarity. You'll review the completed phrase after the last word and can correct mistakes before it is saved.
- If generating a new seed, you'll be provided with a 12-word BIP-85 seed phrase. **Ensure you write this down and store it securely.**
- **Switch Between Seed Profiles:**
@@ -368,9 +410,27 @@ SeedPass allows you to manage multiple seed profiles (previously referred to as
- **List All Seed Profiles:**
- In the **Profiles** menu, choose "List All Seed Profiles" to view all existing profiles.
- **Set Seed Profile Name:**
- In the **Profiles** menu, choose "Set Seed Profile Name" to assign a label to the current profile. The name is stored locally and shown next to the fingerprint.
**Note:** The term "seed profile" is used to represent different sets of seeds you can manage within SeedPass. This provides an intuitive way to handle multiple identities or sets of passwords.
### Recovery
If you previously backed up your vault to Nostr you can restore it during the
initial setup. You must provide both your 12-word master seed and the master
password that encrypted the vault; without the correct password the retrieved
data cannot be decrypted.
1. Start SeedPass and choose option **4** when prompted to set up a seed.
2. Paste your BIP85 seed phrase when asked.
3. Enter the master password associated with that seed.
4. SeedPass initializes the profile and attempts to download the encrypted
vault from the configured relays.
5. A success message confirms the vault was restored. If no data is found a
failure message is shown and a new empty vault is created.
### Configuration File and Settings
SeedPass keeps per-profile settings in an encrypted file named `seedpass_config.json.enc` inside each profile directory under `~/.seedpass/`. This file stores your chosen Nostr relays and the optional settings PIN. New profiles start with the following default relays:
@@ -412,17 +472,17 @@ Back in the Settings menu you can:
whether both the encrypted database and the script itself pass checksum
validation.
* Choose `14` to toggle Secret Mode and set the clipboard clear delay.
* Select `15` to toggle Offline Mode and work locally without contacting Nostr.
* Select `15` to toggle Offline Mode. SeedPass starts offline; disable it here to enable Nostr syncing.
* Choose `16` to toggle Quick Unlock so subsequent actions skip the password prompt. Startup delay is unchanged.
* Select `17` to return to the main menu.
## Running Tests
SeedPass includes a small suite of unit tests located under `src/tests`. **Before running `pytest`, be sure to install the test requirements.** Activate your virtual environment and run `pip install -r src/requirements.txt` to ensure all testing dependencies are available. Then run the tests with **pytest**. Use `-vv` to see INFO-level log messages from each passing test:
SeedPass includes a small suite of unit tests located under `src/tests`. **Before running `pytest`, be sure to install the test requirements.** Activate your virtual environment and run `pip install --require-hashes -r requirements.lock` to ensure all testing dependencies are available. Then run the tests with **pytest**. Use `-vv` to see INFO-level log messages from each passing test:
```bash
pip install -r src/requirements.txt
pip install --require-hashes -r requirements.lock
pytest -vv
```
@@ -478,6 +538,10 @@ If the checksum file is missing, generate it manually:
python scripts/update_checksum.py
```
If SeedPass reports a "script checksum mismatch" warning on startup,
regenerate the checksum with `seedpass util update-checksum` or select
"Generate Script Checksum" from the Settings menu.
To run mutation tests locally, generate coverage data first and then execute `mutmut`:
```bash
@@ -495,14 +559,14 @@ Mutation testing is disabled in the GitHub workflow due to reliability issues an
- **Backup Your Data:** Regularly back up your encrypted data and checksum files to prevent data loss.
- **Backup the Settings PIN:** Your settings PIN is stored in the encrypted configuration file. Keep a copy of this file or remember the PIN, as losing it will require deleting the file and reconfiguring your relays.
- **Protect Your Passwords:** Do not share your master password or seed phrases with anyone and ensure they are strong and unique.
- **Revealing the Parent Seed:** The `vault reveal-parent-seed` command and `/api/v1/parent-seed` endpoint print your seed in plain text. Run them only in a secure environment.
- **Backing Up the Parent Seed:** Use the CLI `vault reveal-parent-seed` command or the `/api/v1/vault/backup-parent-seed` endpoint with explicit confirmation to create an encrypted backup. The API does not return the seed directly.
- **No PBKDF2 Salt Needed:** 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.
- **Checksum Verification:** Always verify the script's checksum to ensure its integrity and protect against unauthorized modifications.
- **Potential Bugs and Limitations:** Be aware that the software may contain bugs and lacks certain features. Snapshot chunks are capped at 50KB and the client rotates snapshots after enough delta events accumulate. The security of memory management and logs has not been thoroughly evaluated and may pose risks of leaking sensitive information.
- **Multiple Seeds Management:** While managing multiple seeds adds flexibility, it also increases the responsibility to secure each seed and its associated password.
- **No PBKDF2 Salt Required:** SeedPass deliberately omits an explicit PBKDF2 salt. Every password is derived from a unique 512-bit BIP-85 child seed, which already provides stronger per-password uniqueness than a conventional 128-bit salt.
- **Default KDF Iterations:** New profiles start with 50,000 PBKDF2 iterations. Use `seedpass config set kdf_iterations` to change this.
- **Offline Mode:** Disable Nostr sync to keep all operations local until you re-enable networking.
- **Offline Mode (default):** Nostr sync is disabled until you explicitly enable it via the Settings menu or `seedpass config toggle-offline`.
- **Quick Unlock:** Store a hashed copy of your password so future actions skip the prompt. Startup delay no longer changes. Use with caution on shared systems.
## Contributing

33
docs/nostr_setup.md Normal file
View File

@@ -0,0 +1,33 @@
# Nostr Setup
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
SeedPass communicates with the Nostr network through a list of relays. You can manage these relays from the CLI:
```bash
seedpass nostr list-relays # show configured relays
seedpass nostr add-relay <url> # add a relay URL
seedpass nostr remove-relay <n> # remove relay by index
```
At least one relay is required for publishing and retrieving backups. Choose relays you trust to remain online and avoid those that charge high fees or aggressively ratelimit connections.
## Manifest and Delta Events
Backups are published as parameterised replaceable events:
- **Kind 30070 Manifest:** describes the snapshot and lists chunk IDs. The optional `delta_since` field stores the UNIX timestamp of the latest delta event.
- **Kind 30071 Snapshot Chunk:** each 50 KB fragment of the compressed, encrypted vault.
- **Kind 30072 Delta:** captures changes since the last snapshot.
When restoring, SeedPass downloads the most recent manifest and applies any newer delta events.
## Troubleshooting
- **No events found:** ensure the relays are reachable and that the correct fingerprint is selected.
- **Connection failures:** some relays only support WebSocket over TLS; verify you are using `wss://` URLs where required.
- **Stale data:** if deltas accumulate without a fresh snapshot, run `seedpass nostr sync` to publish an updated snapshot.
Increasing log verbosity with `--verbose` can also help diagnose relay or network issues.

38
docs/packaging.md Normal file
View File

@@ -0,0 +1,38 @@
# Packaging SeedPass
This guide describes how to build platform-native packages for SeedPass using [BeeWare Briefcase](https://briefcase.readthedocs.io/).
## Prerequisites
* Python 3.12 with development headers (`python3-dev` on Debian/Ubuntu).
* Briefcase installed in your virtual environment:
```bash
pip install briefcase
```
## Linux
The helper script in `packaging/build-linux.sh` performs `briefcase create`, `build`, and `package` for the current project.
```bash
./packaging/build-linux.sh
```
Briefcase outputs its build artifacts in `build/seedpass-gui/ubuntu/noble/`. These files can be bundled in container formats such as Flatpak or Snap. Example manifests are included:
* `packaging/flatpak/seedpass.yml` targets the `org.gnome.Platform` runtime and copies the Briefcase build into the Flatpak bundle.
* `packaging/snapcraft.yaml` stages the Briefcase build and lists GTK libraries in `stage-packages` so the Snap includes its GUI dependencies.
## macOS and Windows
Scripts are provided to document the commands expected on each platform. They must be run on their respective operating systems:
* `packaging/build-macos.sh`
* `packaging/build-windows.ps1`
Each script runs Briefcase's `create`, `build`, and `package` steps with `--no-input`.
## Reproducible Releases
The `packaging/` directory contains the scripts and manifests needed to regenerate desktop packages. Invoke the appropriate script on the target OS, then use the supplied Flatpak or Snap manifest to bundle additional dependencies for Linux.

17
docs/secret-scanning.md Normal file
View File

@@ -0,0 +1,17 @@
# Secret Scanning
SeedPass uses [Gitleaks](https://github.com/gitleaks/gitleaks) to scan the repository for accidentally committed secrets. The scan runs automatically for pull requests and on a nightly schedule. Any findings will cause the build to fail.
## Suppressing False Positives
If a file or string triggers the scanner but does not contain a real secret, add it to the allowlist in `.gitleaks.toml`.
```toml
[allowlist]
# Ignore specific files
paths = ["path/to/file.txt"]
# Ignore strings that match a regular expression
regexes = ["""dummy_api_key"""]
```
Commit the updated `.gitleaks.toml` to stop future alerts for the allowed items.

30
docs/security.md Normal file
View File

@@ -0,0 +1,30 @@
# Security Testing and Calibration
This project includes fuzz tests and a calibration routine to tune Argon2 parameters for your hardware.
## Running Fuzz Tests
The fuzz tests exercise encryption and decryption with random data using [Hypothesis](https://hypothesis.readthedocs.io/).
Activate the project's virtual environment and run:
```bash
pytest src/tests/test_encryption_fuzz.py
```
Running the entire test suite will also execute these fuzz tests.
## Calibrating Argon2 Time Cost
Argon2 performance varies by device. To calibrate the `time_cost` parameter, run the helper function:
```bash
python - <<'PY'
from seedpass.core.config_manager import ConfigManager
from utils.key_derivation import calibrate_argon2_time_cost
# assuming ``cfg`` is a ConfigManager for your profile
calibrate_argon2_time_cost(cfg)
PY
```
The selected `time_cost` is stored in the profile's configuration and used for subsequent key derivations.

View File

@@ -40,7 +40,7 @@
</li>
<li role="none"><a href="#disclaimer" role="menuitem">Disclaimer</a>
</li>
<li role="none"><a href="https://beta-seedpass-docs.netlify.app/" role="menuitem">Docs</a>
<li role="none"><a href="https://docs.seedpass.me/" role="menuitem">Docs</a>
</li>
</ul>
</div>
@@ -84,6 +84,26 @@ flowchart TB
<h2 class="section-title" id="architecture-heading">Architecture Overview</h2>
<pre class="mermaid">
---
config:
layout: fixed
theme: base
themeVariables:
primaryColor: '#e94a39'
primaryBorderColor: '#e94a39'
lineColor: '#e94a39'
look: classic
---
graph TD
core(seedpass.core)
cli(CLI/TUI)
gui(BeeWare GUI)
ext(Browser extension)
cli --> core
gui --> core
ext --> core
</pre>
<pre class="mermaid">
---
config:
layout: fixed
theme: base
@@ -182,6 +202,8 @@ flowchart TD
<p>SeedPass allows you to manage multiple seed profiles (fingerprints). You can switch between different seeds to compartmentalize your passwords.</p>
<h3 class="subsection-title">Nostr Relay Integration</h3>
<p>SeedPass publishes your encrypted vault to Nostr in 50&#8201;KB chunks using parameterised replaceable events. A manifest describes each snapshot while deltas record updates. When too many deltas accumulate, a new snapshot is rotated in automatically.</p>
<h3 class="subsection-title">Recovery from Nostr</h3>
<p>Restoring a vault on a new device requires both your 12&#8201;word master seed and the master password that encrypted the vault. Without the correct password the downloaded archive cannot be decrypted.</p>
<h3 class="subsection-title">Checksum Verification</h3>
<p>Built-in checksum verification ensures your SeedPass installation hasn't been tampered with.</p>
<h3 class="subsection-title">Interactive TUI</h3>

5
packaging/build-linux.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
set -e
briefcase create linux --no-input
briefcase build linux --no-input
briefcase package linux --no-input

5
packaging/build-macos.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
set -e
briefcase create macos --no-input
briefcase build macos --no-input
briefcase package macos --no-input

View File

@@ -0,0 +1,3 @@
briefcase create windows --no-input
briefcase build windows --no-input
briefcase package windows --no-input

View File

@@ -0,0 +1,18 @@
app-id: io.seedpass.SeedPass
runtime: org.gnome.Platform
runtime-version: '46'
sdk: org.gnome.Sdk
command: seedpass-gui
modules:
- name: seedpass
buildsystem: simple
build-commands:
- mkdir -p /app/bin
- cp -r ../../build/seedpass-gui/ubuntu/noble/* /app/bin/
sources:
- type: dir
path: ../../
finish-args:
- --share=network
- --socket=fallback-x11
- --socket=wayland

22
packaging/snapcraft.yaml Normal file
View File

@@ -0,0 +1,22 @@
name: seedpass
base: core22
version: '0.1.0'
summary: Deterministic password manager
description: |
SeedPass deterministically generates passwords using BIP-39 seeds.
grade: devel
confinement: strict
apps:
seedpass-gui:
command: bin/seedpass-gui
plugs:
- network
- x11
parts:
seedpass:
plugin: dump
source: build/seedpass-gui/ubuntu/noble/app
stage-packages:
- libgtk-3-0
- libglib2.0-0
- libgdk-pixbuf2.0-0

3629
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,78 +0,0 @@
---
# SeedPass Feature BackLog (v2)
> **Encryption invariant**   Everything at rest **and** in export remains ciphertext that ultimately derives from the **profile masterpassword + parent seed**. No unencrypted payload leaves the vault.
>
> **Surface rule**   UI layers (CLI, GUI, future mobile) may *display* decrypted data **after** user unlock, but must never write plaintext to disk or network.
---
## Track vocabulary
| Label | Meaning |
| ------------ | ------------------------------------------------------------------------------ |
| **Core API** | `seedpass.api` headless services consumed by CLI / GUI |
| **Profile** | A fingerprintscoped vault: parentseed + hashed pw + entries |
| **Entry** | One encrypted JSON blob on disk plus Nostr snapshot chunks and delta events |
| **GUI MVP** | Desktop app built with PySide 6 announced in the v2 roadmap |
---
## Phase A    Corelevel enhancements (blockers for GUI)
|  Prio  | Feature | Notes |
| ------ | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|  🔥 | **Encrypted Search API** | • `VaultService.search(query:str, *, kinds=None) -> List[EntryMeta]` <br>• Decrypt *only* whitelisted metafields per `kind` (title, username, url, tags) for inmemory matching. |
|  🔥 | **Rich Listing / Sort / Filter** | • `list_entries(sort_by="updated", kind="note")` <br>• Sorting by `title` must decrypt that field onthefly. |
|  🔥 | **Custom Relay Set (per profile)** | • `StateManager.state["relays"]: List[str]` <br>• CRUD CLI commands & GUI dialog. <br>`NostrClient` reads from state at instantiation. |
|  ⚡ | **Session Lock & Idle Timeout** | • Config `SESSION_TIMEOUT` (default 15min). <br>`AuthGuard` clears inmemory keys & seeds. <br>• CLI `seedpass lock` + GUI menu “Lock vault”. |
**Exitcriteria** : All functions green in CI, consumed by both CLI (Typer) *and* a minimal Qt test harness.
---
## Phase B    Data Portability (encrypted only)
|  Prio  | Feature | Notes | |
| ------ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|  ⭐ | **Encrypted Profile Export** | • CLI `seedpass export --out myprofile.enc` <br>• Serialise *encrypted* entry files → single JSON wrapper → `EncryptionManager.encrypt_data()` <br>• Always require active profile unlock. | |
|  ⭐ | **Encrypted Profile Import / Merge** | • CLI \`seedpass import myprofile.enc \[--strategy skip | overwrite-newer]` <br>• Verify fingerprint match before ingest. <br>• Conflict policy pluggable; default `skip\`. |
---
## Phase C    Advanced secrets & sync
|  Prio  | Feature | Notes |
| ------ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|  ◇ | **TOTP entry kind** | • `kind="totp_secret"` fields: title, issuer, username, secret\_key <br>`secret_key` encrypted; handler uses `pyotp` to show current code. |
|  ◇ | **Manual Conflict Resolver** | • When `checksum` mismatch *and* both sides newer than last sync → prompt user (CLI) or modal (GUI). |
---
## Phase D    Desktop GUI MVP (Qt 6)
*Features here ride on the Core API; keep UI totally stateless.*
|  Prio  | Feature | Notes |
| ------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|  🔥 | **Login Window** | • Unlock profile with master pw. <br>• Profile switcher dropdown. |
|  🔥 | **Vault Window** | • Sidebar (Entries, Search, Backups, Settings). <br>`QTableView` bound to `VaultService.list_entries()` <br>• Sort & basic filters builtin. |
|  🔥 | **Entry Editor Dialog** | • Dynamic form driven by `kinds.py`. <br>• Add / Edit. |
|  ⭐ | **Sync Status Bar** | • Pulsing icon + last sync timestamp; hooks into `SyncService` bus. |
|  ◇ | **Relay Manager Dialog** | • CRUD & ping test per relay. |
*Binary packaging (PyInstaller matrix build) is already tracked in the roadmap and is not duplicated here.*
---
## Phase E    Later / Research
• Hardwarewallet unlock (SLIP39 share)
• Background daemon (`seedpassd` + gRPC)
• Mobile companion (Flutter FFI)
• Federated search across multiple profiles
---
**Reminder:** *No plaintext exports, no ondisk temp files, and no writing decrypted data to Nostr.* Everything funnels through the encryption stack or stays in memory for the current unlocked session only.

View File

@@ -1,11 +1,113 @@
[project]
[tool.poetry]
name = "seedpass"
version = "0.1.0"
description = "Deterministic password manager with a BeeWare GUI"
authors = []
[project.scripts]
[tool.poetry.dependencies]
python = ">=3.10,<3.13"
colorama = ">=0.4.6"
termcolor = ">=1.1.0"
cryptography = ">=40.0.2"
bip-utils = ">=2.5.0"
bech32 = "1.2.0"
coincurve = ">=18.0.0"
mnemonic = "*"
aiohttp = ">=3.12.15"
bcrypt = "*"
portalocker = ">=2.8"
nostr-sdk = ">=0.43"
websocket-client = "1.7.0"
websockets = ">=15.0.0"
tomli = "*"
pgpy = "0.6.0"
pyotp = ">=2.8.0"
pyperclip = "*"
qrcode = ">=8.2"
typer = ">=0.12.3"
fastapi = ">=0.116.0"
uvicorn = ">=0.35.0"
httpx = ">=0.28.1"
requests = ">=2.32"
python-multipart = ">=0.0.20"
orjson = "*"
argon2-cffi = "*"
PyJWT = ">=2.8.0"
slowapi = "^0.1.9"
toga-core = { version = ">=0.5.2", optional = true }
pillow = { version = "*", optional = true }
toga-gtk = { version = ">=0.5.2", optional = true }
toga-winforms = { version = ">=0.5.2", optional = true }
toga-cocoa = { version = ">=0.5.2", optional = true }
[tool.poetry.extras]
gui = ["toga-core", "pillow"]
gui-gtk = ["toga-gtk"]
gui-win = ["toga-winforms"]
gui-mac = ["toga-cocoa"]
[tool.poetry.group.dev.dependencies]
pytest = "^8.2"
coverage = "^7.5"
black = "^24.3"
pip-audit = "^2.7"
pytest-xdist = "^3.5"
hypothesis = "^6.98"
freezegun = "^1.5"
toga-dummy = ">=0.5.2"
Pillow = "^10.4"
[tool.poetry.scripts]
seedpass = "seedpass.cli:app"
seedpass-gui = "seedpass_gui.app:main"
[tool.mypy]
python_version = "3.11"
strict = true
mypy_path = "src"
[tool.briefcase]
project_name = "SeedPass"
bundle = "io.seedpass"
version = "0.1.0"
[tool.briefcase.app.seedpass-gui]
formal-name = "SeedPass"
description = "Deterministic password manager with a BeeWare GUI"
sources = ["src/seedpass_gui"]
requires = [
"toga-core>=0.5.2",
"colorama>=0.4.6",
"termcolor>=1.1.0",
"cryptography>=40.0.2",
"bip-utils>=2.5.0",
"bech32==1.2.0",
"coincurve>=18.0.0",
"mnemonic",
"aiohttp>=3.12.15",
"bcrypt",
"portalocker>=2.8",
"nostr-sdk>=0.43",
"websocket-client==1.7.0",
"websockets>=15.0.0",
"tomli",
"pgpy==0.6.0",
"pyotp>=2.8.0",
"pyperclip",
"qrcode>=8.2",
"typer>=0.12.3",
"fastapi>=0.116.0",
"uvicorn>=0.35.0",
"httpx>=0.28.1",
"requests>=2.32",
"python-multipart>=0.0.20",
"orjson",
"argon2-cffi",
]
icon = "logo/png/SeedPass-Logo-24.png"
license = { file = "LICENSE" }
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

View File

@@ -3,7 +3,7 @@ addopts = -n auto
log_cli = true
log_cli_level = WARNING
log_level = WARNING
testpaths = src/tests
testpaths = src/tests tests
markers =
network: tests that require network connectivity
stress: long running stress tests

View File

@@ -1,113 +0,0 @@
# SeedPass v2 Roadmap — CLI → Desktop GUI
> **Guiding principles**
>
> 1. **Core-first** a headless, testable Python package (`seedpass.core`) that is 100 % GUI-agnostic.
> 2. **Thin adapters** CLI, GUI, and future mobile layers merely call the core API.
> 3. **Stateless UI** all persistence lives in core services; UI never touches vault files directly.
> 4. **Parity at every step** CLI must keep working while GUI evolves.
---
## Phase 0Tooling Baseline
| # | Task | Rationale |
| --- | ---------------------------------------------------------------------------------------------- | --------------------------------- |
| 0.1 | ✅ **Adopt `poetry`** (or `hatch`) for builds & dependency pins. | Single-source version + lockfile. |
| 0.2 | ✅ **GitHub Actions**: lint (ruff), type-check (mypy), tests (pytest -q), coverage gate ≥ 85 %. | Prevent regressions. |
| 0.3 | ✅ Pre-commit hooks: ruff fix, black, isort. | Uniform style. |
---
## Phase 1Finalize Core Refactor (CLI still primary)
> *Most of this is already drafted heres what must ship before GUI work starts.*
| # | Component | Must-have work |
| --- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| 1.1 | **`kinds.py` registry + per-kind handler modules** | import-safe; handler signature `(data,fingerprint,**svc)` |
| 1.2 | **`StateManager`** | JSON file w/ fcntl lock<br>keys: `last_bip85_idx`, `last_sync_ts` |
| 1.3 | **Checksum inside entry metadata** | `sha256(json.dumps(data,sort_keys=True))` |
| 1.4 | **Replaceable Nostr events** (kind 31111, `d` tag = `"{kindtag}{entry_num}"`) | publish/update/delete tombstone |
| 1.5 | **Per-entry `EntryManager` / `BackupManager`** | Save / load / backup / restore individual encrypted files |
| 1.6 | **CLI rewritten with Typer** | Typer commands map 1-to-1 with core service methods; preserves colours. |
| 1.7 | **Legacy index migration command** | `seedpass migrate-legacy` idempotent, uses `add_entry()` under the hood. |
| 1.8 | **bcrypt + NFKD master password hash** | Stored per fingerprint. |
> **Exit-criteria:** end-to-end flow (`add → list → sync → restore`) green in CI and covered by tests.
---
## Phase 2Core API Hardening (prep for GUI)
| # | Task | Deliverable |
| --- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| 2.1 | **Public Service Layer** (`seedpass.api`) | Facade classes:<br>`VaultService`, `ProfileService`, `SyncService` *no* CLI / UI imports. |
| 2.2 | **Thread-safe gate** | Re-entrancy locks so GUI threads can call core safely. |
| 2.3 | **Fast in-process event bus** | Simple `pubsub.py` (observer pattern) for GUI to receive progress callbacks (e.g. sync progress, long ops). |
| 2.4 | **Docstrings + pydantic models** | Typed request/response objects → eases RPC later (e.g. REST, gRPC). |
| 2.5 | **Library packaging** | `python -m pip install .` gives importable `seedpass`. |
---
## Phase 3Desktop GUI MVP
| # | Decision | Notes |
| --- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| 3.0 | **Framework: PySide 6 (Qt 6)** | ✓ LGPL, ✓ native look, ✓ Python-first, ✓ WebEngine if needed. |
| 3.1 | **Process model** | *Same* process; GUI thread ↔ core API via signals/slots.<br>(If we outgrow this, swap to a local gRPC server later.) |
| 3.2 | **UI Skeleton (milestone “Hello Vault”)** | |
| | `LoginWindow` | master-password prompt → opens default profile |
| | `VaultWindow` | sidebar (Profiles, Entries, Backups) + stacked views |
| | `EntryTableView` | QTableView bound to `VaultService.list_entries()` |
| | `EntryEditorDialog` | Add / Edit forms field set driven by `kinds.py` |
| | `SyncStatusBar` | pulse animation + last-sync timestamp |
| 3.3 | **Icons / theming** | Start with Qt-built-in icons; later swap to SVG set. |
| 3.4 | **Packaging** | `PyInstaller --onefile` for Win / macOS / Linux AppImage; GitHub Actions matrix build. |
| 3.5 | **GUI E2E tests** | PyTest + pytest-qt (QtBot) smoke flows; run headless in CI (Xvfb). |
> **Stretch option:** wrap the same UI in **Tauri** later for a lighter binary (\~5 MB), reusing the core API through a local websocket RPC.
---
## Phase 4Unified Workflows & Coverage
| # | Task |
| --- | --------------------------------------------------------------------------------------- |
| 4.1 | Extend GitHub Actions to build GUI artifacts on every tag. |
| 4.2 | Add synthetic coverage for GUI code paths (QtBot). |
| 4.3 | Nightly job: spin up headless GUI, run `sync` against test relay, assert no exceptions. |
---
## Phase 5Future-Proofing (post-GUI v1)
| Idea | Sketch |
| -------------------------- | ----------------------------------------------------------------------------------------- |
| **Background daemon** | Optional `seedpassd` exposing Unix socket + JSON-RPC; both CLI & GUI become thin clients. |
| **Hardware-wallet unlock** | Replace master password with HWW + SLIP-39 share; requires PyUSB bridge. |
| **Mobile companion app** | Reuse core via BeeWare or Flutter FFI; sync over Nostr only (no local vault). |
| **End-to-end test farm** | dedicated relay docker-compose + pytest-subprocess to fake flaky relays. |
---
## Deliverables Checklist
* [ ] Core refactor merged, tests ≥ 85 % coverage
* [ ] `seedpass` installs and passes `python -m seedpass.cli --help`
* [ ] `seedpass-gui` binary opens vault, lists entries, adds & edits, syncs
* [ ] GitHub Actions builds binaries for Win/macOS/Linux on tag
* [ ] `docs/ARCHITECTURE.md` diagrams core ↔ CLI ↔ GUI layers
When the above are ✅ we can ship `v2.0.0-beta.1` and invite early desktop testers.
---
### 🔑 Key Takeaways
1. **Keep all state & crypto in the core package.**
2. **Expose a clean Python API first GUI is “just another client.”**
3. **Checksum + replaceable Nostr events give rock-solid sync & conflict handling.**
4. **Lock files and StateManager prevent index reuse and vault corruption.**
5. **The GUI sprint starts only after Phase 1 + 2 are fully green in CI.**

File diff suppressed because it is too large Load Diff

9
scripts/dependency_scan.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
# Run pip-audit against the pinned requirements
if ! command -v pip-audit >/dev/null 2>&1; then
python -m pip install --quiet pip-audit
fi
pip-audit -r requirements.lock "$@"

View File

@@ -38,11 +38,12 @@ consts.SCRIPT_CHECKSUM_FILE = consts.APP_DIR / "seedpass_script_checksum.txt"
from constants import APP_DIR, initialize_app
from utils.key_derivation import derive_key_from_password, derive_index_key
from password_manager.encryption import EncryptionManager
from password_manager.vault import Vault
from password_manager.config_manager import ConfigManager
from password_manager.backup import BackupManager
from password_manager.entry_management import EntryManager
from seedpass.core.encryption import EncryptionManager
from seedpass.core.vault import Vault
from seedpass.core.config_manager import ConfigManager
from seedpass.core.backup import BackupManager
from seedpass.core.entry_management import EntryManager
from seedpass.core.state_manager import StateManager
from nostr.client import NostrClient
from utils.fingerprint import generate_fingerprint
from utils.fingerprint_manager import FingerprintManager
@@ -79,7 +80,7 @@ def initialize_profile(
profile_dir = APP_DIR / fingerprint
profile_dir.mkdir(parents=True, exist_ok=True)
seed_key = derive_key_from_password(DEFAULT_PASSWORD)
seed_key = derive_key_from_password(DEFAULT_PASSWORD, fingerprint)
seed_mgr = EncryptionManager(seed_key, profile_dir)
seed_file = profile_dir / "parent_seed.enc"
clear_path = profile_dir / "seed_phrase.txt"
@@ -195,11 +196,13 @@ def main() -> None:
encrypted = entry_mgr.vault.get_encrypted_index()
if encrypted:
idx = StateManager(dir_path).state.get("nostr_account_idx", 0)
client = NostrClient(
entry_mgr.vault.encryption_manager,
fingerprint or dir_path.name,
parent_seed=seed,
config_manager=cfg_mgr,
account_index=idx,
)
asyncio.run(client.publish_snapshot(encrypted))
print("[+] Data synchronized to Nostr.")

View File

@@ -2,10 +2,12 @@
# SeedPass Universal Installer for Windows
#
# Supports installing from a specific branch using the -Branch parameter.
# Example: .\install.ps1 -Branch beta
# Use -IncludeGui to install the optional BeeWare GUI backend.
# Example: .\install.ps1 -Branch beta -IncludeGui
param(
[string]$Branch = "main" # The git branch to install from
[string]$Branch = "main", # The git branch to install from
[switch]$IncludeGui # Install BeeWare GUI components
)
# --- Configuration ---
@@ -249,17 +251,31 @@ if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to upgrade pip"
}
& "$VenvDir\Scripts\python.exe" -m pip install -r "src\requirements.txt"
& "$VenvDir\Scripts\python.exe" -m pip install --require-hashes -r "requirements.lock"
if ($LASTEXITCODE -ne 0) {
Write-Warning "Failed to install Python dependencies. If errors mention C++, install Microsoft C++ Build Tools: https://visualstudio.microsoft.com/visual-cpp-build-tools/"
Write-Error "Dependency installation failed."
}
& "$VenvDir\Scripts\python.exe" -m pip install -e .
if ($IncludeGui) {
& "$VenvDir\Scripts\python.exe" -m pip install -e .[gui]
} else {
& "$VenvDir\Scripts\python.exe" -m pip install -e .
}
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to install SeedPass package"
}
if ($IncludeGui) {
Write-Info "Installing BeeWare GUI backend..."
try {
& "$VenvDir\Scripts\python.exe" -m pip install toga-winforms
if ($LASTEXITCODE -ne 0) { throw "toga-winforms installation failed" }
} catch {
Write-Warning "Failed to install GUI backend. Install Microsoft C++ Build Tools from https://visualstudio.microsoft.com/visual-cpp-build-tools/ and rerun the installer."
}
}
# 5. Create launcher script
Write-Info "Creating launcher script..."
if (-not (Test-Path $LauncherDir)) { New-Item -ItemType Directory -Path $LauncherDir | Out-Null }
@@ -279,6 +295,18 @@ if ($existingSeedpass -and $existingSeedpass.Source -ne $LauncherPath) {
Write-Warning "Ensure '$LauncherDir' comes first in your PATH or remove the old installation."
}
# Detect additional seedpass executables on PATH that are not our launcher
$allSeedpass = Get-Command seedpass -All -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source
$stale = @()
foreach ($cmd in $allSeedpass) {
if ($cmd -ne $LauncherPath) { $stale += $cmd }
}
if ($stale.Count -gt 0) {
Write-Warning "Stale 'seedpass' executables detected:"
foreach ($cmd in $stale) { Write-Warning " - $cmd" }
Write-Warning "Remove or rename these to avoid launching outdated code."
}
# 6. Add launcher directory to User's PATH if needed
Write-Info "Checking if '$LauncherDir' is in your PATH..."
$UserPath = [System.Environment]::GetEnvironmentVariable("Path", "User")

View File

@@ -5,7 +5,9 @@
# Supports installing from a specific branch using the -b or --branch flag.
# Example: ./install.sh -b beta
set -e
set -euo pipefail
IFS=$'\n\t'
trap 'echo "[ERROR] Line $LINENO failed"; exit 1' ERR
# --- Configuration ---
REPO_URL="https://github.com/PR0M3TH3AN/SeedPass.git"
@@ -15,15 +17,52 @@ VENV_DIR="$INSTALL_DIR/venv"
LAUNCHER_DIR="$HOME/.local/bin"
LAUNCHER_PATH="$LAUNCHER_DIR/seedpass"
BRANCH="main" # Default branch
MODE="tui"
INSTALL_GUI=false
# --- Helper Functions ---
print_info() { echo -e "\033[1;34m[INFO]\033[0m $1"; }
print_success() { echo -e "\033[1;32m[SUCCESS]\033[0m $1"; }
print_warning() { echo -e "\033[1;33m[WARNING]\033[0m $1"; }
print_error() { echo -e "\033[1;31m[ERROR]\033[0m $1" >&2; exit 1; }
print_info() { echo -e "\033[1;34m[INFO]\033[0m" "$1"; }
print_success() { echo -e "\033[1;32m[SUCCESS]\033[0m" "$1"; }
print_warning() { echo -e "\033[1;33m[WARNING]\033[0m" "$1"; }
print_error() { echo -e "\033[1;31m[ERROR]\033[0m" "$1" >&2; exit 1; }
# Install build dependencies for Gtk/GObject if available via the system package manager
install_dependencies() {
print_info "Installing system packages required for Gtk bindings..."
if command -v apt-get &>/dev/null; then
sudo apt-get update && sudo apt-get install -y \\
build-essential pkg-config libcairo2 libcairo2-dev \\
libgirepository1.0-dev gobject-introspection \\
gir1.2-gtk-3.0 libgtk-3-dev python3-dev libffi-dev libssl-dev \\
cmake rustc cargo zlib1g-dev libjpeg-dev libpng-dev \\
libfreetype6-dev xclip wl-clipboard
elif command -v yum &>/dev/null; then
sudo yum install -y @'Development Tools' cairo cairo-devel \\
gobject-introspection-devel gtk3-devel python3-devel \\
libffi-devel openssl-devel cmake rust cargo zlib-devel \\
libjpeg-turbo-devel libpng-devel freetype-devel xclip \\
wl-clipboard
elif command -v dnf &>/dev/null; then
sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y \\
cairo cairo-devel gobject-introspection-devel gtk3-devel \\
python3-devel libffi-devel openssl-devel cmake rust cargo \\
zlib-devel libjpeg-turbo-devel libpng-devel freetype-devel \\
xclip wl-clipboard
elif command -v pacman &>/dev/null; then
sudo pacman -Syu --noconfirm base-devel pkgconf cmake rustup \\
gtk3 gobject-introspection cairo libjpeg-turbo zlib \\
libpng freetype xclip wl-clipboard && rustup default stable
elif command -v brew &>/dev/null; then
brew install pkg-config cairo gobject-introspection gtk+3 cmake rustup-init && \\
rustup-init -y
else
print_warning "Unsupported package manager. Please install Gtk/GObject dependencies manually."
fi
}
usage() {
echo "Usage: $0 [-b | --branch <branch_name>] [-h | --help]"
echo "Usage: $0 [-b | --branch <branch_name>] [-m | --mode <tui|gui|both>] [-h | --help]"
echo " -b, --branch Specify the git branch to install (default: main)"
echo " -m, --mode Installation mode: tui, gui, both (default: tui)"
echo " -h, --help Display this help message"
exit 0
}
@@ -44,12 +83,40 @@ main() {
-h|--help)
usage
;;
-m|--mode)
if [ -n "$2" ]; then
MODE="$2"
shift 2
else
print_error "Error: --mode requires an argument (tui|gui|both)."
fi
;;
*)
print_error "Unknown parameter passed: $1"; usage
;;
esac
done
case "$MODE" in
tui|gui|both) ;;
*)
print_error "Invalid mode: $MODE. Use 'tui', 'gui', or 'both'."
;;
esac
DISPLAY_DETECTED=false
if [ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ]; then
DISPLAY_DETECTED=true
fi
if [[ "$MODE" == "gui" || "$MODE" == "both" ]]; then
if [ "$DISPLAY_DETECTED" = true ]; then
INSTALL_GUI=true
else
print_warning "No display detected. Skipping GUI installation."
fi
fi
# 1. Detect OS
OS_NAME=$(uname -s)
print_info "Installing SeedPass from branch: '$BRANCH'"
@@ -84,15 +151,14 @@ main() {
fi
# 3. Install OS-specific dependencies
print_info "Checking for build dependencies..."
if [ "$OS_NAME" = "Linux" ]; then
if command -v apt-get &> /dev/null; then sudo apt-get update && sudo apt-get install -y build-essential pkg-config xclip;
elif command -v dnf &> /dev/null; then sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y pkg-config xclip;
elif command -v pacman &> /dev/null; then sudo pacman -Syu --noconfirm base-devel pkg-config xclip;
else print_warning "Could not detect package manager. Ensure build tools and pkg-config are installed."; fi
elif [ "$OS_NAME" = "Darwin" ]; then
if ! command -v brew &> /dev/null; then print_error "Homebrew not installed. See https://brew.sh/"; fi
brew install pkg-config
if [ "$INSTALL_GUI" = true ]; then
print_info "Checking for Gtk development libraries..."
if command -v pkg-config &>/dev/null && pkg-config --exists girepository-2.0; then
print_info "Gtk bindings already available."
else
print_warning "Gtk introspection bindings not found. Installing dependencies..."
install_dependencies
fi
fi
# 4. Clone or update the repository
@@ -116,10 +182,43 @@ main() {
source "$VENV_DIR/bin/activate"
# 6. Install/Update Python dependencies
print_info "Installing/updating Python dependencies from src/requirements.txt..."
print_info "Installing/updating Python dependencies from requirements.lock..."
pip install --upgrade pip
pip install -r src/requirements.txt
pip install -e .
pip install --require-hashes -r requirements.lock
if [ "$INSTALL_GUI" = true ]; then
GUI_READY=true
if [ "$OS_NAME" = "Linux" ]; then
if ! (command -v pkg-config &>/dev/null && pkg-config --exists girepository-2.0); then
print_warning "GTK libraries (girepository-2.0) not found. Install them with: sudo apt install libgirepository1.0-dev"
read -r -p "Continue with GUI installation anyway? (y/N) " CONTINUE_GUI
if [[ ! "$CONTINUE_GUI" =~ ^[Yy]$ ]]; then
GUI_READY=false
fi
fi
fi
if [ "$GUI_READY" = true ]; then
if [ "$OS_NAME" = "Linux" ]; then
print_info "Installing Linux GUI dependencies..."
pip install -e ".[gui-gtk]"
elif [ "$OS_NAME" = "Darwin" ]; then
print_info "Installing macOS GUI dependencies..."
pip install -e ".[gui-mac]"
else
print_warning "Unsupported OS for GUI installation. Installing core package only."
pip install -e .
fi
else
print_warning "Skipping GUI installation."
pip install -e .
fi
else
pip install -e .
fi
if ! "$VENV_DIR/bin/python" -c "import seedpass.cli; print('ok')"; then
print_error "SeedPass CLI import check failed."
fi
deactivate
# 7. Create launcher script
@@ -138,6 +237,23 @@ EOF2
print_warning "Ensure '$LAUNCHER_DIR' comes first in your PATH or remove the old installation."
fi
# Detect any additional seedpass executables on PATH that are not our launcher
IFS=':' read -ra _sp_paths <<< "$PATH"
stale_cmds=()
for _dir in "${_sp_paths[@]}"; do
_candidate="$_dir/seedpass"
if [ -x "$_candidate" ] && [ "$_candidate" != "$LAUNCHER_PATH" ]; then
stale_cmds+=("$_candidate")
fi
done
if [ ${#stale_cmds[@]} -gt 0 ]; then
print_warning "Stale 'seedpass' executables detected:"
for cmd in "${stale_cmds[@]}"; do
print_warning " - $cmd"
done
print_warning "Remove or rename these to avoid launching outdated code."
fi
# 8. Final instructions
print_success "Installation/update complete!"
print_info "You can now launch the interactive TUI by typing: seedpass"

36
scripts/run_ci_tests.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -eo pipefail
pytest_args=(-vv)
if [[ -n "${STRESS_ARGS:-}" ]]; then
pytest_args+=(${STRESS_ARGS})
fi
if [[ "${RUNNER_OS:-}" == "Windows" ]]; then
pytest_args+=(-n 1)
fi
pytest_args+=(--cov=src --cov-report=xml --cov-report=term-missing --cov-fail-under=20 src/tests)
timeout_bin="timeout"
if ! command -v "$timeout_bin" >/dev/null 2>&1; then
if command -v gtimeout >/dev/null 2>&1; then
timeout_bin="gtimeout"
else
timeout_bin=""
fi
fi
if [[ -n "$timeout_bin" ]]; then
$timeout_bin 15m pytest "${pytest_args[@]}" 2>&1 | tee pytest.log
status=${PIPESTATUS[0]}
else
echo "timeout command not found; running tests without timeout" >&2
pytest "${pytest_args[@]}" 2>&1 | tee pytest.log
status=${PIPESTATUS[0]}
fi
if [[ $status -eq 124 ]]; then
echo "::error::Tests exceeded 15-minute limit"
tail -n 20 pytest.log
exit 1
fi
exit $status

32
scripts/run_gui_tests.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -eo pipefail
pytest_args=(-vv --desktop -m desktop src/tests)
if [[ "${RUNNER_OS:-}" == "Windows" ]]; then
pytest_args+=(-n 1)
fi
timeout_bin="timeout"
if ! command -v "$timeout_bin" >/dev/null 2>&1; then
if command -v gtimeout >/dev/null 2>&1; then
timeout_bin="gtimeout"
else
timeout_bin=""
fi
fi
if [[ -n "$timeout_bin" ]]; then
$timeout_bin 10m pytest "${pytest_args[@]}" 2>&1 | tee pytest_gui.log
status=${PIPESTATUS[0]}
else
echo "timeout command not found; running tests without timeout" >&2
pytest "${pytest_args[@]}" 2>&1 | tee pytest_gui.log
status=${PIPESTATUS[0]}
fi
if [[ $status -eq 124 ]]; then
echo "::error::Desktop tests exceeded 10-minute limit"
tail -n 20 pytest_gui.log
exit 1
fi
exit $status

View File

@@ -14,7 +14,7 @@ from constants import SCRIPT_CHECKSUM_FILE, initialize_app
def main() -> None:
"""Calculate checksum for the main script and write it to SCRIPT_CHECKSUM_FILE."""
initialize_app()
script_path = SRC_DIR / "password_manager" / "manager.py"
script_path = SRC_DIR / "seedpass/core" / "manager.py"
if not update_checksum_file(str(script_path), str(SCRIPT_CHECKSUM_FILE)):
raise SystemExit(f"Failed to update checksum for {script_path}")
print(f"Updated checksum written to {SCRIPT_CHECKSUM_FILE}")

12
scripts/vendor_dependencies.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
VENDOR_DIR="src/vendor"
# Clean vendor directory
rm -rf "$VENDOR_DIR"
mkdir -p "$VENDOR_DIR"
pip download --no-binary :all: -r src/runtime_requirements.txt -d "$VENDOR_DIR"
echo "Vendored dependencies installed in $VENDOR_DIR"

View File

@@ -9,9 +9,11 @@ logger = logging.getLogger(__name__)
# -----------------------------------
# Nostr Relay Connection Settings
# -----------------------------------
# Retry fewer times with a shorter wait by default
MAX_RETRIES = 2 # Maximum number of retries for relay connections
RETRY_DELAY = 1 # Seconds to wait before retrying a failed connection
# Retry fewer times with a shorter wait by default. These values
# act as defaults that can be overridden via ``ConfigManager``
# entries ``nostr_max_retries`` and ``nostr_retry_delay``.
MAX_RETRIES = 2 # Default maximum number of retry attempts
RETRY_DELAY = 1 # Default seconds to wait before retrying
MIN_HEALTHY_RELAYS = 2 # Minimum relays that should return data on startup
# -----------------------------------
@@ -48,6 +50,9 @@ DEFAULT_PASSWORD_LENGTH = 16 # Default length for generated passwords
MIN_PASSWORD_LENGTH = 8 # Minimum allowed password length
MAX_PASSWORD_LENGTH = 128 # Maximum allowed password length
# Characters considered safe for passwords when limiting punctuation
SAFE_SPECIAL_CHARS = "!@#$%^*-_+=?"
# Timeout in seconds before the vault locks due to inactivity
INACTIVITY_TIMEOUT = 15 * 60 # 15 minutes

View File

@@ -1,17 +1,15 @@
# bip85/__init__.py
import logging
import traceback
logger = logging.getLogger(__name__)
try:
from .bip85 import BIP85
if logger.isEnabledFor(logging.DEBUG):
logger.info("BIP85 module imported successfully.")
except Exception as e:
if logger.isEnabledFor(logging.DEBUG):
logger.error(f"Failed to import BIP85 module: {e}", exc_info=True)
except Exception as exc:
logger.error("Failed to import BIP85 module: %s", exc, exc_info=True)
raise ImportError(
"BIP85 dependencies are missing. Install 'bip_utils', 'cryptography', and 'colorama'."
) from exc
__all__ = ["BIP85"]

View File

@@ -18,7 +18,8 @@ import hashlib
import hmac
import logging
import os
import traceback
from typing import Union
from colorama import Fore
from bip_utils import Bip32Slip10Secp256k1, Bip39MnemonicGenerator, Bip39Languages
@@ -38,13 +39,19 @@ class Bip85Error(Exception):
class BIP85:
def __init__(self, seed_bytes: bytes | str):
"""Initialize from BIP39 seed bytes or BIP32 xprv string."""
def __init__(self, seed_or_xprv: Union[bytes, str]):
"""Initialize from seed bytes or an ``xprv`` string.
Parameters:
seed_or_xprv (Union[bytes, str]): Either raw BIP39 seed bytes
or a BIP32 extended private key (``xprv``) string.
"""
try:
if isinstance(seed_bytes, (bytes, bytearray)):
self.bip32_ctx = Bip32Slip10Secp256k1.FromSeed(seed_bytes)
if isinstance(seed_or_xprv, (bytes, bytearray)):
self.bip32_ctx = Bip32Slip10Secp256k1.FromSeed(seed_or_xprv)
else:
self.bip32_ctx = Bip32Slip10Secp256k1.FromExtendedKey(seed_bytes)
self.bip32_ctx = Bip32Slip10Secp256k1.FromExtendedKey(seed_or_xprv)
logging.debug("BIP32 context initialized successfully.")
except Exception as e:
logging.error(f"Error initializing BIP32 context: {e}", exc_info=True)
@@ -52,26 +59,34 @@ class BIP85:
raise Bip85Error(f"Error initializing BIP32 context: {e}")
def derive_entropy(
self, index: int, bytes_len: int, app_no: int = 39, words_len: int | None = None
self,
index: int,
entropy_bytes: int,
app_no: int = 39,
word_count: int | None = None,
) -> bytes:
"""
Derives entropy using BIP-85 HMAC-SHA512 method.
"""Derive entropy using the BIP-85 HMAC-SHA512 method.
Parameters:
index (int): Index for the child entropy.
bytes_len (int): Number of bytes to derive for the entropy.
app_no (int): Application number (default 39 for BIP39)
entropy_bytes (int): Number of bytes of entropy to derive.
app_no (int): Application number (default 39 for BIP39).
word_count (int | None): Number of words used in the derivation path
for BIP39. If ``None`` and ``app_no`` is ``39``, ``word_count``
defaults to ``entropy_bytes``. The final segment of the
derivation path becomes ``m/83696968'/39'/0'/word_count'/index'``.
Returns:
bytes: Derived entropy.
bytes: Derived entropy of length ``entropy_bytes``.
Raises:
SystemExit: If derivation fails or entropy length is invalid.
SystemExit: If derivation fails or the derived entropy length is
invalid.
"""
if app_no == 39:
if words_len is None:
words_len = bytes_len
path = f"m/83696968'/{app_no}'/0'/{words_len}'/{index}'"
if word_count is None:
word_count = entropy_bytes
path = f"m/83696968'/{app_no}'/0'/{word_count}'/{index}'"
elif app_no == 32:
path = f"m/83696968'/{app_no}'/{index}'"
else:
@@ -87,17 +102,17 @@ class BIP85:
hmac_result = hmac.new(hmac_key, k, hashlib.sha512).digest()
logging.debug(f"HMAC-SHA512 result: {hmac_result.hex()}")
entropy = hmac_result[:bytes_len]
entropy = hmac_result[:entropy_bytes]
if len(entropy) != bytes_len:
if len(entropy) != entropy_bytes:
logging.error(
f"Derived entropy length is {len(entropy)} bytes; expected {bytes_len} bytes."
f"Derived entropy length is {len(entropy)} bytes; expected {entropy_bytes} bytes."
)
print(
f"{Fore.RED}Error: Derived entropy length is {len(entropy)} bytes; expected {bytes_len} bytes."
f"{Fore.RED}Error: Derived entropy length is {len(entropy)} bytes; expected {entropy_bytes} bytes."
)
raise Bip85Error(
f"Derived entropy length is {len(entropy)} bytes; expected {bytes_len} bytes."
f"Derived entropy length is {len(entropy)} bytes; expected {entropy_bytes} bytes."
)
logging.debug(f"Derived entropy: {entropy.hex()}")
@@ -108,14 +123,17 @@ class BIP85:
raise Bip85Error(f"Error deriving entropy: {e}")
def derive_mnemonic(self, index: int, words_num: int) -> str:
bytes_len = {12: 16, 18: 24, 24: 32}.get(words_num)
if not bytes_len:
entropy_bytes = {12: 16, 18: 24, 24: 32}.get(words_num)
if not entropy_bytes:
logging.error(f"Unsupported number of words: {words_num}")
print(f"{Fore.RED}Error: Unsupported number of words: {words_num}")
raise Bip85Error(f"Unsupported number of words: {words_num}")
entropy = self.derive_entropy(
index=index, bytes_len=bytes_len, app_no=39, words_len=words_num
index=index,
entropy_bytes=entropy_bytes,
app_no=39,
word_count=words_num,
)
try:
mnemonic = Bip39MnemonicGenerator(Bip39Languages.ENGLISH).FromEntropy(
@@ -131,7 +149,7 @@ class BIP85:
def derive_symmetric_key(self, index: int = 0, app_no: int = 2) -> bytes:
"""Derive 32 bytes of entropy for symmetric key usage."""
try:
key = self.derive_entropy(index=index, bytes_len=32, app_no=app_no)
key = self.derive_entropy(index=index, entropy_bytes=32, app_no=app_no)
logging.debug(f"Derived symmetric key: {key.hex()}")
return key
except Exception as e:

View File

@@ -1,25 +1,35 @@
# main.py
import os
from pathlib import Path
import sys
# Add bundled vendor directory to sys.path so bundled dependencies can be imported
vendor_dir = Path(__file__).parent / "vendor"
if vendor_dir.exists():
sys.path.insert(0, str(vendor_dir))
import os
import logging
from logging.handlers import QueueHandler, QueueListener
import signal
import getpass
import time
import argparse
import asyncio
import gzip
import tomli
from tomli import TOMLDecodeError
from colorama import init as colorama_init
from termcolor import colored
from utils.color_scheme import color_text
import traceback
import importlib
from password_manager.manager import PasswordManager
from seedpass.core.manager import PasswordManager, restore_backup_index
from nostr.client import NostrClient
from password_manager.entry_types import EntryType
from seedpass.core.entry_types import EntryType
from seedpass.core.config_manager import ConfigManager
from constants import INACTIVITY_TIMEOUT, initialize_app
from utils.password_prompt import PasswordPromptError
from utils.password_prompt import (
PasswordPromptError,
prompt_existing_password,
prompt_new_password,
)
from utils import (
timed_input,
copy_to_clipboard,
@@ -27,12 +37,34 @@ from utils import (
pause,
clear_header_with_notification,
)
from utils.clipboard import ClipboardUnavailableError
from utils.atomic_write import atomic_write
from utils.logging_utils import ConsolePauseFilter
import queue
from local_bip85.bip85 import Bip85Error
colorama_init()
OPTIONAL_DEPENDENCIES = {
"pyperclip": "clipboard support for secret mode",
"qrcode": "QR code generation for TOTP setup",
"toga": "desktop GUI features",
}
def _warn_missing_optional_dependencies() -> None:
"""Log warnings for any optional packages that are not installed."""
for module, feature in OPTIONAL_DEPENDENCIES.items():
try:
importlib.import_module(module)
except ModuleNotFoundError:
logging.warning(
"Optional dependency '%s' is not installed; %s will be unavailable.",
module,
feature,
)
def load_global_config() -> dict:
"""Load configuration from ~/.seedpass/config.toml if present."""
@@ -42,44 +74,48 @@ def load_global_config() -> dict:
try:
with open(config_path, "rb") as f:
return tomli.load(f)
except Exception as exc:
except (OSError, TOMLDecodeError) as exc:
logging.warning(f"Failed to read {config_path}: {exc}")
return {}
def configure_logging():
logger = logging.getLogger()
logger.setLevel(logging.DEBUG) # Keep this as DEBUG to capture all logs
_queue_listener: QueueListener | None = None
def configure_logging():
"""Configure application-wide logging with queue-based handlers."""
global _queue_listener
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
# Remove all handlers associated with the root logger object
for handler in logger.handlers[:]:
logger.removeHandler(handler)
# Ensure the 'logs' directory exists
log_directory = Path("logs")
if not log_directory.exists():
log_directory.mkdir(parents=True, exist_ok=True)
log_directory.mkdir(parents=True, exist_ok=True)
# Create handlers
c_handler = logging.StreamHandler(sys.stdout)
f_handler = logging.FileHandler(log_directory / "main.log")
log_queue: queue.Queue[logging.LogRecord] = queue.Queue()
queue_handler = QueueHandler(log_queue)
# Set levels: only errors and critical messages will be shown in the console
c_handler.setLevel(logging.ERROR)
f_handler.setLevel(logging.DEBUG)
console_handler = logging.StreamHandler(sys.stderr)
console_handler.setLevel(logging.ERROR)
console_handler.addFilter(ConsolePauseFilter())
file_handler = logging.FileHandler(log_directory / "main.log")
file_handler.setLevel(logging.DEBUG)
# Create formatters and add them to handlers
formatter = logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s [%(filename)s:%(lineno)d]"
"%(asctime)s [%(levelname)s] %(message)s [%(filename)s:%(lineno)d]",
)
c_handler.setFormatter(formatter)
f_handler.setFormatter(formatter)
console_handler.setFormatter(formatter)
file_handler.setFormatter(formatter)
# Add handlers to the logger
logger.addHandler(c_handler)
logger.addHandler(f_handler)
_queue_listener = QueueListener(log_queue, console_handler, file_handler)
_queue_listener.start()
logger.addHandler(queue_handler)
# Set logging level for third-party libraries to WARNING to suppress their debug logs
logging.getLogger("monstr").setLevel(logging.WARNING)
logging.getLogger("nostr").setLevel(logging.WARNING)
@@ -151,7 +187,8 @@ def handle_switch_fingerprint(password_manager: PasswordManager):
print(colored("Available Seed Profiles:", "cyan"))
for idx, fp in enumerate(fingerprints, start=1):
print(colored(f"{idx}. {fp}", "cyan"))
label = password_manager.fingerprint_manager.display_name(fp)
print(colored(f"{idx}. {label}", "cyan"))
choice = input("Select a seed profile by number to switch: ").strip()
if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)):
@@ -159,6 +196,13 @@ def handle_switch_fingerprint(password_manager: PasswordManager):
return
selected_fingerprint = fingerprints[int(choice) - 1]
if selected_fingerprint == password_manager.current_fingerprint:
print(
colored(
f"Seed profile {selected_fingerprint} is already active.", "yellow"
)
)
return
if password_manager.select_fingerprint(selected_fingerprint):
print(colored(f"Switched to seed profile {selected_fingerprint}.", "green"))
else:
@@ -182,11 +226,7 @@ def handle_add_new_fingerprint(password_manager: PasswordManager):
def handle_remove_fingerprint(password_manager: PasswordManager):
"""
Handles removing an existing seed profile.
:param password_manager: An instance of PasswordManager.
"""
"""Handle removing an existing seed profile."""
try:
fingerprints = password_manager.fingerprint_manager.list_fingerprints()
if not fingerprints:
@@ -195,7 +235,8 @@ def handle_remove_fingerprint(password_manager: PasswordManager):
print(colored("Available Seed Profiles:", "cyan"))
for idx, fp in enumerate(fingerprints, start=1):
print(colored(f"{idx}. {fp}", "cyan"))
label = password_manager.fingerprint_manager.display_name(fp)
print(colored(f"{idx}. {label}", "cyan"))
choice = input("Select a seed profile by number to remove: ").strip()
if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)):
@@ -204,12 +245,24 @@ def handle_remove_fingerprint(password_manager: PasswordManager):
selected_fingerprint = fingerprints[int(choice) - 1]
confirm = confirm_action(
f"Are you sure you want to remove seed profile {selected_fingerprint}? This will delete all associated data. (Y/N): "
f"Are you sure you want to remove seed profile {selected_fingerprint}? This will delete all associated data. (Y/N):"
)
if confirm:
def _cleanup_and_exit() -> None:
password_manager.current_fingerprint = None
password_manager.is_dirty = False
getattr(password_manager, "cleanup", lambda: None)()
print(colored("All seed profiles removed. Exiting.", "yellow"))
sys.exit(0)
if password_manager.fingerprint_manager.remove_fingerprint(
selected_fingerprint
selected_fingerprint, _cleanup_and_exit
):
password_manager.current_fingerprint = (
password_manager.fingerprint_manager.current_fingerprint
)
password_manager.is_dirty = False
print(
colored(
f"Seed profile {selected_fingerprint} removed successfully.",
@@ -239,7 +292,8 @@ def handle_list_fingerprints(password_manager: PasswordManager):
print(colored("Available Seed Profiles:", "cyan"))
for fp in fingerprints:
print(colored(f"- {fp}", "cyan"))
label = password_manager.fingerprint_manager.display_name(fp)
print(colored(f"- {label}", "cyan"))
pause()
except Exception as e:
logging.error(f"Error listing seed profiles: {e}", exc_info=True)
@@ -267,12 +321,24 @@ def handle_display_npub(password_manager: PasswordManager):
def _display_live_stats(
password_manager: PasswordManager, interval: float = 1.0
) -> None:
"""Continuously refresh stats until the user presses Enter."""
"""Continuously refresh stats until the user presses Enter.
Each refresh also triggers a background sync so the latest stats are
displayed if newer data exists on Nostr.
"""
stats_mgr = getattr(password_manager, "stats_manager", None)
display_fn = getattr(password_manager, "display_stats", None)
sync_fn = getattr(password_manager, "start_background_sync", None)
if not callable(display_fn):
return
if callable(sync_fn):
try:
sync_fn()
except Exception as exc: # pragma: no cover - sync best effort
logging.debug("Background sync failed during stats display: %s", exc)
if not sys.stdin or not sys.stdin.isatty():
clear_screen()
display_fn()
@@ -281,9 +347,42 @@ def _display_live_stats(
print(note)
print(colored("Press Enter to continue.", "cyan"))
pause()
if stats_mgr is not None:
stats_mgr.reset()
return
# Flush any pending input so an accidental newline doesn't exit immediately
try: # pragma: no cover - depends on platform
import termios
termios.tcflush(sys.stdin, termios.TCIFLUSH)
except Exception:
try: # pragma: no cover - Windows fallback
import msvcrt
while msvcrt.kbhit():
msvcrt.getwch()
except Exception:
pass
while True:
# Break out immediately if the user has already pressed Enter
try: # pragma: no cover - non-interactive environments
import select
ready, _, _ = select.select([sys.stdin], [], [], 0)
if ready:
line = sys.stdin.readline().strip()
if line == "" or line.lower() == "b":
break
except Exception:
pass
if callable(sync_fn):
try:
sync_fn()
except Exception: # pragma: no cover - sync best effort
logging.debug("Background sync failed during stats display")
clear_screen()
display_fn()
note = get_notification_text(password_manager)
@@ -300,6 +399,8 @@ def _display_live_stats(
except KeyboardInterrupt:
print()
break
if stats_mgr is not None:
stats_mgr.reset()
def handle_display_stats(password_manager: PasswordManager) -> None:
@@ -313,31 +414,28 @@ def handle_display_stats(password_manager: PasswordManager) -> None:
def print_matches(
password_manager: PasswordManager,
matches: list[tuple[int, str, str | None, str | None, bool]],
matches: list[tuple[int, str, str | None, str | None, bool, EntryType]],
) -> None:
"""Print a list of search matches."""
print(colored("\n[+] Matches:\n", "green"))
for entry in matches:
idx, website, username, url, blacklisted = entry
idx, website, username, url, blacklisted, etype = entry
data = password_manager.entry_manager.retrieve_entry(idx)
etype = (
data.get("type", data.get("kind", EntryType.PASSWORD.value))
if data
else EntryType.PASSWORD.value
)
print(color_text(f"Index: {idx}", "index"))
if etype == EntryType.TOTP.value:
print(color_text(f" Label: {data.get('label', website)}", "index"))
print(color_text(f" Derivation Index: {data.get('index', idx)}", "index"))
elif etype == EntryType.SEED.value:
if etype == EntryType.TOTP:
label = data.get("label", website) if data else website
deriv = data.get("index", idx) if data else idx
print(color_text(f" Label: {label}", "index"))
print(color_text(f" Derivation Index: {deriv}", "index"))
elif etype == EntryType.SEED:
print(color_text(" Type: Seed Phrase", "index"))
elif etype == EntryType.SSH.value:
elif etype == EntryType.SSH:
print(color_text(" Type: SSH Key", "index"))
elif etype == EntryType.PGP.value:
elif etype == EntryType.PGP:
print(color_text(" Type: PGP Key", "index"))
elif etype == EntryType.NOSTR.value:
elif etype == EntryType.NOSTR:
print(color_text(" Type: Nostr Key", "index"))
elif etype == EntryType.KEY_VALUE.value:
elif etype == EntryType.KEY_VALUE:
print(color_text(" Type: Key/Value", "index"))
else:
if website:
@@ -357,14 +455,15 @@ def handle_post_to_nostr(
Handles the action of posting the encrypted password index to Nostr.
"""
try:
event_id = password_manager.sync_vault(alt_summary=alt_summary)
if event_id:
print(
colored(
f"\N{WHITE HEAVY CHECK MARK} Sync complete. Event ID: {event_id}",
"green",
)
)
result = password_manager.sync_vault(alt_summary=alt_summary)
if result:
print(colored("\N{WHITE HEAVY CHECK MARK} Sync complete.", "green"))
print("Event IDs:")
print(f" manifest: {result['manifest_id']}")
for cid in result["chunk_ids"]:
print(f" chunk: {cid}")
for did in result["delta_ids"]:
print(f" delta: {did}")
logging.info("Encrypted index posted to Nostr successfully.")
else:
print(colored("\N{CROSS MARK} Sync failed…", "red"))
@@ -377,29 +476,36 @@ def handle_post_to_nostr(
def handle_retrieve_from_nostr(password_manager: PasswordManager):
"""
Handles the action of retrieving the encrypted password index from Nostr.
"""
"""Retrieve the encrypted password index from Nostr."""
try:
result = asyncio.run(password_manager.nostr_client.fetch_latest_snapshot())
if result:
manifest, chunks = result
encrypted = gzip.decompress(b"".join(chunks))
if manifest.delta_since:
version = int(manifest.delta_since)
deltas = asyncio.run(
password_manager.nostr_client.fetch_deltas_since(version)
)
if deltas:
encrypted = deltas[-1]
password_manager.encryption_manager.decrypt_and_save_index_from_nostr(
encrypted
password_manager.sync_index_from_nostr()
if password_manager.nostr_client.last_error:
msg = (
f"No Nostr events found for fingerprint"
f" {password_manager.current_fingerprint}."
if "Snapshot not found" in password_manager.nostr_client.last_error
else password_manager.nostr_client.last_error
)
print(colored("Encrypted index retrieved and saved successfully.", "green"))
logging.info("Encrypted index retrieved and saved successfully from Nostr.")
print(colored(msg, "red"))
logging.error(msg)
else:
print(colored("Failed to retrieve data from Nostr.", "red"))
logging.error("Failed to retrieve data from Nostr.")
try:
legacy_pub = (
password_manager.nostr_client.key_manager.generate_legacy_nostr_keys().public_key_hex()
)
if password_manager.nostr_client.keys.public_key_hex() == legacy_pub:
note = "Restored index from legacy Nostr backup."
print(colored(note, "yellow"))
logging.info(note)
except Exception:
pass
print(
colored(
"Encrypted index retrieved and saved successfully.",
"green",
)
)
logging.info("Encrypted index retrieved and saved successfully from Nostr.")
except Exception as e:
logging.error(f"Failed to retrieve from Nostr: {e}", exc_info=True)
print(colored(f"Error: Failed to retrieve from Nostr: {e}", "red"))
@@ -424,10 +530,21 @@ def handle_view_relays(cfg_mgr: "ConfigManager") -> None:
print(colored(f"Error: {e}", "red"))
def _safe_close_client_pool(pm: PasswordManager) -> None:
"""Close the Nostr client pool if the client exists."""
client = getattr(pm, "nostr_client", None)
if client is None:
return
try:
client.close_client_pool()
except Exception as exc:
logging.error(f"Error during NostrClient shutdown: {exc}")
def _reload_relays(password_manager: PasswordManager, relays: list) -> None:
"""Reload NostrClient with the updated relay list."""
try:
password_manager.nostr_client.close_client_pool()
_safe_close_client_pool(password_manager)
except Exception as exc:
logging.warning(f"Failed to close client pool: {exc}")
try:
@@ -559,33 +676,49 @@ def handle_set_inactivity_timeout(password_manager: PasswordManager) -> None:
def handle_set_kdf_iterations(password_manager: PasswordManager) -> None:
"""Change the PBKDF2 iteration count."""
"""Interactive slider for PBKDF2 iteration strength with benchmarking."""
import hashlib
import time
cfg_mgr = password_manager.config_manager
if cfg_mgr is None:
print(colored("Configuration manager unavailable.", "red"))
return
levels = [
("1", "Very Fast", 10_000),
("2", "Fast", 50_000),
("3", "Balanced", 100_000),
("4", "Slow", 200_000),
("5", "Paranoid", 500_000),
]
try:
current = cfg_mgr.get_kdf_iterations()
print(colored(f"Current iterations: {current}", "cyan"))
except Exception as e:
logging.error(f"Error loading iterations: {e}")
print(colored(f"Error: {e}", "red"))
return
value = input("Enter new iteration count: ").strip()
if not value:
print(colored("No iteration count entered.", "yellow"))
print(colored(f"Current iterations: {current}", "cyan"))
for key, label, iters in levels:
marker = "*" if iters == current else " "
print(colored(f"{key}. {label} ({iters}) {marker}", "menu"))
print(colored("b. Benchmark current setting", "menu"))
choice = input("Select strength or 'b' to benchmark: ").strip().lower()
if not choice:
print(colored("No change made.", "yellow"))
return
if choice == "b":
start = time.perf_counter()
hashlib.pbkdf2_hmac("sha256", b"bench", b"salt", current)
elapsed = time.perf_counter() - start
print(colored(f"{current} iterations took {elapsed:.2f}s", "green"))
return
selected = {k: v for k, _, v in levels}.get(choice)
if not selected:
print(colored("Invalid choice.", "red"))
return
try:
iterations = int(value)
if iterations <= 0:
print(colored("Iterations must be positive.", "red"))
return
except ValueError:
print(colored("Invalid number.", "red"))
return
try:
cfg_mgr.set_kdf_iterations(iterations)
print(colored("KDF iteration count updated.", "green"))
cfg_mgr.set_kdf_iterations(selected)
print(colored(f"KDF iteration count set to {selected}.", "green"))
except Exception as e:
logging.error(f"Error saving iterations: {e}")
print(colored(f"Error: {e}", "red"))
@@ -624,8 +757,7 @@ def handle_set_additional_backup_location(pm: PasswordManager) -> None:
path = Path(value).expanduser()
path.mkdir(parents=True, exist_ok=True)
test_file = path / ".seedpass_write_test"
with open(test_file, "w") as f:
f.write("test")
atomic_write(test_file, lambda f: f.write("test"))
test_file.unlink()
except Exception as e:
print(colored(f"Path not writable: {e}", "red"))
@@ -641,12 +773,41 @@ def handle_set_additional_backup_location(pm: PasswordManager) -> None:
print(colored(f"Error: {e}", "red"))
def handle_set_profile_name(pm: PasswordManager) -> None:
"""Set or clear the custom name for the current seed profile."""
fp = getattr(pm.fingerprint_manager, "current_fingerprint", None)
if not fp:
print(colored("No seed profile selected.", "red"))
return
current = pm.fingerprint_manager.get_name(fp)
if current:
print(colored(f"Current name: {current}", "cyan"))
else:
print(colored("No custom name set.", "cyan"))
value = input("Enter new name (leave blank to remove): ").strip()
if pm.fingerprint_manager.set_name(fp, value or None):
if value:
print(colored("Name updated.", "green"))
else:
print(colored("Name removed.", "green"))
def handle_toggle_secret_mode(pm: PasswordManager) -> None:
"""Toggle secret mode and adjust clipboard delay."""
cfg = pm.config_manager
if cfg is None:
print(colored("Configuration manager unavailable.", "red"))
return
vault = getattr(pm, "vault", None)
fingerprint_dir = getattr(pm, "fingerprint_dir", None)
if vault is not None and fingerprint_dir is not None:
try:
cfg = pm.config_manager = ConfigManager(vault, fingerprint_dir)
except Exception as exc:
logging.error(f"Failed to initialize ConfigManager: {exc}")
print(colored("Configuration manager unavailable.", "red"))
return
else:
print(colored("Configuration manager unavailable.", "red"))
return
try:
enabled = cfg.get_secret_mode_enabled()
delay = cfg.get_clipboard_clear_delay()
@@ -686,8 +847,18 @@ def handle_toggle_quick_unlock(pm: PasswordManager) -> None:
"""Enable or disable Quick Unlock."""
cfg = pm.config_manager
if cfg is None:
print(colored("Configuration manager unavailable.", "red"))
return
vault = getattr(pm, "vault", None)
fingerprint_dir = getattr(pm, "fingerprint_dir", None)
if vault is not None and fingerprint_dir is not None:
try:
cfg = pm.config_manager = ConfigManager(vault, fingerprint_dir)
except Exception as exc:
logging.error(f"Failed to initialize ConfigManager: {exc}")
print(colored("Configuration manager unavailable.", "red"))
return
else:
print(colored("Configuration manager unavailable.", "red"))
return
try:
enabled = cfg.get_quick_unlock()
except Exception as exc:
@@ -713,8 +884,18 @@ def handle_toggle_offline_mode(pm: PasswordManager) -> None:
"""Enable or disable offline mode."""
cfg = pm.config_manager
if cfg is None:
print(colored("Configuration manager unavailable.", "red"))
return
vault = getattr(pm, "vault", None)
fingerprint_dir = getattr(pm, "fingerprint_dir", None)
if vault is not None and fingerprint_dir is not None:
try:
cfg = pm.config_manager = ConfigManager(vault, fingerprint_dir)
except Exception as exc:
logging.error(f"Failed to initialize ConfigManager: {exc}")
print(colored("Configuration manager unavailable.", "red"))
return
else:
print(colored("Configuration manager unavailable.", "red"))
return
try:
enabled = cfg.get_offline_mode()
except Exception as exc:
@@ -756,6 +937,7 @@ def handle_profiles_menu(password_manager: PasswordManager) -> None:
print(color_text("2. Add a New Seed Profile", "menu"))
print(color_text("3. Remove an Existing Seed Profile", "menu"))
print(color_text("4. List All Seed Profiles", "menu"))
print(color_text("5. Set Seed Profile Name", "menu"))
choice = input("Select an option or press Enter to go back: ").strip()
password_manager.update_activity()
if choice == "1":
@@ -767,6 +949,8 @@ def handle_profiles_menu(password_manager: PasswordManager) -> None:
handle_remove_fingerprint(password_manager)
elif choice == "4":
handle_list_fingerprints(password_manager)
elif choice == "5":
handle_set_profile_name(password_manager)
elif not choice:
break
else:
@@ -852,12 +1036,12 @@ def handle_settings(password_manager: PasswordManager) -> None:
print(color_text("8. Import database", "menu"))
print(color_text("9. Export 2FA codes", "menu"))
print(color_text("10. Set additional backup location", "menu"))
print(color_text("11. Set KDF iterations", "menu"))
print(color_text("11. KDF strength & benchmark", "menu"))
print(color_text("12. Set inactivity timeout", "menu"))
print(color_text("13. Lock Vault", "menu"))
print(color_text("14. Stats", "menu"))
print(color_text("15. Toggle Secret Mode", "menu"))
print(color_text("16. Toggle Offline Mode", "menu"))
print(color_text("16. Toggle Offline Mode (default ON)", "menu"))
print(color_text("17. Toggle Quick Unlock", "menu"))
choice = input("Select an option or press Enter to go back: ").strip()
if choice == "1":
@@ -865,7 +1049,16 @@ def handle_settings(password_manager: PasswordManager) -> None:
elif choice == "2":
handle_nostr_menu(password_manager)
elif choice == "3":
password_manager.change_password()
try:
old_pw = prompt_existing_password("Enter your current password: ")
new_pw = prompt_new_password()
password_manager.change_password(old_pw, new_pw)
except ValueError:
print(colored("Incorrect password.", "red"))
except PasswordPromptError:
pass
except Exception as e:
print(colored(f"Error: {e}", "red"))
pause()
elif choice == "4":
password_manager.handle_verify_checksum()
@@ -943,6 +1136,7 @@ def display_menu(
getattr(password_manager, "start_background_relay_check", lambda: None)()
_display_live_stats(password_manager)
while True:
getattr(password_manager, "poll_background_errors", lambda: None)()
fp, parent_fp, child_fp = getattr(
password_manager,
"header_fingerprint_args",
@@ -963,11 +1157,15 @@ def display_menu(
getattr(password_manager, "start_background_relay_check", lambda: None)()
continue
# Periodically push updates to Nostr
if (
password_manager.is_dirty
and time.time() - password_manager.last_update >= sync_interval
):
handle_post_to_nostr(password_manager)
current_fp = getattr(password_manager, "current_fingerprint", None)
if current_fp:
if (
password_manager.is_dirty
and time.time() - password_manager.last_update >= sync_interval
):
handle_post_to_nostr(password_manager)
password_manager.is_dirty = False
else:
password_manager.is_dirty = False
# Flush logging handlers
@@ -993,7 +1191,8 @@ def display_menu(
continue
logging.info("Exiting the program.")
print(colored("Exiting the program.", "green"))
password_manager.nostr_client.close_client_pool()
getattr(password_manager, "cleanup", lambda: None)()
_safe_close_client_pool(password_manager)
sys.exit(0)
if choice == "1":
while True:
@@ -1100,6 +1299,7 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
Optional seed profile fingerprint to select automatically.
"""
configure_logging()
_warn_missing_optional_dependencies()
initialize_app()
logger = logging.getLogger(__name__)
logger.info("Starting SeedPass Password Manager")
@@ -1107,10 +1307,35 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
load_global_config()
parser = argparse.ArgumentParser()
parser.add_argument("--fingerprint")
parser.add_argument(
"--restore-backup",
help="Restore index from backup file before starting",
)
parser.add_argument(
"--no-clipboard",
action="store_true",
help="Disable clipboard support and print secrets",
)
parser.add_argument(
"--deterministic-totp",
action="store_true",
help="Derive TOTP secrets deterministically",
)
parser.add_argument(
"--max-prompt-attempts",
type=int,
default=None,
help="Maximum number of password/seed prompt attempts (0 to disable)",
)
sub = parser.add_subparsers(dest="command")
exp = sub.add_parser("export")
exp.add_argument("--file")
exp.add_argument(
"--unencrypted",
action="store_true",
help="Export without encryption",
)
imp = sub.add_parser("import")
imp.add_argument("--file")
@@ -1126,6 +1351,44 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
args = parser.parse_args(argv)
if args.restore_backup:
fp_target = args.fingerprint or fingerprint
if fp_target is None:
print(
colored(
"Error: --fingerprint is required when using --restore-backup.",
"red",
)
)
return 1
try:
restore_backup_index(Path(args.restore_backup), fp_target)
logger.info("Restored backup from %s", args.restore_backup)
except Exception as e:
logger.error(f"Failed to restore backup: {e}", exc_info=True)
print(colored(f"Error: Failed to restore backup: {e}", "red"))
return 1
elif args.command is None:
print("Startup Options:")
print("1. Continue")
print("2. Restore from backup")
choice = input("Select an option: ").strip()
if choice == "2":
path = input("Enter backup file path: ").strip()
fp_target = args.fingerprint or fingerprint
if fp_target is None:
fp_target = input("Enter fingerprint for restore: ").strip()
try:
restore_backup_index(Path(path), fp_target)
logger.info("Restored backup from %s", path)
except Exception as e:
logger.error(f"Failed to restore backup: {e}", exc_info=True)
print(colored(f"Error: Failed to restore backup: {e}", "red"))
return 1
if args.max_prompt_attempts is not None:
os.environ["SEEDPASS_MAX_PROMPT_ATTEMPTS"] = str(args.max_prompt_attempts)
try:
password_manager = PasswordManager(fingerprint=args.fingerprint or fingerprint)
logger.info("PasswordManager initialized successfully.")
@@ -1138,8 +1401,15 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red"))
return 1
if args.no_clipboard:
password_manager.secret_mode_enabled = False
if args.deterministic_totp:
password_manager.deterministic_totp = True
if args.command == "export":
password_manager.handle_export_database(Path(args.file))
password_manager.handle_export_database(
Path(args.file), encrypt=not args.unencrypted
)
return 0
if args.command == "import":
password_manager.handle_import_database(Path(args.file))
@@ -1181,22 +1451,30 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
if entry.get("type") != EntryType.TOTP.value:
print(colored("Entry is not a TOTP entry.", "red"))
return 1
code = password_manager.entry_manager.get_totp_code(
idx, password_manager.parent_seed
key = getattr(password_manager, "KEY_TOTP_DET", None) or getattr(
password_manager, "parent_seed", None
)
code = password_manager.entry_manager.get_totp_code(idx, key)
print(code)
try:
copy_to_clipboard(code, password_manager.clipboard_clear_delay)
print(colored("Code copied to clipboard", "green"))
except Exception as exc:
logging.warning(f"Clipboard copy failed: {exc}")
if copy_to_clipboard(code, password_manager.clipboard_clear_delay):
print(colored("Code copied to clipboard", "green"))
except ClipboardUnavailableError as exc:
print(
colored(
f"Clipboard unavailable: {exc}\n"
"Re-run with '--no-clipboard' to print codes instead.",
"yellow",
)
)
return 0
def signal_handler(sig, _frame):
print(colored("\nReceived shutdown signal. Exiting gracefully...", "yellow"))
logging.info(f"Received shutdown signal: {sig}. Initiating graceful shutdown.")
try:
password_manager.nostr_client.close_client_pool()
getattr(password_manager, "cleanup", lambda: None)()
_safe_close_client_pool(password_manager)
logging.info("NostrClient closed successfully.")
except Exception as exc:
logging.error(f"Error during shutdown: {exc}")
@@ -1214,7 +1492,8 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
logger.info("Program terminated by user via KeyboardInterrupt.")
print(colored("\nProgram terminated by user.", "yellow"))
try:
password_manager.nostr_client.close_client_pool()
getattr(password_manager, "cleanup", lambda: None)()
_safe_close_client_pool(password_manager)
logging.info("NostrClient closed successfully.")
except Exception as exc:
logging.error(f"Error during shutdown: {exc}")
@@ -1224,7 +1503,8 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
logger.error(f"A user-related error occurred: {e}", exc_info=True)
print(colored(f"Error: {e}", "red"))
try:
password_manager.nostr_client.close_client_pool()
getattr(password_manager, "cleanup", lambda: None)()
_safe_close_client_pool(password_manager)
logging.info("NostrClient closed successfully.")
except Exception as exc:
logging.error(f"Error during shutdown: {exc}")
@@ -1234,7 +1514,8 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
logger.error(f"An unexpected error occurred: {e}", exc_info=True)
print(colored(f"Error: An unexpected error occurred: {e}", "red"))
try:
password_manager.nostr_client.close_client_pool()
getattr(password_manager, "cleanup", lambda: None)()
_safe_close_client_pool(password_manager)
logging.info("NostrClient closed successfully.")
except Exception as exc:
logging.error(f"Error during shutdown: {exc}")

View File

@@ -14,6 +14,7 @@ class ChunkMeta:
id: str
size: int
hash: str
event_id: Optional[str] = None
@dataclass
@@ -24,3 +25,4 @@ class Manifest:
algo: str
chunks: List[ChunkMeta]
delta_since: Optional[int] = None
nonce: Optional[str] = None

View File

@@ -1,37 +1,42 @@
# src/nostr/client.py
import asyncio
import base64
import json
import logging
import time
from typing import List, Optional, Tuple, TYPE_CHECKING
import hashlib
import asyncio
import gzip
import websockets
import threading
from datetime import timedelta
from typing import List, Optional, TYPE_CHECKING
# Imports from the nostr-sdk library
import websockets
from nostr_sdk import (
Client,
Keys,
NostrSigner,
EventBuilder,
Filter,
Kind,
KindStandard,
NostrSigner,
Tag,
RelayUrl,
PublicKey,
)
from datetime import timedelta
from nostr_sdk import EventId, Timestamp
from nostr_sdk import EventId, Keys, Timestamp
from .key_manager import KeyManager as SeedPassKeyManager
from .backup_models import Manifest, ChunkMeta, KIND_MANIFEST, KIND_SNAPSHOT_CHUNK
from password_manager.encryption import EncryptionManager
from constants import MAX_RETRIES, RETRY_DELAY
from utils.file_lock import exclusive_lock
from seedpass.core.encryption import EncryptionManager
from .backup_models import (
ChunkMeta,
KIND_DELTA,
KIND_MANIFEST,
KIND_SNAPSHOT_CHUNK,
Manifest,
)
from .connection import ConnectionHandler, DEFAULT_RELAYS
from .key_manager import KeyManager as SeedPassKeyManager
from .snapshot import SnapshotHandler, prepare_snapshot
if TYPE_CHECKING: # pragma: no cover - imported for type hints
from password_manager.config_manager import ConfigManager
from seedpass.core.config_manager import ConfigManager
# Backwards compatibility for tests that patch these symbols
KeyManager = SeedPassKeyManager
@@ -40,52 +45,8 @@ ClientBuilder = Client
logger = logging.getLogger(__name__)
logger.setLevel(logging.WARNING)
DEFAULT_RELAYS = [
"wss://relay.snort.social",
"wss://nostr.oxtr.dev",
"wss://relay.primal.net",
]
def prepare_snapshot(
encrypted_bytes: bytes, limit: int
) -> Tuple[Manifest, list[bytes]]:
"""Compress and split the encrypted vault into chunks.
Each chunk is hashed with SHA-256 and described in the returned
:class:`Manifest`.
Parameters
----------
encrypted_bytes : bytes
The encrypted vault contents.
limit : int
Maximum chunk size in bytes.
Returns
-------
Tuple[Manifest, list[bytes]]
The manifest describing all chunks and the list of chunk bytes.
"""
compressed = gzip.compress(encrypted_bytes)
chunks = [compressed[i : i + limit] for i in range(0, len(compressed), limit)]
metas: list[ChunkMeta] = []
for i, chunk in enumerate(chunks):
metas.append(
ChunkMeta(
id=f"seedpass-chunk-{i:04d}",
size=len(chunk),
hash=hashlib.sha256(chunk).hexdigest(),
)
)
manifest = Manifest(ver=1, algo="gzip", chunks=metas)
return manifest, chunks
class NostrClient:
class NostrClient(ConnectionHandler, SnapshotHandler):
"""Interact with the Nostr network using nostr-sdk."""
def __init__(
@@ -96,6 +57,8 @@ class NostrClient:
parent_seed: Optional[str] = None,
offline_mode: bool = False,
config_manager: Optional["ConfigManager"] = None,
key_index: bytes | None = None,
account_index: int | None = None,
) -> None:
self.encryption_manager = encryption_manager
self.fingerprint = fingerprint
@@ -107,7 +70,7 @@ class NostrClient:
parent_seed = self.encryption_manager.decrypt_parent_seed()
# Use our project's KeyManager to derive the private key
self.key_manager = KeyManager(parent_seed, fingerprint)
self.key_manager = KeyManager(parent_seed, fingerprint, account_index)
# Create a nostr-sdk Keys object from our derived private key
private_key_hex = self.key_manager.keys.private_key_hex()
@@ -134,9 +97,11 @@ class NostrClient:
self.last_error: Optional[str] = None
self.delta_threshold = 100
self._state_lock = threading.Lock()
self.current_manifest: Manifest | None = None
self.current_manifest_id: str | None = None
self._delta_events: list[str] = []
self.key_index = key_index or b""
# Configure and initialize the nostr-sdk Client
signer = NostrSigner.keys(self.keys)
@@ -144,393 +109,9 @@ class NostrClient:
self._connected = False
def connect(self) -> None:
"""Connect the client to all configured relays."""
if self.offline_mode or not self.relays:
return
if not self._connected:
self.initialize_client_pool()
def initialize_client_pool(self) -> None:
"""Add relays to the client and connect."""
if self.offline_mode or not self.relays:
return
asyncio.run(self._initialize_client_pool())
async def _connect_async(self) -> None:
"""Ensure the client is connected within an async context."""
if self.offline_mode or not self.relays:
return
if not self._connected:
await self._initialize_client_pool()
async def _initialize_client_pool(self) -> None:
if self.offline_mode or not self.relays:
return
if hasattr(self.client, "add_relays"):
await self.client.add_relays(self.relays)
else:
for relay in self.relays:
await self.client.add_relay(relay)
await self.client.connect()
self._connected = True
logger.info(f"NostrClient connected to relays: {self.relays}")
async def _ping_relay(self, relay: str, timeout: float) -> bool:
"""Attempt to retrieve the latest event from a single relay."""
sub_id = "seedpass-health"
pubkey = self.keys.public_key().to_hex()
req = json.dumps(
["REQ", sub_id, {"kinds": [1], "authors": [pubkey], "limit": 1}]
)
try:
async with websockets.connect(
relay, open_timeout=timeout, close_timeout=timeout
) as ws:
await ws.send(req)
while True:
msg = await asyncio.wait_for(ws.recv(), timeout=timeout)
data = json.loads(msg)
if data[0] in {"EVENT", "EOSE"}:
return True
except Exception:
return False
async def _check_relay_health(self, min_relays: int, timeout: float) -> int:
tasks = [self._ping_relay(r, timeout) for r in self.relays]
results = await asyncio.gather(*tasks, return_exceptions=True)
healthy = sum(1 for r in results if r is True)
if healthy < min_relays:
logger.warning(
"Only %s relays responded with data; consider adding more.", healthy
)
return healthy
def check_relay_health(self, min_relays: int = 2, timeout: float = 5.0) -> int:
"""Ping relays and return the count of those providing data."""
if self.offline_mode or not self.relays:
return 0
return asyncio.run(self._check_relay_health(min_relays, timeout))
def publish_json_to_nostr(
self,
encrypted_json: bytes,
to_pubkey: str | None = None,
alt_summary: str | None = None,
) -> str | None:
"""Builds and publishes a Kind 1 text note or direct message.
Parameters
----------
encrypted_json : bytes
The encrypted index data to publish.
to_pubkey : str | None, optional
If provided, send as a direct message to this public key.
alt_summary : str | None, optional
If provided, include an ``alt`` tag so uploads can be
associated with a specific event like a password change.
"""
if self.offline_mode or not self.relays:
return None
self.connect()
self.last_error = None
try:
content = base64.b64encode(encrypted_json).decode("utf-8")
if to_pubkey:
receiver = PublicKey.parse(to_pubkey)
event_output = self.client.send_private_msg_to(
self.relays, receiver, content
)
else:
builder = EventBuilder.text_note(content)
if alt_summary:
builder = builder.tags([Tag.alt(alt_summary)])
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
event_output = self.publish_event(event)
event_id_hex = (
event_output.id.to_hex()
if hasattr(event_output, "id")
else str(event_output)
)
logger.info(f"Successfully published event with ID: {event_id_hex}")
return event_id_hex
except Exception as e:
self.last_error = str(e)
logger.error(f"Failed to publish JSON to Nostr: {e}")
return None
def publish_event(self, event):
"""Publish a prepared event to the configured relays."""
if self.offline_mode or not self.relays:
return None
self.connect()
return asyncio.run(self._publish_event(event))
async def _publish_event(self, event):
if self.offline_mode or not self.relays:
return None
await self._connect_async()
return await self.client.send_event(event)
def update_relays(self, new_relays: List[str]) -> None:
"""Reconnect the client using a new set of relays."""
self.close_client_pool()
self.relays = new_relays
signer = NostrSigner.keys(self.keys)
self.client = Client(signer)
self._connected = False
# Immediately reconnect using the updated relay list
self.initialize_client_pool()
def retrieve_json_from_nostr_sync(
self, retries: int | None = None, delay: float | None = None
) -> Optional[bytes]:
"""Retrieve the latest Kind 1 event from the author with optional retries."""
if self.offline_mode or not self.relays:
return None
if retries is None or delay is None:
if self.config_manager is None:
from password_manager.config_manager import ConfigManager
from password_manager.vault import Vault
cfg_mgr = ConfigManager(
Vault(self.encryption_manager, self.fingerprint_dir),
self.fingerprint_dir,
)
else:
cfg_mgr = self.config_manager
cfg = cfg_mgr.load_config(require_pin=False)
retries = int(cfg.get("nostr_max_retries", MAX_RETRIES))
delay = float(cfg.get("nostr_retry_delay", RETRY_DELAY))
self.connect()
self.last_error = None
attempt = 0
while True:
try:
result = asyncio.run(self._retrieve_json_from_nostr())
if result is not None:
return result
except Exception as e:
self.last_error = str(e)
logger.error("Failed to retrieve events from Nostr: %s", e)
if attempt >= retries:
break
attempt += 1
time.sleep(delay)
return None
async def _retrieve_json_from_nostr(self) -> Optional[bytes]:
if self.offline_mode or not self.relays:
return None
await self._connect_async()
# Filter for the latest text note (Kind 1) from our public key
pubkey = self.keys.public_key()
f = Filter().author(pubkey).kind(Kind.from_std(KindStandard.TEXT_NOTE)).limit(1)
timeout = timedelta(seconds=10)
events = (await self.client.fetch_events(f, timeout)).to_vec()
if not events:
self.last_error = "No events found on relays for this user."
logger.warning(self.last_error)
return None
latest_event = events[0]
content_b64 = latest_event.content()
if content_b64:
return base64.b64decode(content_b64.encode("utf-8"))
self.last_error = "Latest event contained no content"
return None
async def publish_snapshot(
self, encrypted_bytes: bytes, limit: int = 50_000
) -> tuple[Manifest, str]:
"""Publish a compressed snapshot split into chunks.
Parameters
----------
encrypted_bytes : bytes
Vault contents already encrypted with the user's key.
limit : int, optional
Maximum chunk size in bytes. Defaults to 50 kB.
"""
start = time.perf_counter()
if self.offline_mode or not self.relays:
return Manifest(ver=1, algo="gzip", chunks=[]), ""
await self._connect_async()
manifest, chunks = prepare_snapshot(encrypted_bytes, limit)
for meta, chunk in zip(manifest.chunks, chunks):
content = base64.b64encode(chunk).decode("utf-8")
builder = EventBuilder(Kind(KIND_SNAPSHOT_CHUNK), content).tags(
[Tag.identifier(meta.id)]
)
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
await self.client.send_event(event)
manifest_json = json.dumps(
{
"ver": manifest.ver,
"algo": manifest.algo,
"chunks": [meta.__dict__ for meta in manifest.chunks],
"delta_since": manifest.delta_since,
}
)
manifest_event = (
EventBuilder(Kind(KIND_MANIFEST), manifest_json)
.build(self.keys.public_key())
.sign_with_keys(self.keys)
)
result = await self.client.send_event(manifest_event)
manifest_id = result.id.to_hex() if hasattr(result, "id") else str(result)
self.current_manifest = manifest
self.current_manifest_id = manifest_id
# Record when this snapshot was published for future delta events
self.current_manifest.delta_since = int(time.time())
self._delta_events = []
if getattr(self, "verbose_timing", False):
duration = time.perf_counter() - start
logger.info("publish_snapshot completed in %.2f seconds", duration)
return manifest, manifest_id
async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None:
"""Retrieve the latest manifest and all snapshot chunks."""
if self.offline_mode or not self.relays:
return None
await self._connect_async()
pubkey = self.keys.public_key()
f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).limit(1)
timeout = timedelta(seconds=10)
events = (await self.client.fetch_events(f, timeout)).to_vec()
if not events:
return None
manifest_event = events[0]
manifest_raw = manifest_event.content()
data = json.loads(manifest_raw)
manifest = Manifest(
ver=data["ver"],
algo=data["algo"],
chunks=[ChunkMeta(**c) for c in data["chunks"]],
delta_since=(
int(data["delta_since"])
if data.get("delta_since") is not None
else None
),
)
chunks: list[bytes] = []
for meta in manifest.chunks:
cf = (
Filter()
.author(pubkey)
.kind(Kind(KIND_SNAPSHOT_CHUNK))
.identifier(meta.id)
.limit(1)
)
cev = (await self.client.fetch_events(cf, timeout)).to_vec()
if not cev:
raise ValueError(f"Missing chunk {meta.id}")
chunk_bytes = base64.b64decode(cev[0].content().encode("utf-8"))
if hashlib.sha256(chunk_bytes).hexdigest() != meta.hash:
raise ValueError(f"Checksum mismatch for chunk {meta.id}")
chunks.append(chunk_bytes)
self.current_manifest = manifest
man_id = getattr(manifest_event, "id", None)
if hasattr(man_id, "to_hex"):
man_id = man_id.to_hex()
self.current_manifest_id = man_id
return manifest, chunks
async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str:
"""Publish a delta event referencing a manifest."""
if self.offline_mode or not self.relays:
return ""
await self._connect_async()
content = base64.b64encode(delta_bytes).decode("utf-8")
tag = Tag.event(EventId.parse(manifest_id))
builder = EventBuilder(Kind(KIND_DELTA), content).tags([tag])
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
result = await self.client.send_event(event)
delta_id = result.id.to_hex() if hasattr(result, "id") else str(result)
created_at = getattr(
event, "created_at", getattr(event, "timestamp", int(time.time()))
)
if hasattr(created_at, "secs"):
created_at = created_at.secs
if self.current_manifest is not None:
self.current_manifest.delta_since = int(created_at)
manifest_json = json.dumps(
{
"ver": self.current_manifest.ver,
"algo": self.current_manifest.algo,
"chunks": [meta.__dict__ for meta in self.current_manifest.chunks],
"delta_since": self.current_manifest.delta_since,
}
)
manifest_event = (
EventBuilder(Kind(KIND_MANIFEST), manifest_json)
.tags([Tag.identifier(self.current_manifest_id)])
.build(self.keys.public_key())
.sign_with_keys(self.keys)
)
await self.client.send_event(manifest_event)
self._delta_events.append(delta_id)
return delta_id
async def fetch_deltas_since(self, version: int) -> list[bytes]:
"""Retrieve delta events newer than the given version."""
if self.offline_mode or not self.relays:
return []
await self._connect_async()
pubkey = self.keys.public_key()
f = (
Filter()
.author(pubkey)
.kind(Kind(KIND_DELTA))
.since(Timestamp.from_secs(version))
)
timeout = timedelta(seconds=10)
events = (await self.client.fetch_events(f, timeout)).to_vec()
deltas: list[bytes] = []
for ev in events:
deltas.append(base64.b64decode(ev.content().encode("utf-8")))
if self.current_manifest is not None:
snap_size = sum(c.size for c in self.current_manifest.chunks)
if (
len(deltas) >= self.delta_threshold
or sum(len(d) for d in deltas) > snap_size
):
# Publish a new snapshot to consolidate deltas
joined = b"".join(deltas)
await self.publish_snapshot(joined)
exp = Timestamp.from_secs(int(time.time()))
for ev in events:
exp_builder = EventBuilder(Kind(KIND_DELTA), ev.content()).tags(
[Tag.expiration(exp)]
)
exp_event = exp_builder.build(
self.keys.public_key()
).sign_with_keys(self.keys)
await self.client.send_event(exp_event)
return deltas
def close_client_pool(self) -> None:
"""Disconnects the client from all relays."""
try:
asyncio.run(self.client.disconnect())
self._connected = False
logger.info("NostrClient disconnected from relays.")
except Exception as e:
logger.error("Error during NostrClient shutdown: %s", e)
__all__ = [
"NostrClient",
"prepare_snapshot",
"DEFAULT_RELAYS",
]

View File

@@ -27,7 +27,8 @@ class Keys:
@staticmethod
def hex_to_bech32(key_str: str, prefix: str = "npub") -> str:
data = convertbits(bytes.fromhex(key_str), 8, 5)
# Pad to align with 5-bit groups as expected for Bech32 encoding
data = convertbits(bytes.fromhex(key_str), 8, 5, True)
return bech32_encode(prefix, data)
@staticmethod

232
src/nostr/connection.py Normal file
View File

@@ -0,0 +1,232 @@
import asyncio
import base64
import json
import logging
from datetime import timedelta
from typing import List, Optional
import websockets
from . import client as nostr_client
from constants import MAX_RETRIES, RETRY_DELAY
logger = logging.getLogger("nostr.client")
logger.setLevel(logging.WARNING)
DEFAULT_RELAYS = [
"wss://relay.snort.social",
"wss://nostr.oxtr.dev",
"wss://relay.primal.net",
]
class ConnectionHandler:
"""Mixin providing relay connection and retry logic."""
async def connect(self) -> None:
"""Connect the client to all configured relays."""
if self.offline_mode or not self.relays:
return
if not getattr(self, "_connected", False):
await self._initialize_client_pool()
def initialize_client_pool(self) -> None:
"""Add relays to the client and connect."""
if self.offline_mode or not self.relays:
return
asyncio.run(self._initialize_client_pool())
async def _connect_async(self) -> None:
"""Ensure the client is connected within an async context."""
if self.offline_mode or not self.relays:
return
if not getattr(self, "_connected", False):
await self._initialize_client_pool()
async def _initialize_client_pool(self) -> None:
if self.offline_mode or not self.relays:
return
formatted = []
for relay in self.relays:
if isinstance(relay, str):
try:
formatted.append(nostr_client.RelayUrl.parse(relay))
except Exception:
logger.error("Invalid relay URL: %s", relay)
else:
formatted.append(relay)
if hasattr(self.client, "add_relays"):
await self.client.add_relays(formatted)
else:
for relay in formatted:
await self.client.add_relay(relay)
await self.client.connect()
self._connected = True
logger.info("NostrClient connected to relays: %s", formatted)
async def _ping_relay(self, relay: str, timeout: float) -> bool:
"""Attempt to retrieve the latest event from a single relay."""
sub_id = "seedpass-health"
pubkey = self.keys.public_key().to_hex()
req = json.dumps(
[
"REQ",
sub_id,
{"kinds": [1], "authors": [pubkey], "limit": 1},
]
)
try:
async with websockets.connect(
relay, open_timeout=timeout, close_timeout=timeout
) as ws:
await ws.send(req)
while True:
msg = await asyncio.wait_for(ws.recv(), timeout=timeout)
data = json.loads(msg)
if data[0] in {"EVENT", "EOSE"}:
return True
except Exception:
return False
async def _check_relay_health(self, min_relays: int, timeout: float) -> int:
tasks = [self._ping_relay(r, timeout) for r in self.relays]
results = await asyncio.gather(*tasks, return_exceptions=True)
healthy = sum(1 for r in results if r is True)
if healthy < min_relays:
logger.warning(
"Only %s relays responded with data; consider adding more.", healthy
)
return healthy
def check_relay_health(self, min_relays: int = 2, timeout: float = 5.0) -> int:
"""Ping relays and return the count of those providing data."""
if self.offline_mode or not self.relays:
return 0
return asyncio.run(self._check_relay_health(min_relays, timeout))
async def publish_json_to_nostr(
self,
encrypted_json: bytes,
to_pubkey: str | None = None,
alt_summary: str | None = None,
) -> str | None:
"""Build and publish a Kind 1 text note or direct message."""
if self.offline_mode or not self.relays:
return None
await self.connect()
self.last_error = None
try:
content = base64.b64encode(encrypted_json).decode("utf-8")
if to_pubkey:
receiver = nostr_client.PublicKey.parse(to_pubkey)
event_output = self.client.send_private_msg_to(
self.relays, receiver, content
)
else:
builder = nostr_client.EventBuilder.text_note(content)
if alt_summary:
builder = builder.tags([nostr_client.Tag.alt(alt_summary)])
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
event_output = await self.publish_event(event)
event_id_hex = (
event_output.id.to_hex()
if hasattr(event_output, "id")
else str(event_output)
)
logger.info("Successfully published event with ID: %s", event_id_hex)
return event_id_hex
except Exception as e:
self.last_error = str(e)
logger.error("Failed to publish JSON to Nostr: %s", e)
return None
async def publish_event(self, event):
"""Publish a prepared event to the configured relays."""
if self.offline_mode or not self.relays:
return None
await self.connect()
return await self.client.send_event(event)
def update_relays(self, new_relays: List[str]) -> None:
"""Reconnect the client using a new set of relays."""
self.close_client_pool()
self.relays = new_relays
signer = nostr_client.NostrSigner.keys(self.keys)
self.client = nostr_client.Client(signer)
self._connected = False
self.initialize_client_pool()
async def retrieve_json_from_nostr(
self, retries: int | None = None, delay: float | None = None
) -> Optional[bytes]:
"""Retrieve the latest Kind 1 event from the author with optional retries."""
if self.offline_mode or not self.relays:
return None
if retries is None or delay is None:
if self.config_manager is None:
from seedpass.core.config_manager import ConfigManager
from seedpass.core.vault import Vault
cfg_mgr = ConfigManager(
Vault(self.encryption_manager, self.fingerprint_dir),
self.fingerprint_dir,
)
else:
cfg_mgr = self.config_manager
cfg = cfg_mgr.load_config(require_pin=False)
retries = int(cfg.get("nostr_max_retries", MAX_RETRIES))
delay = float(cfg.get("nostr_retry_delay", RETRY_DELAY))
await self.connect()
self.last_error = None
for attempt in range(retries):
try:
result = await self._retrieve_json_from_nostr()
if result is not None:
return result
except Exception as e:
self.last_error = str(e)
logger.error("Failed to retrieve events from Nostr: %s", e)
if attempt < retries - 1:
sleep_time = delay * (2**attempt)
await asyncio.sleep(sleep_time)
return None
async def _retrieve_json_from_nostr(self) -> Optional[bytes]:
if self.offline_mode or not self.relays:
return None
await self._connect_async()
pubkey = self.keys.public_key()
f = (
nostr_client.Filter()
.author(pubkey)
.kind(nostr_client.Kind.from_std(nostr_client.KindStandard.TEXT_NOTE))
.limit(1)
)
timeout = timedelta(seconds=10)
events = (await self.client.fetch_events(f, timeout)).to_vec()
if not events:
self.last_error = "No events found on relays for this user."
logger.warning(self.last_error)
return None
latest_event = events[0]
content_b64 = latest_event.content()
if content_b64:
return base64.b64decode(content_b64.encode("utf-8"))
self.last_error = "Latest event contained no content"
return None
def close_client_pool(self) -> None:
"""Disconnect the client from all relays."""
try:
asyncio.run(self.client.disconnect())
self._connected = False
logger.info("NostrClient disconnected from relays.")
except Exception as e:
logger.error("Error during NostrClient shutdown: %s", e)

View File

@@ -2,16 +2,8 @@
import time
import logging
import traceback
try:
from monstr.event.event import Event
except ImportError: # pragma: no cover - optional dependency
class Event: # minimal placeholder for type hints when monstr is absent
id: str
created_at: int
content: str
from nostr_sdk import Event
# Instantiate the logger
@@ -27,26 +19,15 @@ class EventHandler:
pass # Initialize if needed
def handle_new_event(self, evt: Event):
"""
Processes incoming events by logging their details.
"""Process and log details from a Nostr event."""
:param evt: The received Event object.
"""
try:
# Assuming evt.created_at is always an integer Unix timestamp
if isinstance(evt.created_at, int):
created_at_str = time.strftime(
"%Y-%m-%d %H:%M:%S", time.gmtime(evt.created_at)
)
else:
# Handle unexpected types gracefully
created_at_str = str(evt.created_at)
created_at = evt.created_at().as_secs()
created_at_str = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(created_at))
event_id = evt.id().to_hex()
# Log the event details without extra newlines
logger.info(
f"[New Event] ID: {evt.id} | Created At: {created_at_str} | Content: {evt.content}"
f"[New Event] ID: {event_id} | Created At: {created_at_str} | Content: {evt.content()}"
)
except Exception as e:
logger.error(f"Error handling new event: {e}", exc_info=True)
# Optionally, handle the exception without re-raising
# For example, continue processing other events

View File

@@ -2,28 +2,36 @@
import hashlib
import logging
import traceback
from bech32 import bech32_encode, convertbits
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
from .coincurve_keys import Keys
# BIP-85 application numbers for Nostr key derivation
NOSTR_KEY_APP_ID = 1237
LEGACY_NOSTR_KEY_APP_ID = 0
logger = logging.getLogger(__name__)
class KeyManager:
"""
Manages key generation, encoding, and derivation for NostrClient.
"""
"""Manages key generation, encoding, and derivation for ``NostrClient``."""
def __init__(self, parent_seed: str, fingerprint: str):
"""
Initializes the KeyManager with the provided parent_seed and fingerprint.
def __init__(
self, parent_seed: str, fingerprint: str, account_index: int | None = None
):
"""Initialize the key manager.
Parameters:
parent_seed (str): The parent seed used for key derivation.
fingerprint (str): The fingerprint to differentiate key derivations.
Parameters
----------
parent_seed:
The BIP-39 seed used as the root for derivations.
fingerprint:
Seed profile fingerprint used for legacy derivations and logging.
account_index:
Optional explicit index for BIP-85 Nostr key derivation. When ``None``
the index defaults to ``0``.
"""
try:
if not isinstance(parent_seed, str):
@@ -37,12 +45,15 @@ class KeyManager:
self.parent_seed = parent_seed
self.fingerprint = fingerprint
logger.debug(f"KeyManager initialized with parent_seed and fingerprint.")
self.account_index = account_index
logger.debug(
"KeyManager initialized with parent_seed, fingerprint and account index."
)
# Initialize BIP85
self.bip85 = self.initialize_bip85()
# Generate Nostr keys using the fingerprint
# Generate Nostr keys using the provided account index
self.keys = self.generate_nostr_keys()
logger.debug("Nostr Keys initialized successfully.")
@@ -67,33 +78,47 @@ class KeyManager:
raise
def generate_nostr_keys(self) -> Keys:
"""
Derives a unique Nostr key pair for the given fingerprint using BIP-85.
Returns:
Keys: An instance of Keys containing the Nostr key pair.
"""
"""Derive a Nostr key pair using the configured ``account_index``."""
try:
# Convert fingerprint to an integer index (using a hash function)
index = int(hashlib.sha256(self.fingerprint.encode()).hexdigest(), 16) % (
2**31
)
index = self.account_index if self.account_index is not None else 0
# Derive entropy for Nostr key (32 bytes)
entropy_bytes = self.bip85.derive_entropy(
index=index,
bytes_len=32, # Adjust parameter name and value as per your method signature
index=index, entropy_bytes=32, app_no=NOSTR_KEY_APP_ID
)
# Generate Nostr key pair from entropy
private_key_hex = entropy_bytes.hex()
keys = Keys(priv_k=private_key_hex)
logger.debug(f"Nostr keys generated for fingerprint {self.fingerprint}.")
logger.debug("Nostr keys generated for account index %s", index)
return keys
except Exception as e:
logger.error(f"Failed to generate Nostr keys: {e}", exc_info=True)
raise
def generate_v1_nostr_keys(self) -> Keys:
"""Derive keys using the legacy fingerprint-hash method."""
try:
index = int(hashlib.sha256(self.fingerprint.encode()).hexdigest(), 16) % (
2**31
)
entropy_bytes = self.bip85.derive_entropy(
index=index, entropy_bytes=32, app_no=NOSTR_KEY_APP_ID
)
return Keys(priv_k=entropy_bytes.hex())
except Exception as e:
logger.error(f"Failed to generate v1 Nostr keys: {e}", exc_info=True)
raise
def generate_legacy_nostr_keys(self) -> Keys:
"""Derive Nostr keys using the legacy application ID."""
try:
entropy = self.bip85.derive_entropy(
index=0, entropy_bytes=32, app_no=LEGACY_NOSTR_KEY_APP_ID
)
return Keys(priv_k=entropy.hex())
except Exception as e:
logger.error(f"Failed to generate legacy Nostr keys: {e}", exc_info=True)
raise
def get_public_key_hex(self) -> str:
"""
Returns the public key in hexadecimal format.

View File

@@ -1,41 +0,0 @@
# nostr/logging_config.py
import logging
import os
# Comment out or remove the configure_logging function to avoid conflicts
# def configure_logging():
# """
# Configures logging with both file and console handlers.
# Logs include the timestamp, log level, message, filename, and line number.
# Only ERROR and higher-level messages are shown in the terminal, while all messages
# are logged in the log file.
# """
# logger = logging.getLogger()
# logger.setLevel(logging.DEBUG) # Set root logger to DEBUG
#
# # Prevent adding multiple handlers if configure_logging is called multiple times
# if not logger.handlers:
# # Create the 'logs' folder if it doesn't exist
# log_directory = 'logs'
# if not os.path.exists(log_directory):
# os.makedirs(log_directory)
#
# # Create handlers
# c_handler = logging.StreamHandler()
# f_handler = logging.FileHandler(os.path.join(log_directory, 'app.log'))
#
# # Set levels: only errors and critical messages will be shown in the console
# c_handler.setLevel(logging.ERROR)
# f_handler.setLevel(logging.DEBUG)
#
# # Create formatters and add them to handlers, include file and line number in log messages
# formatter = logging.Formatter(
# '%(asctime)s [%(levelname)s] %(message)s [%(filename)s:%(lineno)d]'
# )
# c_handler.setFormatter(formatter)
# f_handler.setFormatter(formatter)
#
# # Add handlers to the logger
# logger.addHandler(c_handler)
# logger.addHandler(f_handler)

443
src/nostr/snapshot.py Normal file
View File

@@ -0,0 +1,443 @@
import asyncio
import base64
import gzip
import hashlib
import hmac
import json
import logging
import os
import time
from datetime import timedelta
from typing import Tuple
from . import client as nostr_client
from constants import MAX_RETRIES, RETRY_DELAY
from .backup_models import (
ChunkMeta,
Manifest,
KIND_DELTA,
KIND_MANIFEST,
KIND_SNAPSHOT_CHUNK,
)
logger = logging.getLogger("nostr.client")
logger.setLevel(logging.WARNING)
def prepare_snapshot(
encrypted_bytes: bytes, limit: int
) -> Tuple[Manifest, list[bytes]]:
"""Compress and split the encrypted vault into chunks."""
compressed = gzip.compress(encrypted_bytes)
chunks = [compressed[i : i + limit] for i in range(0, len(compressed), limit)]
metas: list[ChunkMeta] = []
for i, chunk in enumerate(chunks):
metas.append(
ChunkMeta(
id=f"seedpass-chunk-{i:04d}",
size=len(chunk),
hash=hashlib.sha256(chunk).hexdigest(),
event_id=None,
)
)
manifest = Manifest(ver=1, algo="gzip", chunks=metas)
return manifest, chunks
def new_manifest_id(key_index: bytes) -> tuple[str, bytes]:
"""Return a new manifest identifier and nonce.
The identifier is computed as HMAC-SHA256 of ``b"manifest|" + nonce``
using ``key_index`` as the HMAC key. The nonce is returned so it can be
embedded inside the manifest itself.
"""
nonce = os.urandom(16)
digest = hmac.new(key_index, b"manifest|" + nonce, hashlib.sha256).hexdigest()
return digest, nonce
class SnapshotHandler:
"""Mixin providing chunk and manifest handling."""
async def publish_snapshot(
self, encrypted_bytes: bytes, limit: int = 50_000
) -> tuple[Manifest, str]:
start = time.perf_counter()
if self.offline_mode or not self.relays:
return Manifest(ver=1, algo="gzip", chunks=[]), ""
await self.ensure_manifest_is_current()
await self._connect_async()
manifest, chunks = prepare_snapshot(encrypted_bytes, limit)
existing: dict[str, str] = {}
if self.current_manifest:
for old in self.current_manifest.chunks:
if old.hash and old.event_id:
existing[old.hash] = old.event_id
for meta, chunk in zip(manifest.chunks, chunks):
cached_id = existing.get(meta.hash)
if cached_id:
meta.event_id = cached_id
continue
content = base64.b64encode(chunk).decode("utf-8")
builder = nostr_client.EventBuilder(
nostr_client.Kind(KIND_SNAPSHOT_CHUNK), content
).tags([nostr_client.Tag.identifier(meta.id)])
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
result = await self.client.send_event(event)
try:
meta.event_id = (
result.id.to_hex() if hasattr(result, "id") else str(result)
)
except Exception:
meta.event_id = None
if (
self.current_manifest_id
and self.current_manifest
and getattr(self.current_manifest, "nonce", None)
):
manifest_id = self.current_manifest_id
manifest.nonce = self.current_manifest.nonce
else:
manifest_id, nonce = new_manifest_id(self.key_index)
manifest.nonce = base64.b64encode(nonce).decode("utf-8")
manifest_json = json.dumps(
{
"ver": manifest.ver,
"algo": manifest.algo,
"chunks": [meta.__dict__ for meta in manifest.chunks],
"delta_since": manifest.delta_since,
"nonce": manifest.nonce,
}
)
manifest_event = (
nostr_client.EventBuilder(nostr_client.Kind(KIND_MANIFEST), manifest_json)
.tags([nostr_client.Tag.identifier(manifest_id)])
.build(self.keys.public_key())
.sign_with_keys(self.keys)
)
await self.client.send_event(manifest_event)
with self._state_lock:
self.current_manifest = manifest
self.current_manifest_id = manifest_id
self.current_manifest.delta_since = int(time.time())
self._delta_events = []
if getattr(self, "verbose_timing", False):
duration = time.perf_counter() - start
logger.info("publish_snapshot completed in %.2f seconds", duration)
return manifest, manifest_id
async def _fetch_chunks_with_retry(
self, manifest_event
) -> tuple[Manifest, list[bytes]] | None:
pubkey = self.keys.public_key()
timeout = timedelta(seconds=10)
try:
data = json.loads(manifest_event.content())
manifest = Manifest(
ver=data["ver"],
algo=data["algo"],
chunks=[ChunkMeta(**c) for c in data["chunks"]],
delta_since=(
int(data["delta_since"])
if data.get("delta_since") is not None
else None
),
nonce=data.get("nonce"),
)
except Exception:
return None
if self.config_manager is None:
from seedpass.core.config_manager import ConfigManager
from seedpass.core.vault import Vault
cfg_mgr = ConfigManager(
Vault(self.encryption_manager, self.fingerprint_dir),
self.fingerprint_dir,
)
else:
cfg_mgr = self.config_manager
cfg = cfg_mgr.load_config(require_pin=False)
max_retries = int(cfg.get("nostr_max_retries", MAX_RETRIES))
delay = float(cfg.get("nostr_retry_delay", RETRY_DELAY))
chunks: list[bytes] = []
for meta in manifest.chunks:
chunk_bytes: bytes | None = None
for attempt in range(max_retries):
cf = (
nostr_client.Filter()
.author(pubkey)
.kind(nostr_client.Kind(KIND_SNAPSHOT_CHUNK))
)
if meta.event_id:
cf = cf.id(nostr_client.EventId.parse(meta.event_id))
else:
cf = cf.identifier(meta.id)
cf = cf.limit(1)
cev = (await self.client.fetch_events(cf, timeout)).to_vec()
if cev:
candidate = base64.b64decode(cev[0].content().encode("utf-8"))
if hashlib.sha256(candidate).hexdigest() == meta.hash:
chunk_bytes = candidate
break
if attempt < max_retries - 1:
await asyncio.sleep(delay * (2**attempt))
if chunk_bytes is None:
return None
chunks.append(chunk_bytes)
ident = None
try:
tags_obj = manifest_event.tags()
ident = tags_obj.identifier()
except Exception:
tags = getattr(manifest_event, "tags", None)
if callable(tags):
tags = tags()
if tags:
tag = tags[0]
if hasattr(tag, "as_vec"):
vec = tag.as_vec()
if vec and len(vec) >= 2:
ident = vec[1]
elif isinstance(tag, (list, tuple)) and len(tag) >= 2:
ident = tag[1]
elif isinstance(tag, str):
ident = tag
with self._state_lock:
self.current_manifest = manifest
self.current_manifest_id = ident
return manifest, chunks
async def _fetch_manifest_with_keys(
self, keys_obj: nostr_client.Keys
) -> tuple[Manifest, list[bytes]] | None:
"""Retrieve the manifest and chunks using ``keys_obj``."""
self.keys = keys_obj
pubkey = self.keys.public_key()
timeout = timedelta(seconds=10)
ident = self.current_manifest_id
f = nostr_client.Filter().author(pubkey).kind(nostr_client.Kind(KIND_MANIFEST))
if ident:
f = f.identifier(ident)
f = f.limit(1)
try:
events = (await self.client.fetch_events(f, timeout)).to_vec()
except Exception as e: # pragma: no cover - network errors
self.last_error = str(e)
logger.error(
"Failed to fetch manifest from relays %s: %s",
self.relays,
e,
)
return None
if not events and ident:
f = (
nostr_client.Filter()
.author(pubkey)
.kind(nostr_client.Kind(KIND_MANIFEST))
.limit(1)
)
try:
events = (await self.client.fetch_events(f, timeout)).to_vec()
except Exception as e: # pragma: no cover - network errors
self.last_error = str(e)
logger.error(
"Failed to fetch manifest from relays %s: %s",
self.relays,
e,
)
return None
if not events:
return None
for manifest_event in events:
try:
result = await self._fetch_chunks_with_retry(manifest_event)
if result is not None:
return result
except Exception as e: # pragma: no cover - network errors
self.last_error = str(e)
logger.error(
"Error retrieving snapshot from relays %s: %s",
self.relays,
e,
)
return None
async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None:
"""Retrieve the latest manifest and all snapshot chunks."""
if self.offline_mode or not self.relays:
return None
await self._connect_async()
self.last_error = None
logger.debug("Searching for backup with current keys...")
try:
primary_keys = nostr_client.Keys.parse(
self.key_manager.keys.private_key_hex()
)
except Exception:
primary_keys = self.keys
result = await self._fetch_manifest_with_keys(primary_keys)
if result is not None:
return result
logger.warning(
"No backup found with current keys. Falling back to legacy key derivation..."
)
try:
legacy_keys = self.key_manager.generate_legacy_nostr_keys()
legacy_sdk_keys = nostr_client.Keys.parse(legacy_keys.private_key_hex())
except Exception as e:
self.last_error = str(e)
return None
result = await self._fetch_manifest_with_keys(legacy_sdk_keys)
if result is not None:
logger.info("Found legacy backup with old key derivation.")
return result
if self.last_error is None:
self.last_error = "No backup found on Nostr relays."
return None
async def ensure_manifest_is_current(self) -> None:
"""Verify the local manifest is up to date before publishing."""
if self.offline_mode or not self.relays:
return
await self._connect_async()
pubkey = self.keys.public_key()
ident = self.current_manifest_id
if ident is None:
return
f = (
nostr_client.Filter()
.author(pubkey)
.kind(nostr_client.Kind(KIND_MANIFEST))
.identifier(ident)
.limit(1)
)
timeout = timedelta(seconds=10)
try:
events = (await self.client.fetch_events(f, timeout)).to_vec()
except Exception:
return
if not events:
return
try:
data = json.loads(events[0].content())
remote = data.get("delta_since")
if remote is not None:
remote = int(remote)
except Exception:
return
with self._state_lock:
local = self.current_manifest.delta_since if self.current_manifest else None
if remote is not None and (local is None or remote > local):
self.last_error = "Manifest out of date"
raise RuntimeError("Manifest out of date")
async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str:
if self.offline_mode or not self.relays:
return ""
await self.ensure_manifest_is_current()
await self._connect_async()
content = base64.b64encode(delta_bytes).decode("utf-8")
tag = nostr_client.Tag.event(nostr_client.EventId.parse(manifest_id))
builder = nostr_client.EventBuilder(
nostr_client.Kind(KIND_DELTA), content
).tags([tag])
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
result = await self.client.send_event(event)
delta_id = result.id.to_hex() if hasattr(result, "id") else str(result)
created_at = getattr(
event, "created_at", getattr(event, "timestamp", int(time.time()))
)
if hasattr(created_at, "secs"):
created_at = created_at.secs
manifest_event = None
with self._state_lock:
if self.current_manifest is not None:
self.current_manifest.delta_since = int(created_at)
manifest_json = json.dumps(
{
"ver": self.current_manifest.ver,
"algo": self.current_manifest.algo,
"chunks": [
meta.__dict__ for meta in self.current_manifest.chunks
],
"delta_since": self.current_manifest.delta_since,
"nonce": self.current_manifest.nonce,
}
)
manifest_event = (
nostr_client.EventBuilder(
nostr_client.Kind(KIND_MANIFEST), manifest_json
)
.tags([nostr_client.Tag.identifier(self.current_manifest_id)])
.build(self.keys.public_key())
.sign_with_keys(self.keys)
)
self._delta_events.append(delta_id)
if manifest_event is not None:
await self.client.send_event(manifest_event)
return delta_id
async def fetch_deltas_since(self, version: int) -> list[bytes]:
if self.offline_mode or not self.relays:
return []
await self._connect_async()
pubkey = self.keys.public_key()
f = (
nostr_client.Filter()
.author(pubkey)
.kind(nostr_client.Kind(KIND_DELTA))
.since(nostr_client.Timestamp.from_secs(version))
)
timeout = timedelta(seconds=10)
events = (await self.client.fetch_events(f, timeout)).to_vec()
events.sort(
key=lambda ev: getattr(ev, "created_at", getattr(ev, "timestamp", 0))
)
deltas: list[bytes] = []
for ev in events:
deltas.append(base64.b64decode(ev.content().encode("utf-8")))
manifest = self.get_current_manifest()
if manifest is not None:
snap_size = sum(c.size for c in manifest.chunks)
if (
len(deltas) >= self.delta_threshold
or sum(len(d) for d in deltas) > snap_size
):
joined = b"".join(deltas)
await self.publish_snapshot(joined)
exp = nostr_client.Timestamp.from_secs(int(time.time()))
for ev in events:
exp_builder = nostr_client.EventBuilder(
nostr_client.Kind(KIND_DELTA), ev.content()
).tags([nostr_client.Tag.expiration(exp)])
exp_event = exp_builder.build(
self.keys.public_key()
).sign_with_keys(self.keys)
await self.client.send_event(exp_event)
return deltas
def get_current_manifest(self) -> Manifest | None:
with self._state_lock:
return self.current_manifest
def get_current_manifest_id(self) -> str | None:
with self._state_lock:
return self.current_manifest_id
def get_delta_events(self) -> list[str]:
with self._state_lock:
return list(self._delta_events)

View File

@@ -1,8 +0,0 @@
# nostr/utils.py
import logging
# Example utility function (if any specific to nostr package)
def some_helper_function():
pass # Implement as needed

View File

@@ -1,322 +0,0 @@
# /src/password_manager/encryption.py
import logging
import traceback
try:
import orjson as json_lib # type: ignore
JSONDecodeError = orjson.JSONDecodeError
USE_ORJSON = True
except Exception: # pragma: no cover - fallback for environments without orjson
import json as json_lib
from json import JSONDecodeError
USE_ORJSON = False
import hashlib
import os
import base64
from pathlib import Path
from typing import Optional
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag
from cryptography.fernet import Fernet, InvalidToken
from termcolor import colored
from utils.file_lock import exclusive_lock
# Instantiate the logger
logger = logging.getLogger(__name__)
class EncryptionManager:
"""
Manages encryption and decryption, handling migration from legacy Fernet
to modern AES-GCM.
"""
def __init__(self, encryption_key: bytes, fingerprint_dir: Path):
"""
Initializes the EncryptionManager with keys for both new (AES-GCM)
and legacy (Fernet) encryption formats.
Parameters:
encryption_key (bytes): A base64-encoded key.
fingerprint_dir (Path): The directory corresponding to the fingerprint.
"""
self.fingerprint_dir = fingerprint_dir
self.parent_seed_file = self.fingerprint_dir / "parent_seed.enc"
try:
if isinstance(encryption_key, str):
encryption_key = encryption_key.encode()
# (1) Keep both the legacy Fernet instance and the new AES-GCM cipher ready.
self.key_b64 = encryption_key
self.fernet = Fernet(self.key_b64)
self.key = base64.urlsafe_b64decode(self.key_b64)
self.cipher = AESGCM(self.key)
logger.debug(f"EncryptionManager initialized for {self.fingerprint_dir}")
except Exception as e:
logger.error(
f"Failed to initialize ciphers with provided encryption key: {e}",
exc_info=True,
)
raise
def encrypt_data(self, data: bytes) -> bytes:
"""
(2) Encrypts data using the NEW AES-GCM format, prepending a version
header and the nonce. All new data will be in this format.
"""
try:
nonce = os.urandom(12) # 96-bit nonce is recommended for AES-GCM
ciphertext = self.cipher.encrypt(nonce, data, None)
return b"V2:" + nonce + ciphertext
except Exception as e:
logger.error(f"Failed to encrypt data: {e}", exc_info=True)
raise
def decrypt_data(self, encrypted_data: bytes) -> bytes:
"""
(3) The core migration logic. Tries the new format first, then falls back
to the old one. This is the ONLY place decryption logic should live.
"""
# Try the new V2 format first
if encrypted_data.startswith(b"V2:"):
try:
nonce = encrypted_data[3:15]
ciphertext = encrypted_data[15:]
if len(ciphertext) < 16:
logger.error("AES-GCM payload too short")
raise InvalidToken("AES-GCM payload too short")
return self.cipher.decrypt(nonce, ciphertext, None)
except InvalidTag as e:
logger.error("AES-GCM decryption failed: Invalid authentication tag.")
try:
result = self.fernet.decrypt(encrypted_data[3:])
logger.warning(
"Legacy-format file had incorrect 'V2:' header; decrypted with Fernet"
)
return result
except InvalidToken:
raise InvalidToken("AES-GCM decryption failed.") from e
# If it's not V2, it must be the legacy Fernet format
else:
logger.warning("Data is in legacy Fernet format. Attempting migration.")
try:
return self.fernet.decrypt(encrypted_data)
except InvalidToken as e:
logger.error(
"Legacy Fernet decryption failed. Vault may be corrupt or key is incorrect."
)
raise InvalidToken(
"Could not decrypt data with any available method."
) from e
# --- All functions below this point now use the smart `decrypt_data` method ---
def encrypt_parent_seed(self, parent_seed: str) -> None:
"""Encrypts and saves the parent seed to 'parent_seed.enc'."""
data = parent_seed.encode("utf-8")
encrypted_data = self.encrypt_data(data) # This now creates V2 format
with exclusive_lock(self.parent_seed_file) as fh:
fh.seek(0)
fh.truncate()
fh.write(encrypted_data)
os.chmod(self.parent_seed_file, 0o600)
logger.info(f"Parent seed encrypted and saved to '{self.parent_seed_file}'.")
def decrypt_parent_seed(self) -> str:
"""Decrypts and returns the parent seed, handling migration."""
with exclusive_lock(self.parent_seed_file) as fh:
fh.seek(0)
encrypted_data = fh.read()
is_legacy = not encrypted_data.startswith(b"V2:")
decrypted_data = self.decrypt_data(encrypted_data)
if is_legacy:
logger.info("Parent seed was in legacy format. Re-encrypting to V2 format.")
self.encrypt_parent_seed(decrypted_data.decode("utf-8").strip())
return decrypted_data.decode("utf-8").strip()
def encrypt_and_save_file(self, data: bytes, relative_path: Path) -> None:
file_path = self.fingerprint_dir / relative_path
file_path.parent.mkdir(parents=True, exist_ok=True)
encrypted_data = self.encrypt_data(data)
with exclusive_lock(file_path) as fh:
fh.seek(0)
fh.truncate()
fh.write(encrypted_data)
fh.flush()
os.fsync(fh.fileno())
os.chmod(file_path, 0o600)
def decrypt_file(self, relative_path: Path) -> bytes:
file_path = self.fingerprint_dir / relative_path
with exclusive_lock(file_path) as fh:
fh.seek(0)
encrypted_data = fh.read()
return self.decrypt_data(encrypted_data)
def save_json_data(self, data: dict, relative_path: Optional[Path] = None) -> None:
if relative_path is None:
relative_path = Path("seedpass_entries_db.json.enc")
if USE_ORJSON:
json_data = json_lib.dumps(data)
else:
json_data = json_lib.dumps(data, separators=(",", ":")).encode("utf-8")
self.encrypt_and_save_file(json_data, relative_path)
logger.debug(f"JSON data encrypted and saved to '{relative_path}'.")
def load_json_data(self, relative_path: Optional[Path] = None) -> dict:
"""
Loads and decrypts JSON data, automatically migrating and re-saving
if it's in the legacy format.
"""
if relative_path is None:
relative_path = Path("seedpass_entries_db.json.enc")
file_path = self.fingerprint_dir / relative_path
if not file_path.exists():
return {"entries": {}}
with exclusive_lock(file_path) as fh:
fh.seek(0)
encrypted_data = fh.read()
is_legacy = not encrypted_data.startswith(b"V2:")
try:
decrypted_data = self.decrypt_data(encrypted_data)
if USE_ORJSON:
data = json_lib.loads(decrypted_data)
else:
data = json_lib.loads(decrypted_data.decode("utf-8"))
# If it was a legacy file, re-save it in the new format now
if is_legacy:
logger.info(f"Migrating and re-saving legacy vault file: {file_path}")
self.save_json_data(data, relative_path)
self.update_checksum(relative_path)
return data
except (InvalidToken, InvalidTag, JSONDecodeError) as e:
logger.error(
f"FATAL: Could not decrypt or parse data from {file_path}: {e}",
exc_info=True,
)
raise
def get_encrypted_index(self) -> Optional[bytes]:
relative_path = Path("seedpass_entries_db.json.enc")
file_path = self.fingerprint_dir / relative_path
if not file_path.exists():
return None
with exclusive_lock(file_path) as fh:
fh.seek(0)
return fh.read()
def decrypt_and_save_index_from_nostr(
self, encrypted_data: bytes, relative_path: Optional[Path] = None
) -> None:
"""Decrypts data from Nostr and saves it, automatically using the new format."""
if relative_path is None:
relative_path = Path("seedpass_entries_db.json.enc")
try:
decrypted_data = self.decrypt_data(
encrypted_data
) # This now handles both formats
if USE_ORJSON:
data = json_lib.loads(decrypted_data)
else:
data = json_lib.loads(decrypted_data.decode("utf-8"))
self.save_json_data(data, relative_path) # This always saves in V2 format
self.update_checksum(relative_path)
logger.info("Index file from Nostr was processed and saved successfully.")
print(colored("Index file updated from Nostr successfully.", "green"))
except Exception as e:
logger.error(
f"Failed to decrypt and save data from Nostr: {e}",
exc_info=True,
)
print(
colored(
f"Error: Failed to decrypt and save data from Nostr: {e}",
"red",
)
)
raise
def update_checksum(self, relative_path: Optional[Path] = None) -> None:
"""Updates the checksum file for the specified file."""
if relative_path is None:
relative_path = Path("seedpass_entries_db.json.enc")
file_path = self.fingerprint_dir / relative_path
if not file_path.exists():
return
try:
with exclusive_lock(file_path) as fh:
fh.seek(0)
encrypted_bytes = fh.read()
checksum = hashlib.sha256(encrypted_bytes).hexdigest()
checksum_file = file_path.parent / f"{file_path.stem}_checksum.txt"
with exclusive_lock(checksum_file) as fh:
fh.seek(0)
fh.truncate()
fh.write(checksum.encode("utf-8"))
fh.flush()
os.fsync(fh.fileno())
os.chmod(checksum_file, 0o600)
except Exception as e:
logger.error(
f"Failed to update checksum for '{relative_path}': {e}",
exc_info=True,
)
raise
# ... validate_seed and derive_seed_from_mnemonic can remain the same ...
def validate_seed(self, seed_phrase: str) -> bool:
try:
words = seed_phrase.split()
if len(words) != 12:
logger.error("Seed phrase does not contain exactly 12 words.")
print(
colored(
"Error: Seed phrase must contain exactly 12 words.",
"red",
)
)
return False
logger.debug("Seed phrase validated successfully.")
return True
except Exception as e:
logging.error(f"Error validating seed phrase: {e}", exc_info=True)
print(colored(f"Error: Failed to validate seed phrase: {e}", "red"))
return False
def derive_seed_from_mnemonic(self, mnemonic: str, passphrase: str = "") -> bytes:
try:
if not isinstance(mnemonic, str):
if isinstance(mnemonic, list):
mnemonic = " ".join(mnemonic)
else:
mnemonic = str(mnemonic)
if not isinstance(mnemonic, str):
raise TypeError("Mnemonic must be a string after conversion")
from bip_utils import Bip39SeedGenerator
seed = Bip39SeedGenerator(mnemonic).Generate(passphrase)
logger.debug("Seed derived successfully from mnemonic.")
return seed
except Exception as e:
logger.error(f"Failed to derive seed from mnemonic: {e}", exc_info=True)
print(colored(f"Error: Failed to derive seed from mnemonic: {e}", "red"))
raise

View File

@@ -1,74 +0,0 @@
"""Vault utilities for reading and writing encrypted files."""
from pathlib import Path
from typing import Optional, Union
from os import PathLike
from .encryption import EncryptionManager
class Vault:
"""Simple wrapper around :class:`EncryptionManager` for vault storage."""
INDEX_FILENAME = "seedpass_entries_db.json.enc"
CONFIG_FILENAME = "seedpass_config.json.enc"
def __init__(
self,
encryption_manager: EncryptionManager,
fingerprint_dir: Union[str, PathLike[str], Path],
):
self.encryption_manager = encryption_manager
self.fingerprint_dir = Path(fingerprint_dir)
self.index_file = self.fingerprint_dir / self.INDEX_FILENAME
self.config_file = self.fingerprint_dir / self.CONFIG_FILENAME
def set_encryption_manager(self, manager: EncryptionManager) -> None:
"""Replace the internal encryption manager."""
self.encryption_manager = manager
# ----- Password index helpers -----
def load_index(self) -> dict:
"""Return decrypted password index data as a dict, applying migrations."""
legacy_file = self.fingerprint_dir / "seedpass_passwords_db.json.enc"
if legacy_file.exists() and not self.index_file.exists():
legacy_checksum = (
self.fingerprint_dir / "seedpass_passwords_db_checksum.txt"
)
legacy_file.rename(self.index_file)
if legacy_checksum.exists():
legacy_checksum.rename(
self.fingerprint_dir / "seedpass_entries_db_checksum.txt"
)
data = self.encryption_manager.load_json_data(self.index_file)
from .migrations import apply_migrations, LATEST_VERSION
version = data.get("schema_version", 0)
if version > LATEST_VERSION:
raise ValueError(
f"File schema version {version} is newer than supported {LATEST_VERSION}"
)
data = apply_migrations(data)
return data
def save_index(self, data: dict) -> None:
"""Encrypt and write password index."""
self.encryption_manager.save_json_data(data, self.index_file)
def get_encrypted_index(self) -> Optional[bytes]:
"""Return the encrypted index bytes if present."""
return self.encryption_manager.get_encrypted_index()
def decrypt_and_save_index_from_nostr(self, encrypted_data: bytes) -> None:
"""Decrypt Nostr payload and overwrite the local index."""
self.encryption_manager.decrypt_and_save_index_from_nostr(encrypted_data)
# ----- Config helpers -----
def load_config(self) -> dict:
"""Load decrypted configuration."""
return self.encryption_manager.load_json_data(self.config_file)
def save_config(self, config: dict) -> None:
"""Encrypt and persist configuration."""
self.encryption_manager.save_json_data(config, self.config_file)

View File

@@ -28,7 +28,6 @@ Generated on: 2025-04-06
├── encryption_manager.py
├── event_handler.py
├── key_manager.py
├── logging_config.py
├── utils.py
├── utils/
├── __init__.py
@@ -3082,52 +3081,6 @@ __all__ = ['NostrClient']
```
## nostr/logging_config.py
```python
# nostr/logging_config.py
import logging
import os
# Comment out or remove the configure_logging function to avoid conflicts
# def configure_logging():
# """
# Configures logging with both file and console handlers.
# Logs include the timestamp, log level, message, filename, and line number.
# Only ERROR and higher-level messages are shown in the terminal, while all messages
# are logged in the log file.
# """
# logger = logging.getLogger()
# logger.setLevel(logging.DEBUG) # Set root logger to DEBUG
#
# # Prevent adding multiple handlers if configure_logging is called multiple times
# if not logger.handlers:
# # Create the 'logs' folder if it doesn't exist
# log_directory = 'logs'
# if not os.path.exists(log_directory):
# os.makedirs(log_directory)
#
# # Create handlers
# c_handler = logging.StreamHandler()
# f_handler = logging.FileHandler(os.path.join(log_directory, 'app.log'))
#
# # Set levels: only errors and critical messages will be shown in the console
# c_handler.setLevel(logging.ERROR)
# f_handler.setLevel(logging.DEBUG)
#
# # Create formatters and add them to handlers, include file and line number in log messages
# formatter = logging.Formatter(
# '%(asctime)s [%(levelname)s] %(message)s [%(filename)s:%(lineno)d]'
# )
# c_handler.setFormatter(formatter)
# f_handler.setFormatter(formatter)
#
# # Add handlers to the logger
# logger.addHandler(c_handler)
# logger.addHandler(f_handler)
```
## nostr/event_handler.py
```python
# nostr/event_handler.py

View File

@@ -1,34 +1,42 @@
colorama>=0.4.6
termcolor>=1.1.0
cryptography>=40.0.2
bip-utils>=2.5.0
bech32==1.2.0
coincurve>=18.0.0
mnemonic
aiohttp>=3.12.14
bcrypt
pytest>=7.0
pytest-cov
pytest-xdist
portalocker>=2.8
nostr-sdk>=0.42.1
websocket-client==1.7.0
colorama>=0.4.6,<1
termcolor>=1.1.0,<4
cryptography>=40.0.2,<46
bip-utils>=2.5.0,<3
bech32>=1.2,<2
coincurve>=18.0.0,<22
mnemonic>=0.21,<1
aiohttp>=3.9,<4
bcrypt>=4,<5
pytest>=7,<9
pytest-cov>=4,<7
pytest-xdist>=3,<4
portalocker>=2.8,<4
nostr-sdk>=0.43,<1
websocket-client>=1.7,<2
websockets>=15.0.0
tomli
hypothesis
mutmut==2.4.4
pgpy==0.6.0
pyotp>=2.8.0
websockets>=15,<16
tomli>=2,<3
hypothesis>=6,<7
mutmut>=2.4.4,<4
pgpy>=0.6,<1
pyotp>=2.8,<3
freezegun
pyperclip
qrcode>=8.2
typer>=0.12.3
fastapi>=0.116.0
uvicorn>=0.35.0
httpx>=0.28.1
requests>=2.32
python-multipart
orjson
argon2-cffi
freezegun>=1.5.4,<2
typer>=0.12.3,<1
# Optional dependencies - install as needed for additional features
pyperclip>=1.9,<2 # Clipboard support for secret mode
qrcode>=8.2,<9 # Generate QR codes for TOTP setup
fastapi>=0.110,<1 # API server
uvicorn>=0.29,<1 # API server
starlette>=0.47.2,<1 # API server
httpx>=0.28.1,<1 # API server
requests>=2.32,<3 # API server
python-multipart>=0.0.20,<0.1 # API server file uploads
PyJWT>=2.10.1,<3 # JWT authentication for API server
orjson>=3.11.1,<4 # Fast JSON serialization for API server
argon2-cffi>=21,<26 # Password hashing for API server
toga-core>=0.5.2,<1 # Desktop GUI
pillow>=11.3,<12 # Image support for GUI
toga-dummy>=0.5.2,<1 # Headless GUI tests
slowapi>=0.1.9,<1 # Rate limiting for API server

View File

@@ -0,0 +1,35 @@
# Runtime dependencies for vendoring/packaging only
# Generated from requirements.txt with all test-only packages removed
colorama>=0.4.6,<1
termcolor>=1.1.0,<4
cryptography>=40.0.2,<46
bip-utils>=2.5.0,<3
bech32>=1.2,<2
coincurve>=18.0.0,<22
mnemonic>=0.21,<1
aiohttp>=3.9,<4
bcrypt>=4,<5
portalocker>=2.8,<4
nostr-sdk>=0.43,<1
websocket-client>=1.7,<2
websockets>=15,<16
tomli>=2,<3
pgpy>=0.6,<1
pyotp>=2.8,<3
pyperclip>=1.9,<2
qrcode>=8.2,<9
typer>=0.12.3,<1
fastapi>=0.110,<1
uvicorn>=0.29,<1
starlette>=0.47.2,<1
httpx>=0.28.1,<1
requests>=2.32,<3
python-multipart>=0.0.20,<0.1
PyJWT>=2.10.1,<3
orjson>=3.11.1,<4
argon2-cffi>=21,<26
toga-core>=0.5.2,<1
pillow>=11.3,<12
toga-dummy>=0.5.2,<1
slowapi>=0.1.9,<1

View File

@@ -3,4 +3,3 @@ selected_directories:
- utils/
- nostr/
- local_bip85/
- password_manager/

View File

@@ -0,0 +1,3 @@
"""SeedPass package initialization."""
# Optionally re-export selected symbols here.

View File

@@ -9,55 +9,84 @@ import secrets
import queue
from typing import Any, List, Optional
import logging
from fastapi import FastAPI, Header, HTTPException, Request, Response
from fastapi.concurrency import run_in_threadpool
import asyncio
import sys
from fastapi.middleware.cors import CORSMiddleware
from password_manager.manager import PasswordManager
from password_manager.entry_types import EntryType
import bcrypt
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from slowapi.middleware import SlowAPIMiddleware
from seedpass.core.manager import PasswordManager
from seedpass.core.entry_types import EntryType
from seedpass.core.api import UtilityService
_RATE_LIMIT = int(os.getenv("SEEDPASS_RATE_LIMIT", "100"))
_RATE_WINDOW = int(os.getenv("SEEDPASS_RATE_WINDOW", "60"))
_RATE_LIMIT_STR = f"{_RATE_LIMIT}/{_RATE_WINDOW} seconds"
limiter = Limiter(key_func=get_remote_address, default_limits=[_RATE_LIMIT_STR])
app = FastAPI()
_pm: Optional[PasswordManager] = None
_token: str = ""
logger = logging.getLogger(__name__)
def _check_token(auth: str | None) -> None:
if auth != f"Bearer {_token}":
def _get_pm(request: Request) -> PasswordManager:
pm = getattr(request.app.state, "pm", None)
assert pm is not None
return pm
def _check_token(request: Request, auth: str | None) -> None:
if auth is None or not auth.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Unauthorized")
token = auth.split(" ", 1)[1].encode()
token_hash = getattr(request.app.state, "token_hash", b"")
if not token_hash or not bcrypt.checkpw(token, token_hash):
raise HTTPException(status_code=401, detail="Unauthorized")
def _reload_relays(relays: list[str]) -> None:
def _reload_relays(request: Request, relays: list[str]) -> None:
"""Reload the Nostr client with a new relay list."""
assert _pm is not None
pm = _get_pm(request)
try:
_pm.nostr_client.close_client_pool()
except Exception:
pass
pm.nostr_client.close_client_pool()
except (OSError, RuntimeError, ValueError) as exc:
logger.warning("Failed to close NostrClient pool: %s", exc)
try:
_pm.nostr_client.relays = relays
_pm.nostr_client.initialize_client_pool()
except Exception:
pass
pm.nostr_client.relays = relays
pm.nostr_client.initialize_client_pool()
except (OSError, RuntimeError, ValueError) as exc:
logger.error("Failed to initialize NostrClient with relays %s: %s", relays, exc)
def start_server(fingerprint: str | None = None) -> str:
"""Initialize global state and return the API token.
"""Initialize global state and return a random API token.
Parameters
----------
fingerprint:
Optional seed profile fingerprint to select before starting the server.
"""
global _pm, _token
if fingerprint is None:
_pm = PasswordManager()
pm = PasswordManager()
else:
_pm = PasswordManager(fingerprint=fingerprint)
_token = secrets.token_urlsafe(16)
print(f"API token: {_token}")
pm = PasswordManager(fingerprint=fingerprint)
app.state.pm = pm
raw_token = secrets.token_urlsafe(32)
app.state.token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt())
if not getattr(app.state, "limiter", None):
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)
origins = [
o.strip()
for o in os.getenv("SEEDPASS_CORS_ORIGINS", "").split(",")
@@ -70,14 +99,35 @@ def start_server(fingerprint: str | None = None) -> str:
allow_methods=["*"],
allow_headers=["*"],
)
return _token
return raw_token
def _require_password(request: Request, password: str | None) -> None:
pm = _get_pm(request)
if password is None or not pm.verify_password(password):
raise HTTPException(status_code=401, detail="Invalid password")
def _validate_encryption_path(request: Request, path: Path) -> Path:
"""Validate and normalize ``path`` within the active fingerprint directory.
Returns the resolved absolute path if validation succeeds.
"""
pm = _get_pm(request)
try:
return pm.encryption_manager.resolve_relative_path(path)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/api/v1/entry")
def search_entry(query: str, authorization: str | None = Header(None)) -> List[Any]:
_check_token(authorization)
assert _pm is not None
results = _pm.entry_manager.search_entries(query)
async def search_entry(
request: Request, query: str, authorization: str | None = Header(None)
) -> List[Any]:
_check_token(request, authorization)
pm = _get_pm(request)
results = await run_in_threadpool(pm.entry_manager.search_entries, query)
return [
{
"id": idx,
@@ -85,23 +135,31 @@ def search_entry(query: str, authorization: str | None = Header(None)) -> List[A
"username": username,
"url": url,
"archived": archived,
"type": etype.value,
}
for idx, label, username, url, archived in results
for idx, label, username, url, archived, etype in results
]
@app.get("/api/v1/entry/{entry_id}")
def get_entry(entry_id: int, authorization: str | None = Header(None)) -> Any:
_check_token(authorization)
assert _pm is not None
entry = _pm.entry_manager.retrieve_entry(entry_id)
async def get_entry(
request: Request,
entry_id: int,
authorization: str | None = Header(None),
password: str | None = Header(None, alias="X-SeedPass-Password"),
) -> Any:
_check_token(request, authorization)
_require_password(request, password)
pm = _get_pm(request)
entry = await run_in_threadpool(pm.entry_manager.retrieve_entry, entry_id)
if entry is None:
raise HTTPException(status_code=404, detail="Not found")
return entry
@app.post("/api/v1/entry")
def create_entry(
async def create_entry(
request: Request,
entry: dict,
authorization: str | None = Header(None),
) -> dict[str, Any]:
@@ -111,38 +169,56 @@ def create_entry(
on, the corresponding entry type is created. When omitted or set to
``password`` the behaviour matches the legacy password-entry API.
"""
_check_token(authorization)
assert _pm is not None
_check_token(request, authorization)
pm = _get_pm(request)
etype = (entry.get("type") or entry.get("kind") or "password").lower()
if etype == "password":
index = _pm.entry_manager.add_entry(
policy_keys = [
"include_special_chars",
"allowed_special_chars",
"special_mode",
"exclude_ambiguous",
"min_uppercase",
"min_lowercase",
"min_digits",
"min_special",
]
kwargs = {k: entry.get(k) for k in policy_keys if entry.get(k) is not None}
index = await run_in_threadpool(
pm.entry_manager.add_entry,
entry.get("label"),
int(entry.get("length", 12)),
entry.get("username"),
entry.get("url"),
**kwargs,
)
return {"id": index}
if etype == "totp":
index = _pm.entry_manager.get_next_index()
uri = _pm.entry_manager.add_totp(
index = await run_in_threadpool(pm.entry_manager.get_next_index)
uri = await run_in_threadpool(
pm.entry_manager.add_totp,
entry.get("label"),
_pm.parent_seed,
pm.KEY_TOTP_DET if entry.get("deterministic", False) else None,
secret=entry.get("secret"),
index=entry.get("index"),
period=int(entry.get("period", 30)),
digits=int(entry.get("digits", 6)),
notes=entry.get("notes", ""),
archived=entry.get("archived", False),
deterministic=entry.get("deterministic", False),
)
return {"id": index, "uri": uri}
if etype == "ssh":
index = _pm.entry_manager.add_ssh_key(
index = await run_in_threadpool(
pm.entry_manager.add_ssh_key,
entry.get("label"),
_pm.parent_seed,
pm.parent_seed,
index=entry.get("index"),
notes=entry.get("notes", ""),
archived=entry.get("archived", False),
@@ -150,9 +226,10 @@ def create_entry(
return {"id": index}
if etype == "pgp":
index = _pm.entry_manager.add_pgp_key(
index = await run_in_threadpool(
pm.entry_manager.add_pgp_key,
entry.get("label"),
_pm.parent_seed,
pm.parent_seed,
index=entry.get("index"),
key_type=entry.get("key_type", "ed25519"),
user_id=entry.get("user_id", ""),
@@ -162,8 +239,10 @@ def create_entry(
return {"id": index}
if etype == "nostr":
index = _pm.entry_manager.add_nostr_key(
index = await run_in_threadpool(
pm.entry_manager.add_nostr_key,
entry.get("label"),
pm.parent_seed,
index=entry.get("index"),
notes=entry.get("notes", ""),
archived=entry.get("archived", False),
@@ -171,8 +250,10 @@ def create_entry(
return {"id": index}
if etype == "key_value":
index = _pm.entry_manager.add_key_value(
index = await run_in_threadpool(
pm.entry_manager.add_key_value,
entry.get("label"),
entry.get("key"),
entry.get("value"),
notes=entry.get("notes", ""),
)
@@ -180,13 +261,14 @@ def create_entry(
if etype in {"seed", "managed_account"}:
func = (
_pm.entry_manager.add_seed
pm.entry_manager.add_seed
if etype == "seed"
else _pm.entry_manager.add_managed_account
else pm.entry_manager.add_managed_account
)
index = func(
index = await run_in_threadpool(
func,
entry.get("label"),
_pm.parent_seed,
pm.parent_seed,
index=entry.get("index"),
notes=entry.get("notes", ""),
)
@@ -197,6 +279,7 @@ def create_entry(
@app.put("/api/v1/entry/{entry_id}")
def update_entry(
request: Request,
entry_id: int,
entry: dict,
authorization: str | None = Header(None),
@@ -206,10 +289,10 @@ def update_entry(
Additional fields like ``period``, ``digits`` and ``value`` are forwarded for
specialized entry types (e.g. TOTP or key/value entries).
"""
_check_token(authorization)
assert _pm is not None
_check_token(request, authorization)
pm = _get_pm(request)
try:
_pm.entry_manager.modify_entry(
pm.entry_manager.modify_entry(
entry_id,
username=entry.get("username"),
url=entry.get("url"),
@@ -217,6 +300,7 @@ def update_entry(
label=entry.get("label"),
period=entry.get("period"),
digits=entry.get("digits"),
key=entry.get("key"),
value=entry.get("value"),
)
except ValueError as e:
@@ -226,31 +310,34 @@ def update_entry(
@app.post("/api/v1/entry/{entry_id}/archive")
def archive_entry(
entry_id: int, authorization: str | None = Header(None)
request: Request, entry_id: int, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Archive an entry."""
_check_token(authorization)
assert _pm is not None
_pm.entry_manager.archive_entry(entry_id)
_check_token(request, authorization)
pm = _get_pm(request)
pm.entry_manager.archive_entry(entry_id)
return {"status": "archived"}
@app.post("/api/v1/entry/{entry_id}/unarchive")
def unarchive_entry(
entry_id: int, authorization: str | None = Header(None)
request: Request, entry_id: int, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Restore an archived entry."""
_check_token(authorization)
assert _pm is not None
_pm.entry_manager.restore_entry(entry_id)
_check_token(request, authorization)
pm = _get_pm(request)
pm.entry_manager.restore_entry(entry_id)
return {"status": "active"}
@app.get("/api/v1/config/{key}")
def get_config(key: str, authorization: str | None = Header(None)) -> Any:
_check_token(authorization)
assert _pm is not None
value = _pm.config_manager.load_config(require_pin=False).get(key)
def get_config(
request: Request, key: str, authorization: str | None = Header(None)
) -> Any:
_check_token(request, authorization)
pm = _get_pm(request)
value = pm.config_manager.load_config(require_pin=False).get(key)
if value is None:
raise HTTPException(status_code=404, detail="Not found")
return {"key": key, "value": value}
@@ -258,12 +345,15 @@ def get_config(key: str, authorization: str | None = Header(None)) -> Any:
@app.put("/api/v1/config/{key}")
def update_config(
key: str, data: dict, authorization: str | None = Header(None)
request: Request,
key: str,
data: dict,
authorization: str | None = Header(None),
) -> dict[str, str]:
"""Update a configuration setting."""
_check_token(authorization)
assert _pm is not None
cfg = _pm.config_manager
_check_token(request, authorization)
pm = _get_pm(request)
cfg = pm.config_manager
mapping = {
"relays": lambda v: cfg.set_relays(v, require_pin=False),
"pin": cfg.set_pin,
@@ -276,6 +366,7 @@ def update_config(
}
action = mapping.get(key)
if action is None:
raise HTTPException(status_code=400, detail="Unknown key")
@@ -288,84 +379,105 @@ def update_config(
@app.post("/api/v1/secret-mode")
def set_secret_mode(
data: dict, authorization: str | None = Header(None)
request: Request, data: dict, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Enable/disable secret mode and set the clipboard delay."""
_check_token(authorization)
assert _pm is not None
_check_token(request, authorization)
pm = _get_pm(request)
enabled = data.get("enabled")
delay = data.get("delay")
if enabled is None or delay is None:
raise HTTPException(status_code=400, detail="Missing fields")
cfg = _pm.config_manager
cfg = pm.config_manager
cfg.set_secret_mode_enabled(bool(enabled))
cfg.set_clipboard_clear_delay(int(delay))
_pm.secret_mode_enabled = bool(enabled)
_pm.clipboard_clear_delay = int(delay)
pm.secret_mode_enabled = bool(enabled)
pm.clipboard_clear_delay = int(delay)
return {"status": "ok"}
@app.get("/api/v1/fingerprint")
def list_fingerprints(authorization: str | None = Header(None)) -> List[str]:
_check_token(authorization)
assert _pm is not None
return _pm.fingerprint_manager.list_fingerprints()
def list_fingerprints(
request: Request, authorization: str | None = Header(None)
) -> List[str]:
_check_token(request, authorization)
pm = _get_pm(request)
return pm.fingerprint_manager.list_fingerprints()
@app.post("/api/v1/fingerprint")
def add_fingerprint(authorization: str | None = Header(None)) -> dict[str, str]:
def add_fingerprint(
request: Request, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Create a new seed profile."""
_check_token(authorization)
assert _pm is not None
_pm.add_new_fingerprint()
_check_token(request, authorization)
pm = _get_pm(request)
pm.add_new_fingerprint()
return {"status": "ok"}
@app.delete("/api/v1/fingerprint/{fingerprint}")
def remove_fingerprint(
fingerprint: str, authorization: str | None = Header(None)
request: Request, fingerprint: str, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Remove a seed profile."""
_check_token(authorization)
assert _pm is not None
_pm.fingerprint_manager.remove_fingerprint(fingerprint)
_check_token(request, authorization)
pm = _get_pm(request)
pm.fingerprint_manager.remove_fingerprint(fingerprint)
return {"status": "deleted"}
@app.post("/api/v1/fingerprint/select")
def select_fingerprint(
data: dict, authorization: str | None = Header(None)
request: Request, data: dict, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Switch the active seed profile."""
_check_token(authorization)
assert _pm is not None
_check_token(request, authorization)
pm = _get_pm(request)
fp = data.get("fingerprint")
if not fp:
raise HTTPException(status_code=400, detail="Missing fingerprint")
_pm.select_fingerprint(fp)
pm.select_fingerprint(fp)
return {"status": "ok"}
@app.get("/api/v1/totp/export")
def export_totp(authorization: str | None = Header(None)) -> dict:
def export_totp(
request: Request,
authorization: str | None = Header(None),
password: str | None = Header(None, alias="X-SeedPass-Password"),
) -> dict:
"""Return all stored TOTP entries in JSON format."""
_check_token(authorization)
assert _pm is not None
return _pm.entry_manager.export_totp_entries(_pm.parent_seed)
_check_token(request, authorization)
_require_password(request, password)
pm = _get_pm(request)
key = getattr(pm, "KEY_TOTP_DET", None) or getattr(pm, "parent_seed", None)
return pm.entry_manager.export_totp_entries(key)
@app.get("/api/v1/totp")
def get_totp_codes(authorization: str | None = Header(None)) -> dict:
def get_totp_codes(
request: Request,
authorization: str | None = Header(None),
password: str | None = Header(None, alias="X-SeedPass-Password"),
) -> dict:
"""Return active TOTP codes with remaining seconds."""
_check_token(authorization)
assert _pm is not None
entries = _pm.entry_manager.list_entries(
filter_kind=EntryType.TOTP.value, include_archived=False
_check_token(request, authorization)
_require_password(request, password)
pm = _get_pm(request)
entries = pm.entry_manager.list_entries(
filter_kinds=[EntryType.TOTP.value], include_archived=False
)
codes = []
for idx, label, _u, _url, _arch in entries:
code = _pm.entry_manager.get_totp_code(idx, _pm.parent_seed)
rem = _pm.entry_manager.get_totp_time_remaining(idx)
key = getattr(pm, "KEY_TOTP_DET", None) or getattr(pm, "parent_seed", None)
code = pm.entry_manager.get_totp_code(idx, key)
rem = pm.entry_manager.get_totp_time_remaining(idx)
codes.append(
{"id": idx, "label": label, "code": code, "seconds_remaining": rem}
)
@@ -373,132 +485,138 @@ def get_totp_codes(authorization: str | None = Header(None)) -> dict:
@app.get("/api/v1/stats")
def get_profile_stats(authorization: str | None = Header(None)) -> dict:
def get_profile_stats(
request: Request, authorization: str | None = Header(None)
) -> dict:
"""Return statistics about the active seed profile."""
_check_token(authorization)
assert _pm is not None
return _pm.get_profile_stats()
_check_token(request, authorization)
pm = _get_pm(request)
return pm.get_profile_stats()
@app.get("/api/v1/notifications")
def get_notifications(authorization: str | None = Header(None)) -> List[dict]:
def get_notifications(
request: Request, authorization: str | None = Header(None)
) -> List[dict]:
"""Return and clear queued notifications."""
_check_token(authorization)
assert _pm is not None
_check_token(request, authorization)
pm = _get_pm(request)
notes = []
while True:
try:
note = _pm.notifications.get_nowait()
note = pm.notifications.get_nowait()
except queue.Empty:
break
notes.append({"level": note.level, "message": note.message})
return notes
@app.get("/api/v1/parent-seed")
def get_parent_seed(
authorization: str | None = Header(None), file: str | None = None
) -> dict:
"""Return the parent seed or save it as an encrypted backup."""
_check_token(authorization)
assert _pm is not None
if file:
path = Path(file)
_pm.encryption_manager.encrypt_and_save_file(
_pm.parent_seed.encode("utf-8"), path
)
return {"status": "saved", "path": str(path)}
return {"seed": _pm.parent_seed}
@app.get("/api/v1/nostr/pubkey")
def get_nostr_pubkey(authorization: str | None = Header(None)) -> Any:
_check_token(authorization)
assert _pm is not None
return {"npub": _pm.nostr_client.key_manager.get_npub()}
def get_nostr_pubkey(request: Request, authorization: str | None = Header(None)) -> Any:
_check_token(request, authorization)
pm = _get_pm(request)
return {"npub": pm.nostr_client.key_manager.get_npub()}
@app.get("/api/v1/relays")
def list_relays(authorization: str | None = Header(None)) -> dict:
def list_relays(request: Request, authorization: str | None = Header(None)) -> dict:
"""Return the configured Nostr relays."""
_check_token(authorization)
assert _pm is not None
cfg = _pm.config_manager.load_config(require_pin=False)
_check_token(request, authorization)
pm = _get_pm(request)
cfg = pm.config_manager.load_config(require_pin=False)
return {"relays": cfg.get("relays", [])}
@app.post("/api/v1/relays")
def add_relay(data: dict, authorization: str | None = Header(None)) -> dict[str, str]:
def add_relay(
request: Request, data: dict, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Add a relay URL to the configuration."""
_check_token(authorization)
assert _pm is not None
_check_token(request, authorization)
pm = _get_pm(request)
url = data.get("url")
if not url:
raise HTTPException(status_code=400, detail="Missing url")
cfg = _pm.config_manager.load_config(require_pin=False)
cfg = pm.config_manager.load_config(require_pin=False)
relays = cfg.get("relays", [])
if url in relays:
raise HTTPException(status_code=400, detail="Relay already present")
relays.append(url)
_pm.config_manager.set_relays(relays, require_pin=False)
_reload_relays(relays)
pm.config_manager.set_relays(relays, require_pin=False)
_reload_relays(request, relays)
return {"status": "ok"}
@app.delete("/api/v1/relays/{idx}")
def remove_relay(idx: int, authorization: str | None = Header(None)) -> dict[str, str]:
def remove_relay(
request: Request, idx: int, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Remove a relay by its index (1-based)."""
_check_token(authorization)
assert _pm is not None
cfg = _pm.config_manager.load_config(require_pin=False)
_check_token(request, authorization)
pm = _get_pm(request)
cfg = pm.config_manager.load_config(require_pin=False)
relays = cfg.get("relays", [])
if not (1 <= idx <= len(relays)):
raise HTTPException(status_code=400, detail="Invalid index")
if len(relays) == 1:
raise HTTPException(status_code=400, detail="At least one relay required")
relays.pop(idx - 1)
_pm.config_manager.set_relays(relays, require_pin=False)
_reload_relays(relays)
pm.config_manager.set_relays(relays, require_pin=False)
_reload_relays(request, relays)
return {"status": "ok"}
@app.post("/api/v1/relays/reset")
def reset_relays(authorization: str | None = Header(None)) -> dict[str, str]:
def reset_relays(
request: Request, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Reset relay list to defaults."""
_check_token(authorization)
assert _pm is not None
_check_token(request, authorization)
pm = _get_pm(request)
from nostr.client import DEFAULT_RELAYS
relays = list(DEFAULT_RELAYS)
_pm.config_manager.set_relays(relays, require_pin=False)
_reload_relays(relays)
pm.config_manager.set_relays(relays, require_pin=False)
_reload_relays(request, relays)
return {"status": "ok"}
@app.post("/api/v1/checksum/verify")
def verify_checksum(authorization: str | None = Header(None)) -> dict[str, str]:
def verify_checksum(
request: Request, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Verify the SeedPass script checksum."""
_check_token(authorization)
assert _pm is not None
_pm.handle_verify_checksum()
_check_token(request, authorization)
pm = _get_pm(request)
pm.handle_verify_checksum()
return {"status": "ok"}
@app.post("/api/v1/checksum/update")
def update_checksum(authorization: str | None = Header(None)) -> dict[str, str]:
def update_checksum(
request: Request, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Regenerate the script checksum file."""
_check_token(authorization)
assert _pm is not None
_pm.handle_update_script_checksum()
_check_token(request, authorization)
pm = _get_pm(request)
pm.handle_update_script_checksum()
return {"status": "ok"}
@app.post("/api/v1/vault/export")
def export_vault(authorization: str | None = Header(None)):
def export_vault(
request: Request,
authorization: str | None = Header(None),
password: str | None = Header(None, alias="X-SeedPass-Password"),
):
"""Export the vault and return the encrypted file."""
_check_token(authorization)
assert _pm is not None
path = _pm.handle_export_database()
_check_token(request, authorization)
_require_password(request, password)
pm = _get_pm(request)
path = pm.handle_export_database()
if path is None:
raise HTTPException(status_code=500, detail="Export failed")
data = Path(path).read_bytes()
@@ -510,13 +628,15 @@ async def import_vault(
request: Request, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Import a vault backup from a file upload or a server path."""
_check_token(authorization)
assert _pm is not None
_check_token(request, authorization)
pm = _get_pm(request)
ctype = request.headers.get("content-type", "")
if ctype.startswith("multipart/form-data"):
form = await request.form()
file = form.get("file")
if file is None:
raise HTTPException(status_code=400, detail="Missing file")
data = await file.read()
@@ -524,55 +644,108 @@ async def import_vault(
tmp.write(data)
tmp_path = Path(tmp.name)
try:
_pm.handle_import_database(tmp_path)
pm.handle_import_database(tmp_path)
finally:
os.unlink(tmp_path)
else:
body = await request.json()
path = body.get("path")
if not path:
path_str = body.get("path")
if not path_str:
raise HTTPException(status_code=400, detail="Missing file or path")
_pm.handle_import_database(Path(path))
_pm.sync_vault()
path = _validate_encryption_path(request, Path(path_str))
if not str(path).endswith(".json.enc"):
raise HTTPException(
status_code=400,
detail="Selected file must be a '.json.enc' backup",
)
pm.handle_import_database(path)
pm.sync_vault()
return {"status": "ok"}
@app.post("/api/v1/vault/backup-parent-seed")
def backup_parent_seed(
data: dict | None = None, authorization: str | None = Header(None)
request: Request,
data: dict,
authorization: str | None = Header(None),
password: str | None = Header(None, alias="X-SeedPass-Password"),
) -> dict[str, str]:
"""Backup and reveal the parent seed."""
_check_token(authorization)
assert _pm is not None
path = None
if data is not None:
p = data.get("path")
if p:
path = Path(p)
_pm.handle_backup_reveal_parent_seed(path)
return {"status": "ok"}
"""Create an encrypted backup of the parent seed after confirmation."""
_check_token(request, authorization)
_require_password(request, password)
pm = _get_pm(request)
if not data.get("confirm"):
raise HTTPException(status_code=400, detail="Confirmation required")
path_str = data.get("path")
if not path_str:
raise HTTPException(status_code=400, detail="Missing path")
path = Path(path_str)
_validate_encryption_path(request, path)
pm.encryption_manager.encrypt_and_save_file(pm.parent_seed.encode("utf-8"), path)
return {"status": "saved", "path": str(path)}
@app.post("/api/v1/change-password")
def change_password(authorization: str | None = Header(None)) -> dict[str, str]:
def change_password(
request: Request, data: dict, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Change the master password for the active profile."""
_check_token(authorization)
assert _pm is not None
_pm.change_password()
_check_token(request, authorization)
pm = _get_pm(request)
pm.change_password(data.get("old", ""), data.get("new", ""))
return {"status": "ok"}
@app.post("/api/v1/password")
def generate_password(
request: Request, data: dict, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Generate a password using optional policy overrides."""
_check_token(request, authorization)
pm = _get_pm(request)
length = int(data.get("length", 12))
policy_keys = [
"include_special_chars",
"allowed_special_chars",
"special_mode",
"exclude_ambiguous",
"min_uppercase",
"min_lowercase",
"min_digits",
"min_special",
]
kwargs = {k: data.get(k) for k in policy_keys if data.get(k) is not None}
util = UtilityService(pm)
password = util.generate_password(length, **kwargs)
return {"password": password}
@app.post("/api/v1/vault/lock")
def lock_vault(authorization: str | None = Header(None)) -> dict[str, str]:
def lock_vault(
request: Request, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Lock the vault and clear sensitive data from memory."""
_check_token(authorization)
assert _pm is not None
_pm.lock_vault()
_check_token(request, authorization)
pm = _get_pm(request)
pm.lock_vault()
return {"status": "locked"}
@app.post("/api/v1/shutdown")
async def shutdown_server(authorization: str | None = Header(None)) -> dict[str, str]:
_check_token(authorization)
async def shutdown_server(
request: Request, authorization: str | None = Header(None)
) -> dict[str, str]:
_check_token(request, authorization)
asyncio.get_event_loop().call_soon(sys.exit, 0)
return {"status": "shutting down"}

View File

@@ -1,656 +0,0 @@
from pathlib import Path
from typing import Optional
import json
import typer
from password_manager.manager import PasswordManager
from password_manager.entry_types import EntryType
import uvicorn
from . import api as api_module
import importlib
app = typer.Typer(
help="SeedPass command line interface",
invoke_without_command=True,
)
# Global option shared across all commands
fingerprint_option = typer.Option(
None,
"--fingerprint",
"-f",
help="Specify which seed profile to use",
)
# Sub command groups
entry_app = typer.Typer(help="Manage individual entries")
vault_app = typer.Typer(help="Manage the entire vault")
nostr_app = typer.Typer(help="Interact with Nostr relays")
config_app = typer.Typer(help="Get or set configuration values")
fingerprint_app = typer.Typer(help="Manage seed profiles")
util_app = typer.Typer(help="Utility commands")
api_app = typer.Typer(help="Run the API server")
app.add_typer(entry_app, name="entry")
app.add_typer(vault_app, name="vault")
app.add_typer(nostr_app, name="nostr")
app.add_typer(config_app, name="config")
app.add_typer(fingerprint_app, name="fingerprint")
app.add_typer(util_app, name="util")
app.add_typer(api_app, name="api")
def _get_pm(ctx: typer.Context) -> PasswordManager:
"""Return a PasswordManager optionally selecting a fingerprint."""
fp = ctx.obj.get("fingerprint")
if fp is None:
pm = PasswordManager()
else:
pm = PasswordManager(fingerprint=fp)
return pm
@app.callback(invoke_without_command=True)
def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None:
"""SeedPass CLI entry point.
When called without a subcommand this launches the interactive TUI.
"""
ctx.obj = {"fingerprint": fingerprint}
if ctx.invoked_subcommand is None:
tui = importlib.import_module("main")
raise typer.Exit(tui.main(fingerprint=fingerprint))
@entry_app.command("list")
def entry_list(
ctx: typer.Context,
sort: str = typer.Option(
"index", "--sort", help="Sort by 'index', 'label', or 'username'"
),
kind: Optional[str] = typer.Option(None, "--kind", help="Filter by entry type"),
archived: bool = typer.Option(False, "--archived", help="Include archived"),
) -> None:
"""List entries in the vault."""
pm = _get_pm(ctx)
entries = pm.entry_manager.list_entries(
sort_by=sort, filter_kind=kind, include_archived=archived
)
for idx, label, username, url, is_archived in entries:
line = f"{idx}: {label}"
if username:
line += f" ({username})"
if url:
line += f" {url}"
if is_archived:
line += " [archived]"
typer.echo(line)
@entry_app.command("search")
def entry_search(ctx: typer.Context, query: str) -> None:
"""Search entries."""
pm = _get_pm(ctx)
results = pm.entry_manager.search_entries(query)
if not results:
typer.echo("No matching entries found")
return
for idx, label, username, url, _arch in results:
line = f"{idx}: {label}"
if username:
line += f" ({username})"
if url:
line += f" {url}"
typer.echo(line)
@entry_app.command("get")
def entry_get(ctx: typer.Context, query: str) -> None:
"""Retrieve a single entry's secret."""
pm = _get_pm(ctx)
matches = pm.entry_manager.search_entries(query)
if len(matches) == 0:
typer.echo("No matching entries found")
raise typer.Exit(code=1)
if len(matches) > 1:
typer.echo("Matches:")
for idx, label, username, _url, _arch in matches:
name = f"{idx}: {label}"
if username:
name += f" ({username})"
typer.echo(name)
raise typer.Exit(code=1)
index = matches[0][0]
entry = pm.entry_manager.retrieve_entry(index)
etype = entry.get("type", entry.get("kind"))
if etype == EntryType.PASSWORD.value:
length = int(entry.get("length", 12))
password = pm.password_generator.generate_password(length, index)
typer.echo(password)
elif etype == EntryType.TOTP.value:
code = pm.entry_manager.get_totp_code(index, pm.parent_seed)
typer.echo(code)
else:
typer.echo("Unsupported entry type")
raise typer.Exit(code=1)
@entry_app.command("add")
def entry_add(
ctx: typer.Context,
label: str,
length: int = typer.Option(12, "--length"),
username: Optional[str] = typer.Option(None, "--username"),
url: Optional[str] = typer.Option(None, "--url"),
) -> None:
"""Add a new password entry and output its index."""
pm = _get_pm(ctx)
index = pm.entry_manager.add_entry(label, length, username, url)
typer.echo(str(index))
pm.sync_vault()
@entry_app.command("add-totp")
def entry_add_totp(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
secret: Optional[str] = typer.Option(None, "--secret", help="Import secret"),
period: int = typer.Option(30, "--period", help="TOTP period in seconds"),
digits: int = typer.Option(6, "--digits", help="Number of TOTP digits"),
) -> None:
"""Add a TOTP entry and output the otpauth URI."""
pm = _get_pm(ctx)
uri = pm.entry_manager.add_totp(
label,
pm.parent_seed,
index=index,
secret=secret,
period=period,
digits=digits,
)
typer.echo(uri)
pm.sync_vault()
@entry_app.command("add-ssh")
def entry_add_ssh(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add an SSH key entry and output its index."""
pm = _get_pm(ctx)
idx = pm.entry_manager.add_ssh_key(
label,
pm.parent_seed,
index=index,
notes=notes,
)
typer.echo(str(idx))
pm.sync_vault()
@entry_app.command("add-pgp")
def entry_add_pgp(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
key_type: str = typer.Option("ed25519", "--key-type", help="Key type"),
user_id: str = typer.Option("", "--user-id", help="User ID"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a PGP key entry and output its index."""
pm = _get_pm(ctx)
idx = pm.entry_manager.add_pgp_key(
label,
pm.parent_seed,
index=index,
key_type=key_type,
user_id=user_id,
notes=notes,
)
typer.echo(str(idx))
pm.sync_vault()
@entry_app.command("add-nostr")
def entry_add_nostr(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a Nostr key entry and output its index."""
pm = _get_pm(ctx)
idx = pm.entry_manager.add_nostr_key(
label,
index=index,
notes=notes,
)
typer.echo(str(idx))
pm.sync_vault()
@entry_app.command("add-seed")
def entry_add_seed(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
words: int = typer.Option(24, "--words", help="Word count"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a derived seed phrase entry and output its index."""
pm = _get_pm(ctx)
idx = pm.entry_manager.add_seed(
label,
pm.parent_seed,
index=index,
words_num=words,
notes=notes,
)
typer.echo(str(idx))
pm.sync_vault()
@entry_app.command("add-key-value")
def entry_add_key_value(
ctx: typer.Context,
label: str,
value: str = typer.Option(..., "--value", help="Stored value"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a key/value entry and output its index."""
pm = _get_pm(ctx)
idx = pm.entry_manager.add_key_value(label, value, notes=notes)
typer.echo(str(idx))
pm.sync_vault()
@entry_app.command("add-managed-account")
def entry_add_managed_account(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a managed account seed entry and output its index."""
pm = _get_pm(ctx)
idx = pm.entry_manager.add_managed_account(
label,
pm.parent_seed,
index=index,
notes=notes,
)
typer.echo(str(idx))
pm.sync_vault()
@entry_app.command("modify")
def entry_modify(
ctx: typer.Context,
entry_id: int,
label: Optional[str] = typer.Option(None, "--label"),
username: Optional[str] = typer.Option(None, "--username"),
url: Optional[str] = typer.Option(None, "--url"),
notes: Optional[str] = typer.Option(None, "--notes"),
period: Optional[int] = typer.Option(
None, "--period", help="TOTP period in seconds"
),
digits: Optional[int] = typer.Option(None, "--digits", help="TOTP digits"),
value: Optional[str] = typer.Option(None, "--value", help="New value"),
) -> None:
"""Modify an existing entry."""
pm = _get_pm(ctx)
try:
pm.entry_manager.modify_entry(
entry_id,
username=username,
url=url,
notes=notes,
label=label,
period=period,
digits=digits,
value=value,
)
except ValueError as e:
typer.echo(str(e))
raise typer.Exit(code=1)
pm.sync_vault()
@entry_app.command("archive")
def entry_archive(ctx: typer.Context, entry_id: int) -> None:
"""Archive an entry."""
pm = _get_pm(ctx)
pm.entry_manager.archive_entry(entry_id)
typer.echo(str(entry_id))
pm.sync_vault()
@entry_app.command("unarchive")
def entry_unarchive(ctx: typer.Context, entry_id: int) -> None:
"""Restore an archived entry."""
pm = _get_pm(ctx)
pm.entry_manager.restore_entry(entry_id)
typer.echo(str(entry_id))
pm.sync_vault()
@entry_app.command("totp-codes")
def entry_totp_codes(ctx: typer.Context) -> None:
"""Display all current TOTP codes."""
pm = _get_pm(ctx)
pm.handle_display_totp_codes()
@entry_app.command("export-totp")
def entry_export_totp(
ctx: typer.Context, file: str = typer.Option(..., help="Output file")
) -> None:
"""Export all TOTP secrets to a JSON file."""
pm = _get_pm(ctx)
data = pm.entry_manager.export_totp_entries(pm.parent_seed)
Path(file).write_text(json.dumps(data, indent=2))
typer.echo(str(file))
@vault_app.command("export")
def vault_export(
ctx: typer.Context, file: str = typer.Option(..., help="Output file")
) -> None:
"""Export the vault."""
pm = _get_pm(ctx)
pm.handle_export_database(Path(file))
typer.echo(str(file))
@vault_app.command("import")
def vault_import(
ctx: typer.Context, file: str = typer.Option(..., help="Input file")
) -> None:
"""Import a vault from an encrypted JSON file."""
pm = _get_pm(ctx)
pm.handle_import_database(Path(file))
pm.sync_vault()
typer.echo(str(file))
@vault_app.command("change-password")
def vault_change_password(ctx: typer.Context) -> None:
"""Change the master password used for encryption."""
pm = _get_pm(ctx)
pm.change_password()
@vault_app.command("lock")
def vault_lock(ctx: typer.Context) -> None:
"""Lock the vault and clear sensitive data from memory."""
pm = _get_pm(ctx)
pm.lock_vault()
typer.echo("locked")
@vault_app.command("stats")
def vault_stats(ctx: typer.Context) -> None:
"""Display statistics about the current seed profile."""
pm = _get_pm(ctx)
stats = pm.get_profile_stats()
typer.echo(json.dumps(stats, indent=2))
@vault_app.command("reveal-parent-seed")
def vault_reveal_parent_seed(
ctx: typer.Context,
file: Optional[str] = typer.Option(
None, "--file", help="Save encrypted seed to this path"
),
) -> None:
"""Display the parent seed and optionally write an encrypted backup file."""
pm = _get_pm(ctx)
pm.handle_backup_reveal_parent_seed(Path(file) if file else None)
@nostr_app.command("sync")
def nostr_sync(ctx: typer.Context) -> None:
"""Sync with configured Nostr relays."""
pm = _get_pm(ctx)
event_id = pm.sync_vault()
if event_id:
typer.echo(event_id)
else:
typer.echo("Error: Failed to sync vault")
@nostr_app.command("get-pubkey")
def nostr_get_pubkey(ctx: typer.Context) -> None:
"""Display the active profile's npub."""
pm = _get_pm(ctx)
npub = pm.nostr_client.key_manager.get_npub()
typer.echo(npub)
@config_app.command("get")
def config_get(ctx: typer.Context, key: str) -> None:
"""Get a configuration value."""
pm = _get_pm(ctx)
value = pm.config_manager.load_config(require_pin=False).get(key)
if value is None:
typer.echo("Key not found")
else:
typer.echo(str(value))
@config_app.command("set")
def config_set(ctx: typer.Context, key: str, value: str) -> None:
"""Set a configuration value."""
pm = _get_pm(ctx)
cfg = pm.config_manager
mapping = {
"inactivity_timeout": lambda v: cfg.set_inactivity_timeout(float(v)),
"secret_mode_enabled": lambda v: cfg.set_secret_mode_enabled(
v.lower() in ("1", "true", "yes", "y", "on")
),
"clipboard_clear_delay": lambda v: cfg.set_clipboard_clear_delay(int(v)),
"additional_backup_path": lambda v: cfg.set_additional_backup_path(v or None),
"relays": lambda v: cfg.set_relays(
[r.strip() for r in v.split(",") if r.strip()], require_pin=False
),
"kdf_iterations": lambda v: cfg.set_kdf_iterations(int(v)),
"kdf_mode": lambda v: cfg.set_kdf_mode(v),
"backup_interval": lambda v: cfg.set_backup_interval(float(v)),
"nostr_max_retries": lambda v: cfg.set_nostr_max_retries(int(v)),
"nostr_retry_delay": lambda v: cfg.set_nostr_retry_delay(float(v)),
"min_uppercase": lambda v: cfg.set_min_uppercase(int(v)),
"min_lowercase": lambda v: cfg.set_min_lowercase(int(v)),
"min_digits": lambda v: cfg.set_min_digits(int(v)),
"min_special": lambda v: cfg.set_min_special(int(v)),
"quick_unlock": lambda v: cfg.set_quick_unlock(
v.lower() in ("1", "true", "yes", "y", "on")
),
"verbose_timing": lambda v: cfg.set_verbose_timing(
v.lower() in ("1", "true", "yes", "y", "on")
),
}
action = mapping.get(key)
if action is None:
typer.echo("Unknown key")
raise typer.Exit(code=1)
try:
action(value)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo("Updated")
@config_app.command("toggle-secret-mode")
def config_toggle_secret_mode(ctx: typer.Context) -> None:
"""Interactively enable or disable secret mode."""
pm = _get_pm(ctx)
cfg = pm.config_manager
try:
enabled = cfg.get_secret_mode_enabled()
delay = cfg.get_clipboard_clear_delay()
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error loading settings: {exc}")
raise typer.Exit(code=1)
typer.echo(f"Secret mode is currently {'ON' if enabled else 'OFF'}")
choice = (
typer.prompt(
"Enable secret mode? (y/n, blank to keep)", default="", show_default=False
)
.strip()
.lower()
)
if choice in ("y", "yes"):
enabled = True
elif choice in ("n", "no"):
enabled = False
inp = typer.prompt(
f"Clipboard clear delay in seconds [{delay}]", default="", show_default=False
).strip()
if inp:
try:
delay = int(inp)
if delay <= 0:
typer.echo("Delay must be positive")
raise typer.Exit(code=1)
except ValueError:
typer.echo("Invalid number")
raise typer.Exit(code=1)
try:
cfg.set_secret_mode_enabled(enabled)
cfg.set_clipboard_clear_delay(delay)
pm.secret_mode_enabled = enabled
pm.clipboard_clear_delay = delay
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
status = "enabled" if enabled else "disabled"
typer.echo(f"Secret mode {status}.")
@config_app.command("toggle-offline")
def config_toggle_offline(ctx: typer.Context) -> None:
"""Enable or disable offline mode."""
pm = _get_pm(ctx)
cfg = pm.config_manager
try:
enabled = cfg.get_offline_mode()
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error loading settings: {exc}")
raise typer.Exit(code=1)
typer.echo(f"Offline mode is currently {'ON' if enabled else 'OFF'}")
choice = (
typer.prompt(
"Enable offline mode? (y/n, blank to keep)", default="", show_default=False
)
.strip()
.lower()
)
if choice in ("y", "yes"):
enabled = True
elif choice in ("n", "no"):
enabled = False
try:
cfg.set_offline_mode(enabled)
pm.offline_mode = enabled
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
status = "enabled" if enabled else "disabled"
typer.echo(f"Offline mode {status}.")
@fingerprint_app.command("list")
def fingerprint_list(ctx: typer.Context) -> None:
"""List available seed profiles."""
pm = _get_pm(ctx)
for fp in pm.fingerprint_manager.list_fingerprints():
typer.echo(fp)
@fingerprint_app.command("add")
def fingerprint_add(ctx: typer.Context) -> None:
"""Create a new seed profile."""
pm = _get_pm(ctx)
pm.add_new_fingerprint()
@fingerprint_app.command("remove")
def fingerprint_remove(ctx: typer.Context, fingerprint: str) -> None:
"""Remove a seed profile."""
pm = _get_pm(ctx)
pm.fingerprint_manager.remove_fingerprint(fingerprint)
@fingerprint_app.command("switch")
def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None:
"""Switch to another seed profile."""
pm = _get_pm(ctx)
pm.select_fingerprint(fingerprint)
@util_app.command("generate-password")
def generate_password(ctx: typer.Context, length: int = 24) -> None:
"""Generate a strong password."""
pm = _get_pm(ctx)
password = pm.password_generator.generate_password(length)
typer.echo(password)
@util_app.command("verify-checksum")
def verify_checksum(ctx: typer.Context) -> None:
"""Verify the SeedPass script checksum."""
pm = _get_pm(ctx)
pm.handle_verify_checksum()
@util_app.command("update-checksum")
def update_checksum(ctx: typer.Context) -> None:
"""Regenerate the script checksum file."""
pm = _get_pm(ctx)
pm.handle_update_script_checksum()
@api_app.command("start")
def api_start(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> None:
"""Start the SeedPass API server."""
token = api_module.start_server(ctx.obj.get("fingerprint"))
typer.echo(f"API token: {token}")
uvicorn.run(api_module.app, host=host, port=port)
@api_app.command("stop")
def api_stop(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> None:
"""Stop the SeedPass API server."""
import requests
try:
requests.post(
f"http://{host}:{port}/api/v1/shutdown",
headers={"Authorization": f"Bearer {api_module._token}"},
timeout=2,
)
except Exception as exc: # pragma: no cover - best effort
typer.echo(f"Failed to stop server: {exc}")
if __name__ == "__main__":
app()

View File

@@ -0,0 +1,186 @@
from __future__ import annotations
import importlib
import importlib.util
import subprocess
import sys
from typing import Optional
import typer
from .common import _get_services
from seedpass.core.errors import SeedPassError
app = typer.Typer(
help="SeedPass command line interface",
invoke_without_command=True,
)
# Global option shared across all commands
fingerprint_option = typer.Option(
None,
"--fingerprint",
"-f",
help="Specify which seed profile to use",
)
no_clipboard_option = typer.Option(
False,
"--no-clipboard",
help="Disable clipboard support and print secrets instead",
is_flag=True,
)
deterministic_totp_option = typer.Option(
False,
"--deterministic-totp",
help="Derive TOTP secrets deterministically",
is_flag=True,
)
# Sub command groups
from . import entry, vault, nostr, config, fingerprint, util, api
app.add_typer(entry.app, name="entry")
app.add_typer(vault.app, name="vault")
app.add_typer(nostr.app, name="nostr")
app.add_typer(config.app, name="config")
app.add_typer(fingerprint.app, name="fingerprint")
app.add_typer(util.app, name="util")
app.add_typer(api.app, name="api")
def run() -> None:
"""Invoke the CLI, handling SeedPass errors gracefully."""
try:
app()
except SeedPassError as exc:
typer.echo(str(exc), err=True)
raise typer.Exit(1) from exc
def _gui_backend_available() -> bool:
"""Return True if a platform-specific BeeWare backend is installed."""
for pkg in ("toga_gtk", "toga_winforms", "toga_cocoa"):
if importlib.util.find_spec(pkg) is not None:
return True
return False
@app.callback(invoke_without_command=True)
def main(
ctx: typer.Context,
fingerprint: Optional[str] = fingerprint_option,
no_clipboard: bool = no_clipboard_option,
deterministic_totp: bool = deterministic_totp_option,
) -> None:
"""SeedPass CLI entry point.
When called without a subcommand this launches the interactive TUI.
"""
ctx.obj = {
"fingerprint": fingerprint,
"no_clipboard": no_clipboard,
"deterministic_totp": deterministic_totp,
}
if ctx.invoked_subcommand is None:
tui = importlib.import_module("main")
raise typer.Exit(tui.main(fingerprint=fingerprint))
@app.command("lock")
def root_lock(ctx: typer.Context) -> None:
"""Lock the vault for the active profile."""
vault_service, _profile, _sync = _get_services(ctx)
vault_service.lock()
typer.echo("locked")
@app.command()
def gui(
install: bool = typer.Option(
False,
"--install",
help="Attempt to install the BeeWare GUI backend if missing",
)
) -> None:
"""Launch the BeeWare GUI.
If a platform specific backend is missing, inform the user how to
install it. Using ``--install`` will attempt installation after
confirmation.
"""
if not _gui_backend_available():
if sys.platform.startswith("linux"):
pkg = "toga-gtk"
version = "0.5.2"
sha256 = "15b346ac1a2584de5effe5e73a3888f055c68c93300aeb111db9d64186b31646"
elif sys.platform == "win32":
pkg = "toga-winforms"
version = "0.5.2"
sha256 = "83181309f204bcc4a34709d23fdfd68467ae8ecc39c906d13c661cb9a0ef581b"
elif sys.platform == "darwin":
pkg = "toga-cocoa"
version = "0.5.2"
sha256 = "a4d5d1546bf92372a6fb1b450164735fb107b2ee69d15bf87421fec3c78465f9"
else:
typer.echo(
f"Unsupported platform '{sys.platform}' for BeeWare GUI.",
err=True,
)
raise typer.Exit(1)
if not install:
typer.echo(
f"BeeWare GUI backend not found. Please install {pkg} manually or rerun "
"with '--install'.",
err=True,
)
raise typer.Exit(1)
if not typer.confirm(
f"Install {pkg}=={version} with hash verification?", default=False
):
typer.echo("Installation cancelled.", err=True)
raise typer.Exit(1)
typer.echo(
"SeedPass uses pinned versions and SHA256 hashes to verify the GUI backend "
"and protect against tampered packages."
)
try:
subprocess.check_call(
[
sys.executable,
"-m",
"pip",
"install",
"--require-hashes",
f"{pkg}=={version}",
f"--hash=sha256:{sha256}",
]
)
typer.echo(f"Successfully installed {pkg}=={version}.")
except subprocess.CalledProcessError as exc:
typer.echo(
"Secure installation failed. Please install the package manually "
f"from a trusted source. Details: {exc}",
err=True,
)
raise typer.Exit(1)
if not _gui_backend_available():
typer.echo(
"BeeWare GUI backend still unavailable after installation attempt.",
err=True,
)
raise typer.Exit(1)
from seedpass_gui.app import main
main()
if __name__ == "__main__": # pragma: no cover
run()

38
src/seedpass/cli/api.py Normal file
View File

@@ -0,0 +1,38 @@
from __future__ import annotations
import typer
import uvicorn
from .. import api as api_module
app = typer.Typer(help="Run the API server")
@app.command("start")
def api_start(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> None:
"""Start the SeedPass API server."""
token = api_module.start_server(ctx.obj.get("fingerprint"))
typer.echo(
f"API token: {token}\nWARNING: Store this token securely; it cannot be recovered."
)
uvicorn.run(api_module.app, host=host, port=port)
@app.command("stop")
def api_stop(
token: str = typer.Option(..., help="API token"),
host: str = "127.0.0.1",
port: int = 8000,
) -> None:
"""Stop the SeedPass API server."""
import requests
try:
requests.post(
f"http://{host}:{port}/api/v1/shutdown",
headers={"Authorization": f"Bearer {token}"},
timeout=2,
)
except Exception as exc: # pragma: no cover - best effort
typer.echo(f"Failed to stop server: {exc}")

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import typer
from seedpass.core.manager import PasswordManager
from seedpass.core.entry_types import EntryType
from seedpass.core.api import (
VaultService,
ProfileService,
SyncService,
EntryService,
ConfigService,
UtilityService,
NostrService,
ChangePasswordRequest,
UnlockRequest,
BackupParentSeedRequest,
ProfileSwitchRequest,
ProfileRemoveRequest,
)
def _get_pm(ctx: typer.Context) -> PasswordManager:
"""Return a PasswordManager optionally selecting a fingerprint."""
fp = ctx.obj.get("fingerprint")
if fp is None:
pm = PasswordManager()
else:
pm = PasswordManager(fingerprint=fp)
if ctx.obj.get("no_clipboard"):
pm.secret_mode_enabled = False
if ctx.obj.get("deterministic_totp"):
pm.deterministic_totp = True
return pm
def _get_services(
ctx: typer.Context,
) -> tuple[VaultService, ProfileService, SyncService]:
"""Return service layer instances for the current context."""
pm = _get_pm(ctx)
return VaultService(pm), ProfileService(pm), SyncService(pm)
def _get_entry_service(ctx: typer.Context) -> EntryService:
pm = _get_pm(ctx)
return EntryService(pm)
def _get_config_service(ctx: typer.Context) -> ConfigService:
pm = _get_pm(ctx)
return ConfigService(pm)
def _get_util_service(ctx: typer.Context) -> UtilityService:
pm = _get_pm(ctx)
return UtilityService(pm)
def _get_nostr_service(ctx: typer.Context) -> NostrService:
pm = _get_pm(ctx)
return NostrService(pm)

125
src/seedpass/cli/config.py Normal file
View File

@@ -0,0 +1,125 @@
from __future__ import annotations
import typer
from .common import _get_config_service
app = typer.Typer(help="Get or set configuration values")
@app.command("get")
def config_get(ctx: typer.Context, key: str) -> None:
"""Get a configuration value."""
service = _get_config_service(ctx)
value = service.get(key)
if value is None:
typer.echo("Key not found")
else:
typer.echo(str(value))
@app.command("set")
def config_set(ctx: typer.Context, key: str, value: str) -> None:
"""Set a configuration value."""
service = _get_config_service(ctx)
try:
val = (
[r.strip() for r in value.split(",") if r.strip()]
if key == "relays"
else value
)
service.set(key, val)
except KeyError:
typer.echo("Unknown key")
raise typer.Exit(code=1)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo("Updated")
@app.command("toggle-secret-mode")
def config_toggle_secret_mode(ctx: typer.Context) -> None:
"""Interactively enable or disable secret mode.
When enabled, newly generated and retrieved passwords are copied to the
clipboard instead of printed to the screen.
"""
service = _get_config_service(ctx)
try:
enabled = service.get_secret_mode_enabled()
delay = service.get_clipboard_clear_delay()
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error loading settings: {exc}")
raise typer.Exit(code=1)
typer.echo(f"Secret mode is currently {'ON' if enabled else 'OFF'}")
choice = (
typer.prompt(
"Enable secret mode? (y/n, blank to keep)", default="", show_default=False
)
.strip()
.lower()
)
if choice in ("y", "yes"):
enabled = True
elif choice in ("n", "no"):
enabled = False
inp = typer.prompt(
f"Clipboard clear delay in seconds [{delay}]", default="", show_default=False
).strip()
if inp:
try:
delay = int(inp)
if delay <= 0:
typer.echo("Delay must be positive")
raise typer.Exit(code=1)
except ValueError:
typer.echo("Invalid number")
raise typer.Exit(code=1)
try:
service.set_secret_mode(enabled, delay)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
status = "enabled" if enabled else "disabled"
typer.echo(f"Secret mode {status}.")
@app.command("toggle-offline")
def config_toggle_offline(ctx: typer.Context) -> None:
"""Enable or disable offline mode."""
service = _get_config_service(ctx)
try:
enabled = service.get_offline_mode()
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error loading settings: {exc}")
raise typer.Exit(code=1)
typer.echo(f"Offline mode is currently {'ON' if enabled else 'OFF'}")
choice = (
typer.prompt(
"Enable offline mode? (y/n, blank to keep)", default="", show_default=False
)
.strip()
.lower()
)
if choice in ("y", "yes"):
enabled = True
elif choice in ("n", "no"):
enabled = False
try:
service.set_offline_mode(enabled)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
status = "enabled" if enabled else "disabled"
typer.echo(f"Offline mode {status}.")

368
src/seedpass/cli/entry.py Normal file
View File

@@ -0,0 +1,368 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import List, Optional
import typer
import click
from .common import _get_entry_service, EntryType
from seedpass.core.entry_types import ALL_ENTRY_TYPES
from utils.clipboard import ClipboardUnavailableError
app = typer.Typer(help="Manage individual entries")
@app.command("list")
def entry_list(
ctx: typer.Context,
sort: str = typer.Option(
"index", "--sort", help="Sort by 'index', 'label', or 'updated'"
),
kind: Optional[str] = typer.Option(
None,
"--kind",
help="Filter by entry type",
click_type=click.Choice(ALL_ENTRY_TYPES),
),
archived: bool = typer.Option(False, "--archived", help="Include archived"),
) -> None:
"""List entries in the vault."""
service = _get_entry_service(ctx)
entries = service.list_entries(
sort_by=sort,
filter_kinds=[kind] if kind else None,
include_archived=archived,
)
for idx, label, username, url, is_archived in entries:
line = f"{idx}: {label}"
if username:
line += f" ({username})"
if url:
line += f" {url}"
if is_archived:
line += " [archived]"
typer.echo(line)
@app.command("search")
def entry_search(
ctx: typer.Context,
query: str,
kinds: List[str] = typer.Option(
None,
"--kind",
"-k",
help="Filter by entry kinds (can be repeated)",
click_type=click.Choice(ALL_ENTRY_TYPES),
),
) -> None:
"""Search entries."""
service = _get_entry_service(ctx)
kinds = list(kinds) if kinds else None
results = service.search_entries(query, kinds=kinds)
if not results:
typer.echo("No matching entries found")
return
for idx, label, username, url, _arch, etype in results:
line = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}"
if username:
line += f" ({username})"
if url:
line += f" {url}"
typer.echo(line)
@app.command("get")
def entry_get(ctx: typer.Context, query: str) -> None:
"""Retrieve a single entry's secret."""
service = _get_entry_service(ctx)
try:
matches = service.search_entries(query)
if len(matches) == 0:
typer.echo("No matching entries found")
raise typer.Exit(code=1)
if len(matches) > 1:
typer.echo("Matches:")
for idx, label, username, _url, _arch, etype in matches:
name = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}"
if username:
name += f" ({username})"
typer.echo(name)
raise typer.Exit(code=1)
index = matches[0][0]
entry = service.retrieve_entry(index)
etype = entry.get("type", entry.get("kind"))
if etype == EntryType.PASSWORD.value:
length = int(entry.get("length", 12))
password = service.generate_password(length, index)
typer.echo(password)
elif etype == EntryType.TOTP.value:
code = service.get_totp_code(index)
typer.echo(code)
else:
typer.echo("Unsupported entry type")
raise typer.Exit(code=1)
except ClipboardUnavailableError as exc:
typer.echo(
f"Clipboard unavailable: {exc}\n"
"Re-run with '--no-clipboard' to print secrets instead.",
err=True,
)
raise typer.Exit(code=1)
@app.command("add")
def entry_add(
ctx: typer.Context,
label: str,
length: int = typer.Option(12, "--length"),
username: Optional[str] = typer.Option(None, "--username"),
url: Optional[str] = typer.Option(None, "--url"),
no_special: bool = typer.Option(
False, "--no-special", help="Exclude special characters", is_flag=True
),
allowed_special_chars: Optional[str] = typer.Option(
None, "--allowed-special-chars", help="Explicit set of special characters"
),
special_mode: Optional[str] = typer.Option(
None,
"--special-mode",
help="Special character mode",
),
exclude_ambiguous: bool = typer.Option(
False,
"--exclude-ambiguous",
help="Exclude ambiguous characters",
is_flag=True,
),
min_uppercase: Optional[int] = typer.Option(None, "--min-uppercase"),
min_lowercase: Optional[int] = typer.Option(None, "--min-lowercase"),
min_digits: Optional[int] = typer.Option(None, "--min-digits"),
min_special: Optional[int] = typer.Option(None, "--min-special"),
) -> None:
"""Add a new password entry and output its index."""
service = _get_entry_service(ctx)
kwargs = {}
if no_special:
kwargs["include_special_chars"] = False
if allowed_special_chars is not None:
kwargs["allowed_special_chars"] = allowed_special_chars
if special_mode is not None:
kwargs["special_mode"] = special_mode
if exclude_ambiguous:
kwargs["exclude_ambiguous"] = True
if min_uppercase is not None:
kwargs["min_uppercase"] = min_uppercase
if min_lowercase is not None:
kwargs["min_lowercase"] = min_lowercase
if min_digits is not None:
kwargs["min_digits"] = min_digits
if min_special is not None:
kwargs["min_special"] = min_special
index = service.add_entry(label, length, username, url, **kwargs)
typer.echo(str(index))
@app.command("add-totp")
def entry_add_totp(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
secret: Optional[str] = typer.Option(None, "--secret", help="Import secret"),
period: int = typer.Option(30, "--period", help="TOTP period in seconds"),
digits: int = typer.Option(6, "--digits", help="Number of TOTP digits"),
deterministic_totp: bool = typer.Option(
False, "--deterministic-totp", help="Derive secret deterministically"
),
) -> None:
"""Add a TOTP entry and output the otpauth URI."""
service = _get_entry_service(ctx)
uri = service.add_totp(
label,
index=index,
secret=secret,
period=period,
digits=digits,
deterministic=deterministic_totp,
)
typer.echo(uri)
@app.command("add-ssh")
def entry_add_ssh(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add an SSH key entry and output its index."""
service = _get_entry_service(ctx)
idx = service.add_ssh_key(
label,
index=index,
notes=notes,
)
typer.echo(str(idx))
@app.command("add-pgp")
def entry_add_pgp(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
key_type: str = typer.Option("ed25519", "--key-type", help="Key type"),
user_id: str = typer.Option("", "--user-id", help="User ID"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a PGP key entry and output its index."""
service = _get_entry_service(ctx)
idx = service.add_pgp_key(
label,
index=index,
key_type=key_type,
user_id=user_id,
notes=notes,
)
typer.echo(str(idx))
@app.command("add-nostr")
def entry_add_nostr(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a Nostr key entry and output its index."""
service = _get_entry_service(ctx)
idx = service.add_nostr_key(
label,
index=index,
notes=notes,
)
typer.echo(str(idx))
@app.command("add-seed")
def entry_add_seed(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
words: int = typer.Option(24, "--words", help="Word count"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a derived seed phrase entry and output its index."""
service = _get_entry_service(ctx)
idx = service.add_seed(
label,
index=index,
words=words,
notes=notes,
)
typer.echo(str(idx))
@app.command("add-key-value")
def entry_add_key_value(
ctx: typer.Context,
label: str,
key: str = typer.Option(..., "--key", help="Key name"),
value: str = typer.Option(..., "--value", help="Stored value"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a key/value entry and output its index."""
service = _get_entry_service(ctx)
idx = service.add_key_value(label, key, value, notes=notes)
typer.echo(str(idx))
@app.command("add-managed-account")
def entry_add_managed_account(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a managed account seed entry and output its index."""
service = _get_entry_service(ctx)
idx = service.add_managed_account(
label,
index=index,
notes=notes,
)
typer.echo(str(idx))
@app.command("modify")
def entry_modify(
ctx: typer.Context,
entry_id: int,
label: Optional[str] = typer.Option(None, "--label"),
username: Optional[str] = typer.Option(None, "--username"),
url: Optional[str] = typer.Option(None, "--url"),
notes: Optional[str] = typer.Option(None, "--notes"),
period: Optional[int] = typer.Option(
None, "--period", help="TOTP period in seconds"
),
digits: Optional[int] = typer.Option(None, "--digits", help="TOTP digits"),
key: Optional[str] = typer.Option(None, "--key", help="New key"),
value: Optional[str] = typer.Option(None, "--value", help="New value"),
) -> None:
"""Modify an existing entry."""
service = _get_entry_service(ctx)
try:
service.modify_entry(
entry_id,
username=username,
url=url,
notes=notes,
label=label,
period=period,
digits=digits,
key=key,
value=value,
)
except ValueError as e:
typer.echo(str(e))
sys.stdout.flush()
raise typer.Exit(code=1)
@app.command("archive")
def entry_archive(ctx: typer.Context, entry_id: int) -> None:
"""Archive an entry."""
service = _get_entry_service(ctx)
service.archive_entry(entry_id)
typer.echo(str(entry_id))
@app.command("unarchive")
def entry_unarchive(ctx: typer.Context, entry_id: int) -> None:
"""Restore an archived entry."""
service = _get_entry_service(ctx)
service.restore_entry(entry_id)
typer.echo(str(entry_id))
@app.command("totp-codes")
def entry_totp_codes(ctx: typer.Context) -> None:
"""Display all current TOTP codes."""
service = _get_entry_service(ctx)
service.display_totp_codes()
@app.command("export-totp")
def entry_export_totp(
ctx: typer.Context, file: str = typer.Option(..., help="Output file")
) -> None:
"""Export all TOTP secrets to a JSON file."""
service = _get_entry_service(ctx)
data = service.export_totp_entries()
Path(file).write_text(json.dumps(data, indent=2))
typer.echo(str(file))

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
import typer
from .common import _get_services, ProfileRemoveRequest, ProfileSwitchRequest
app = typer.Typer(help="Manage seed profiles")
@app.command("list")
def fingerprint_list(ctx: typer.Context) -> None:
"""List available seed profiles."""
_vault, profile_service, _sync = _get_services(ctx)
for fp in profile_service.list_profiles():
typer.echo(fp)
@app.command("add")
def fingerprint_add(ctx: typer.Context) -> None:
"""Create a new seed profile."""
_vault, profile_service, _sync = _get_services(ctx)
profile_service.add_profile()
@app.command("remove")
def fingerprint_remove(ctx: typer.Context, fingerprint: str) -> None:
"""Remove a seed profile."""
_vault, profile_service, _sync = _get_services(ctx)
profile_service.remove_profile(ProfileRemoveRequest(fingerprint=fingerprint))
@app.command("switch")
def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None:
"""Switch to another seed profile."""
_vault, profile_service, _sync = _get_services(ctx)
password = typer.prompt("Master password", hide_input=True)
profile_service.switch_profile(
ProfileSwitchRequest(fingerprint=fingerprint, password=password)
)

67
src/seedpass/cli/nostr.py Normal file
View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import typer
from .common import _get_services, _get_nostr_service
app = typer.Typer(
help="Interact with Nostr relays. See docs/nostr_setup.md for configuration and troubleshooting."
)
@app.command("sync")
def nostr_sync(ctx: typer.Context) -> None:
"""Sync with configured Nostr relays."""
_vault, _profile, sync_service = _get_services(ctx)
model = sync_service.sync()
if model:
typer.echo("Event IDs:")
typer.echo(f"- manifest: {model.manifest_id}")
for cid in model.chunk_ids:
typer.echo(f"- chunk: {cid}")
for did in model.delta_ids:
typer.echo(f"- delta: {did}")
else:
typer.echo("Error: Failed to sync vault")
@app.command("get-pubkey")
def nostr_get_pubkey(ctx: typer.Context) -> None:
"""Display the active profile's npub."""
service = _get_nostr_service(ctx)
npub = service.get_pubkey()
typer.echo(npub)
@app.command("list-relays")
def nostr_list_relays(ctx: typer.Context) -> None:
"""Display configured Nostr relays."""
service = _get_nostr_service(ctx)
relays = service.list_relays()
for i, r in enumerate(relays, 1):
typer.echo(f"{i}: {r}")
@app.command("add-relay")
def nostr_add_relay(ctx: typer.Context, url: str) -> None:
"""Add a relay URL."""
service = _get_nostr_service(ctx)
try:
service.add_relay(url)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo("Added")
@app.command("remove-relay")
def nostr_remove_relay(ctx: typer.Context, idx: int) -> None:
"""Remove a relay by index (1-based)."""
service = _get_nostr_service(ctx)
try:
service.remove_relay(idx)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo("Removed")

74
src/seedpass/cli/util.py Normal file
View File

@@ -0,0 +1,74 @@
from __future__ import annotations
from typing import Optional
import typer
from .common import _get_util_service
app = typer.Typer(help="Utility commands")
@app.command("generate-password")
def generate_password(
ctx: typer.Context,
length: int = 24,
no_special: bool = typer.Option(
False, "--no-special", help="Exclude special characters", is_flag=True
),
allowed_special_chars: Optional[str] = typer.Option(
None, "--allowed-special-chars", help="Explicit set of special characters"
),
special_mode: Optional[str] = typer.Option(
None,
"--special-mode",
help="Special character mode",
),
exclude_ambiguous: bool = typer.Option(
False,
"--exclude-ambiguous",
help="Exclude ambiguous characters",
is_flag=True,
),
min_uppercase: Optional[int] = typer.Option(None, "--min-uppercase"),
min_lowercase: Optional[int] = typer.Option(None, "--min-lowercase"),
min_digits: Optional[int] = typer.Option(None, "--min-digits"),
min_special: Optional[int] = typer.Option(None, "--min-special"),
) -> None:
"""Generate a strong password."""
service = _get_util_service(ctx)
kwargs = {}
if no_special:
kwargs["include_special_chars"] = False
if allowed_special_chars is not None:
kwargs["allowed_special_chars"] = allowed_special_chars
if special_mode is not None:
kwargs["special_mode"] = special_mode
if exclude_ambiguous:
kwargs["exclude_ambiguous"] = True
if min_uppercase is not None:
kwargs["min_uppercase"] = min_uppercase
if min_lowercase is not None:
kwargs["min_lowercase"] = min_lowercase
if min_digits is not None:
kwargs["min_digits"] = min_digits
if min_special is not None:
kwargs["min_special"] = min_special
password = service.generate_password(length, **kwargs)
typer.echo(password)
@app.command("verify-checksum")
def verify_checksum(ctx: typer.Context) -> None:
"""Verify the SeedPass script checksum."""
service = _get_util_service(ctx)
service.verify_checksum()
@app.command("update-checksum")
def update_checksum(ctx: typer.Context) -> None:
"""Regenerate the script checksum file."""
service = _get_util_service(ctx)
service.update_checksum()

99
src/seedpass/cli/vault.py Normal file
View File

@@ -0,0 +1,99 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
import typer
from .common import (
_get_services,
ChangePasswordRequest,
UnlockRequest,
BackupParentSeedRequest,
)
app = typer.Typer(help="Manage the entire vault")
@app.command("export")
def vault_export(
ctx: typer.Context, file: str = typer.Option(..., help="Output file")
) -> None:
"""Export the vault profile to an encrypted file."""
vault_service, _profile, _sync = _get_services(ctx)
data = vault_service.export_profile()
Path(file).write_bytes(data)
typer.echo(str(file))
@app.command("import")
def vault_import(
ctx: typer.Context, file: str = typer.Option(..., help="Input file")
) -> None:
"""Import a vault profile from an encrypted file."""
vault_service, _profile, _sync = _get_services(ctx)
data = Path(file).read_bytes()
vault_service.import_profile(data)
typer.echo(str(file))
@app.command("change-password")
def vault_change_password(ctx: typer.Context) -> None:
"""Change the master password used for encryption."""
vault_service, _profile, _sync = _get_services(ctx)
old_pw = typer.prompt("Current password", hide_input=True)
new_pw = typer.prompt("New password", hide_input=True, confirmation_prompt=True)
try:
vault_service.change_password(
ChangePasswordRequest(old_password=old_pw, new_password=new_pw)
)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo("Password updated")
@app.command("unlock")
def vault_unlock(ctx: typer.Context) -> None:
"""Unlock the vault for the active profile."""
vault_service, _profile, _sync = _get_services(ctx)
password = typer.prompt("Master password", hide_input=True)
try:
resp = vault_service.unlock(UnlockRequest(password=password))
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo(f"Unlocked in {resp.duration:.2f}s")
@app.command("lock")
def vault_lock(ctx: typer.Context) -> None:
"""Lock the vault and clear sensitive data from memory."""
vault_service, _profile, _sync = _get_services(ctx)
vault_service.lock()
typer.echo("locked")
@app.command("stats")
def vault_stats(ctx: typer.Context) -> None:
"""Display statistics about the current seed profile."""
vault_service, _profile, _sync = _get_services(ctx)
stats = vault_service.stats()
typer.echo(json.dumps(stats, indent=2))
@app.command("reveal-parent-seed")
def vault_reveal_parent_seed(
ctx: typer.Context,
file: Optional[str] = typer.Option(
None, "--file", help="Save encrypted seed to this path"
),
) -> None:
"""Display the parent seed and optionally write an encrypted backup file."""
vault_service, _profile, _sync = _get_services(ctx)
password = typer.prompt("Master password", hide_input=True)
vault_service.backup_parent_seed(
BackupParentSeedRequest(path=Path(file) if file else None, password=password)
)

View File

@@ -1,10 +1,17 @@
# password_manager/__init__.py
# seedpass.core/__init__.py
"""Expose password manager components with lazy imports."""
from importlib import import_module
__all__ = ["PasswordManager", "ConfigManager", "Vault", "EntryType"]
__all__ = [
"PasswordManager",
"ConfigManager",
"Vault",
"EntryType",
"StateManager",
"StatsManager",
]
def __getattr__(name: str):
@@ -16,4 +23,8 @@ def __getattr__(name: str):
return import_module(".vault", __name__).Vault
if name == "EntryType":
return import_module(".entry_types", __name__).EntryType
if name == "StateManager":
return import_module(".state_manager", __name__).StateManager
if name == "StatsManager":
return import_module(".stats_manager", __name__).StatsManager
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")

712
src/seedpass/core/api.py Normal file
View File

@@ -0,0 +1,712 @@
from __future__ import annotations
"""Service layer wrapping :class:`PasswordManager` operations.
These services provide thread-safe methods for common operations used by the CLI
and API. Request and response payloads are represented using Pydantic models to
allow easy validation and documentation.
"""
from pathlib import Path
from threading import Lock
from typing import List, Optional, Dict, Any
import dataclasses
import json
from pydantic import BaseModel
from .manager import PasswordManager
from .pubsub import bus
from .entry_types import EntryType
class VaultExportRequest(BaseModel):
"""Parameters required to export the vault."""
path: Path
class VaultExportResponse(BaseModel):
"""Result of a vault export operation."""
path: Path
class VaultImportRequest(BaseModel):
"""Parameters required to import a vault."""
path: Path
class ChangePasswordRequest(BaseModel):
"""Payload for :meth:`VaultService.change_password`."""
old_password: str
new_password: str
class UnlockRequest(BaseModel):
"""Payload for unlocking the vault."""
password: str
class UnlockResponse(BaseModel):
"""Duration taken to unlock the vault."""
duration: float
class BackupParentSeedRequest(BaseModel):
"""Optional path to write the encrypted seed backup."""
path: Optional[Path] = None
password: Optional[str] = None
class ProfileSwitchRequest(BaseModel):
"""Select a different seed profile."""
fingerprint: str
password: Optional[str] = None
class ProfileRemoveRequest(BaseModel):
"""Remove a seed profile."""
fingerprint: str
class SyncResponse(BaseModel):
"""Information about uploaded events after syncing."""
manifest_id: str
chunk_ids: List[str] = []
delta_ids: List[str] = []
class PasswordPolicyOptions(BaseModel):
"""Optional password policy overrides."""
include_special_chars: bool | None = None
allowed_special_chars: str | None = None
special_mode: str | None = None
exclude_ambiguous: bool | None = None
min_uppercase: int | None = None
min_lowercase: int | None = None
min_digits: int | None = None
min_special: int | None = None
class AddPasswordEntryRequest(PasswordPolicyOptions):
label: str
length: int
username: str | None = None
url: str | None = None
class GeneratePasswordRequest(PasswordPolicyOptions):
length: int
class GeneratePasswordResponse(BaseModel):
password: str
class VaultService:
"""Thread-safe wrapper around vault operations."""
def __init__(self, manager: PasswordManager) -> None:
self._manager = manager
self._lock = Lock()
def export_vault(self, req: VaultExportRequest) -> VaultExportResponse:
"""Export the vault to ``req.path``."""
with self._lock:
self._manager.handle_export_database(req.path)
return VaultExportResponse(path=req.path)
def import_vault(self, req: VaultImportRequest) -> None:
"""Import the vault from ``req.path`` and sync."""
with self._lock:
self._manager.handle_import_database(req.path)
self._manager.sync_vault()
def export_profile(self) -> bytes:
"""Return encrypted profile data for backup."""
with self._lock:
data = self._manager.vault.load_index()
payload = json.dumps(data, sort_keys=True, separators=(",", ":")).encode(
"utf-8"
)
return self._manager.vault.encryption_manager.encrypt_data(payload)
def import_profile(self, data: bytes) -> None:
"""Restore a profile from ``data`` and sync."""
with self._lock:
decrypted = self._manager.vault.encryption_manager.decrypt_data(
data, context="profile"
)
index = json.loads(decrypted.decode("utf-8"))
self._manager.vault.save_index(index)
self._manager.sync_vault()
def change_password(self, req: ChangePasswordRequest) -> None:
"""Change the master password."""
with self._lock:
self._manager.change_password(req.old_password, req.new_password)
def unlock(self, req: UnlockRequest) -> UnlockResponse:
"""Unlock the vault and return the duration."""
with self._lock:
duration = self._manager.unlock_vault(req.password)
return UnlockResponse(duration=duration)
def lock(self) -> None:
"""Lock the vault and clear sensitive data."""
with self._lock:
self._manager.lock_vault()
def backup_parent_seed(self, req: BackupParentSeedRequest) -> None:
"""Backup and reveal the parent seed."""
with self._lock:
self._manager.handle_backup_reveal_parent_seed(
req.path, password=req.password
)
def stats(self) -> Dict:
"""Return statistics about the current profile."""
with self._lock:
return self._manager.get_profile_stats()
class ProfileService:
"""Thread-safe wrapper around profile management operations."""
def __init__(self, manager: PasswordManager) -> None:
self._manager = manager
self._lock = Lock()
def list_profiles(self) -> List[str]:
"""List available seed profiles."""
with self._lock:
return list(self._manager.fingerprint_manager.list_fingerprints())
def add_profile(self) -> Optional[str]:
"""Create a new seed profile and return its fingerprint if available."""
with self._lock:
self._manager.add_new_fingerprint()
return getattr(
self._manager.fingerprint_manager, "current_fingerprint", None
)
def remove_profile(self, req: ProfileRemoveRequest) -> None:
"""Remove the specified seed profile."""
with self._lock:
self._manager.fingerprint_manager.remove_fingerprint(req.fingerprint)
def switch_profile(self, req: ProfileSwitchRequest) -> None:
"""Switch to ``req.fingerprint``."""
with self._lock:
self._manager.select_fingerprint(req.fingerprint, password=req.password)
class SyncService:
"""Thread-safe wrapper around vault synchronization."""
def __init__(self, manager: PasswordManager) -> None:
self._manager = manager
self._lock = Lock()
def sync(self) -> Optional[SyncResponse]:
"""Publish the vault to Nostr and return event info."""
with self._lock:
bus.publish("sync_started")
result = self._manager.sync_vault()
bus.publish("sync_finished", result)
if not result:
return None
return SyncResponse(**result)
def start_background_sync(self) -> None:
"""Begin background synchronization if possible."""
with self._lock:
self._manager.start_background_sync()
def start_background_vault_sync(self, summary: Optional[str] = None) -> None:
"""Publish the vault in a background thread."""
with self._lock:
self._manager.start_background_vault_sync(summary)
class EntryService:
"""Thread-safe wrapper around entry operations."""
def __init__(self, manager: PasswordManager) -> None:
self._manager = manager
self._lock = Lock()
def list_entries(
self,
sort_by: str = "index",
filter_kinds: list[str] | None = None,
include_archived: bool = False,
):
with self._lock:
return self._manager.entry_manager.list_entries(
sort_by=sort_by,
filter_kinds=filter_kinds,
include_archived=include_archived,
)
def search_entries(
self, query: str, kinds: list[str] | None = None
) -> list[tuple[int, str, str | None, str | None, bool, EntryType]]:
"""Search entries optionally filtering by ``kinds``.
Parameters
----------
query:
Search string to match against entry metadata.
kinds:
Optional list of entry kinds to restrict the search.
"""
with self._lock:
return self._manager.entry_manager.search_entries(query, kinds=kinds)
def retrieve_entry(self, entry_id: int):
with self._lock:
return self._manager.entry_manager.retrieve_entry(entry_id)
def generate_password(self, length: int, index: int) -> str:
with self._lock:
entry = self._manager.entry_manager.retrieve_entry(index)
gen_fn = getattr(self._manager, "_generate_password_for_entry", None)
if gen_fn is None:
return self._manager.password_generator.generate_password(length, index)
return gen_fn(entry, index, length)
def get_totp_code(self, entry_id: int) -> str:
with self._lock:
key = getattr(self._manager, "KEY_TOTP_DET", None) or getattr(
self._manager, "parent_seed", None
)
return self._manager.entry_manager.get_totp_code(entry_id, key)
def add_entry(
self,
label: str,
length: int,
username: str | None = None,
url: str | None = None,
*,
include_special_chars: bool | None = None,
allowed_special_chars: str | None = None,
special_mode: str | None = None,
exclude_ambiguous: bool | None = None,
min_uppercase: int | None = None,
min_lowercase: int | None = None,
min_digits: int | None = None,
min_special: int | None = None,
) -> int:
with self._lock:
kwargs: dict[str, Any] = {}
if include_special_chars is not None:
kwargs["include_special_chars"] = include_special_chars
if allowed_special_chars is not None:
kwargs["allowed_special_chars"] = allowed_special_chars
if special_mode is not None:
kwargs["special_mode"] = special_mode
if exclude_ambiguous is not None:
kwargs["exclude_ambiguous"] = exclude_ambiguous
if min_uppercase is not None:
kwargs["min_uppercase"] = min_uppercase
if min_lowercase is not None:
kwargs["min_lowercase"] = min_lowercase
if min_digits is not None:
kwargs["min_digits"] = min_digits
if min_special is not None:
kwargs["min_special"] = min_special
idx = self._manager.entry_manager.add_entry(
label,
length,
username,
url,
**kwargs,
)
self._manager.start_background_vault_sync()
return idx
def add_totp(
self,
label: str,
*,
index: int | None = None,
secret: str | None = None,
period: int = 30,
digits: int = 6,
deterministic: bool = False,
) -> str:
with self._lock:
key = self._manager.KEY_TOTP_DET if deterministic else None
uri = self._manager.entry_manager.add_totp(
label,
key,
index=index,
secret=secret,
period=period,
digits=digits,
deterministic=deterministic,
)
self._manager.start_background_vault_sync()
return uri
def add_ssh_key(
self,
label: str,
*,
index: int | None = None,
notes: str = "",
) -> int:
with self._lock:
idx = self._manager.entry_manager.add_ssh_key(
label,
self._manager.parent_seed,
index=index,
notes=notes,
)
self._manager.start_background_vault_sync()
return idx
def add_pgp_key(
self,
label: str,
*,
index: int | None = None,
key_type: str = "ed25519",
user_id: str = "",
notes: str = "",
) -> int:
with self._lock:
idx = self._manager.entry_manager.add_pgp_key(
label,
self._manager.parent_seed,
index=index,
key_type=key_type,
user_id=user_id,
notes=notes,
)
self._manager.start_background_vault_sync()
return idx
def add_nostr_key(
self,
label: str,
*,
index: int | None = None,
notes: str = "",
) -> int:
with self._lock:
idx = self._manager.entry_manager.add_nostr_key(
label,
self._manager.parent_seed,
index=index,
notes=notes,
)
self._manager.start_background_vault_sync()
return idx
def add_seed(
self,
label: str,
*,
index: int | None = None,
words: int = 24,
notes: str = "",
) -> int:
with self._lock:
idx = self._manager.entry_manager.add_seed(
label,
self._manager.parent_seed,
index=index,
words_num=words,
notes=notes,
)
self._manager.start_background_vault_sync()
return idx
def add_key_value(
self, label: str, key: str, value: str, *, notes: str = ""
) -> int:
with self._lock:
idx = self._manager.entry_manager.add_key_value(
label, key, value, notes=notes
)
self._manager.start_background_vault_sync()
return idx
def add_managed_account(
self,
label: str,
*,
index: int | None = None,
notes: str = "",
) -> int:
with self._lock:
idx = self._manager.entry_manager.add_managed_account(
label,
self._manager.parent_seed,
index=index,
notes=notes,
)
self._manager.start_background_vault_sync()
return idx
def modify_entry(
self,
entry_id: int,
*,
username: str | None = None,
url: str | None = None,
notes: str | None = None,
label: str | None = None,
period: int | None = None,
digits: int | None = None,
key: str | None = None,
value: str | None = None,
) -> None:
with self._lock:
self._manager.entry_manager.modify_entry(
entry_id,
username=username,
url=url,
notes=notes,
label=label,
period=period,
digits=digits,
key=key,
value=value,
)
self._manager.start_background_vault_sync()
def archive_entry(self, entry_id: int) -> None:
with self._lock:
self._manager.entry_manager.archive_entry(entry_id)
self._manager.start_background_vault_sync()
def restore_entry(self, entry_id: int) -> None:
with self._lock:
self._manager.entry_manager.restore_entry(entry_id)
self._manager.start_background_vault_sync()
def export_totp_entries(self) -> dict:
with self._lock:
key = getattr(self._manager, "KEY_TOTP_DET", None) or getattr(
self._manager, "parent_seed", None
)
return self._manager.entry_manager.export_totp_entries(key)
def display_totp_codes(self) -> None:
with self._lock:
self._manager.handle_display_totp_codes()
class ConfigService:
"""Thread-safe wrapper around configuration access."""
def __init__(self, manager: PasswordManager) -> None:
self._manager = manager
self._lock = Lock()
def get(self, key: str):
with self._lock:
return self._manager.config_manager.load_config(require_pin=False).get(key)
def set(self, key: str, value: str) -> None:
cfg = self._manager.config_manager
mapping = {
"inactivity_timeout": ("set_inactivity_timeout", float),
"secret_mode_enabled": (
"set_secret_mode_enabled",
lambda v: v.lower() in ("1", "true", "yes", "y", "on"),
),
"clipboard_clear_delay": ("set_clipboard_clear_delay", int),
"additional_backup_path": (
"set_additional_backup_path",
lambda v: v or None,
),
"relays": ("set_relays", lambda v: (v, {"require_pin": False})),
"kdf_iterations": ("set_kdf_iterations", int),
"kdf_mode": ("set_kdf_mode", lambda v: v),
"backup_interval": ("set_backup_interval", float),
"nostr_max_retries": ("set_nostr_max_retries", int),
"nostr_retry_delay": ("set_nostr_retry_delay", float),
"min_uppercase": ("set_min_uppercase", int),
"min_lowercase": ("set_min_lowercase", int),
"min_digits": ("set_min_digits", int),
"min_special": ("set_min_special", int),
"include_special_chars": (
"set_include_special_chars",
lambda v: v.lower() in ("1", "true", "yes", "y", "on"),
),
"allowed_special_chars": ("set_allowed_special_chars", lambda v: v),
"special_mode": ("set_special_mode", lambda v: v),
"exclude_ambiguous": (
"set_exclude_ambiguous",
lambda v: v.lower() in ("1", "true", "yes", "y", "on"),
),
"quick_unlock": (
"set_quick_unlock",
lambda v: v.lower() in ("1", "true", "yes", "y", "on"),
),
}
entry = mapping.get(key)
if entry is None:
raise KeyError(key)
method_name, conv = entry
with self._lock:
result = conv(value)
if (
isinstance(result, tuple)
and len(result) == 2
and isinstance(result[1], dict)
):
arg, kwargs = result
getattr(cfg, method_name)(arg, **kwargs)
else:
getattr(cfg, method_name)(result)
def get_secret_mode_enabled(self) -> bool:
with self._lock:
return self._manager.config_manager.get_secret_mode_enabled()
def get_clipboard_clear_delay(self) -> int:
with self._lock:
return self._manager.config_manager.get_clipboard_clear_delay()
def set_secret_mode(self, enabled: bool, delay: int) -> None:
with self._lock:
cfg = self._manager.config_manager
cfg.set_secret_mode_enabled(enabled)
cfg.set_clipboard_clear_delay(delay)
self._manager.secret_mode_enabled = enabled
self._manager.clipboard_clear_delay = delay
def get_offline_mode(self) -> bool:
with self._lock:
return self._manager.config_manager.get_offline_mode()
def set_offline_mode(self, enabled: bool) -> None:
with self._lock:
cfg = self._manager.config_manager
cfg.set_offline_mode(enabled)
self._manager.offline_mode = enabled
class UtilityService:
"""Miscellaneous helper operations."""
def __init__(self, manager: PasswordManager) -> None:
self._manager = manager
self._lock = Lock()
def generate_password(
self,
length: int,
*,
include_special_chars: bool | None = None,
allowed_special_chars: str | None = None,
special_mode: str | None = None,
exclude_ambiguous: bool | None = None,
min_uppercase: int | None = None,
min_lowercase: int | None = None,
min_digits: int | None = None,
min_special: int | None = None,
) -> str:
with self._lock:
pg = self._manager.password_generator
base_policy = getattr(pg, "policy", None)
overrides: dict[str, Any] = {}
if include_special_chars is not None:
overrides["include_special_chars"] = include_special_chars
if allowed_special_chars is not None:
overrides["allowed_special_chars"] = allowed_special_chars
if special_mode is not None:
overrides["special_mode"] = special_mode
if exclude_ambiguous is not None:
overrides["exclude_ambiguous"] = exclude_ambiguous
if min_uppercase is not None:
overrides["min_uppercase"] = int(min_uppercase)
if min_lowercase is not None:
overrides["min_lowercase"] = int(min_lowercase)
if min_digits is not None:
overrides["min_digits"] = int(min_digits)
if min_special is not None:
overrides["min_special"] = int(min_special)
if base_policy is not None and overrides:
pg.policy = dataclasses.replace(
base_policy,
**{k: overrides[k] for k in overrides if hasattr(base_policy, k)},
)
try:
return pg.generate_password(length)
finally:
pg.policy = base_policy
return pg.generate_password(length)
def verify_checksum(self) -> None:
with self._lock:
self._manager.handle_verify_checksum()
def update_checksum(self) -> None:
with self._lock:
self._manager.handle_update_script_checksum()
class NostrService:
"""Nostr related helper methods."""
def __init__(self, manager: PasswordManager) -> None:
self._manager = manager
self._lock = Lock()
def get_pubkey(self) -> str:
with self._lock:
return self._manager.nostr_client.key_manager.get_npub()
def list_relays(self) -> list[str]:
with self._lock:
return self._manager.state_manager.list_relays()
def add_relay(self, url: str) -> None:
with self._lock:
self._manager.state_manager.add_relay(url)
self._manager.nostr_client.relays = (
self._manager.state_manager.list_relays()
)
def remove_relay(self, idx: int) -> None:
with self._lock:
self._manager.state_manager.remove_relay(idx)
self._manager.nostr_client.relays = (
self._manager.state_manager.list_relays()
)

View File

@@ -1,4 +1,4 @@
# password_manager/backup.py
# seedpass.core/backup.py
"""
Backup Manager Module
@@ -15,11 +15,10 @@ import logging
import os
import shutil
import time
import traceback
from pathlib import Path
from termcolor import colored
from password_manager.config_manager import ConfigManager
from .config_manager import ConfigManager
from utils.file_lock import exclusive_lock
from constants import APP_DIR
@@ -146,6 +145,28 @@ class BackupManager:
)
)
def restore_from_backup(self, backup_path: str) -> None:
"""Restore the index file from a user-specified backup path."""
try:
src = Path(backup_path)
if not src.exists():
logger.error(f"Backup file '{src}' does not exist.")
print(colored(f"Error: Backup file '{src}' does not exist.", "red"))
return
shutil.copy2(src, self.index_file)
os.chmod(self.index_file, 0o600)
logger.info(f"Index file restored from backup '{src}'.")
print(colored(f"[+] Index file restored from backup '{src}'.", "green"))
except Exception as e:
logger.error(
f"Failed to restore from backup '{backup_path}': {e}", exc_info=True
)
print(
colored(
f"Error: Failed to restore from backup '{backup_path}': {e}", "red"
)
)
def list_backups(self) -> None:
try:
backup_files = sorted(

View File

@@ -6,14 +6,14 @@ import logging
from pathlib import Path
from typing import List, Optional
import getpass
from utils.seed_prompt import masked_input
import bcrypt
from password_manager.vault import Vault
from .vault import Vault
from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS
from constants import INACTIVITY_TIMEOUT
from constants import INACTIVITY_TIMEOUT, MAX_RETRIES, RETRY_DELAY
logger = logging.getLogger(__name__)
@@ -41,23 +41,28 @@ class ConfigManager:
logger.info("Config file not found; returning defaults")
return {
"relays": list(DEFAULT_NOSTR_RELAYS),
"offline_mode": False,
"offline_mode": True,
"pin_hash": "",
"password_hash": "",
"inactivity_timeout": INACTIVITY_TIMEOUT,
"kdf_iterations": 50_000,
"kdf_mode": "pbkdf2",
"argon2_time_cost": 2,
"additional_backup_path": "",
"backup_interval": 0,
"secret_mode_enabled": False,
"clipboard_clear_delay": 45,
"quick_unlock": False,
"nostr_max_retries": 2,
"nostr_retry_delay": 1.0,
"quick_unlock_enabled": False,
"nostr_max_retries": MAX_RETRIES,
"nostr_retry_delay": float(RETRY_DELAY),
"min_uppercase": 2,
"min_lowercase": 2,
"min_digits": 2,
"min_special": 2,
"include_special_chars": True,
"allowed_special_chars": "",
"special_mode": "standard",
"exclude_ambiguous": False,
"verbose_timing": False,
}
try:
@@ -66,23 +71,28 @@ class ConfigManager:
raise ValueError("Config data must be a dictionary")
# Ensure defaults for missing keys
data.setdefault("relays", list(DEFAULT_NOSTR_RELAYS))
data.setdefault("offline_mode", False)
data.setdefault("offline_mode", True)
data.setdefault("pin_hash", "")
data.setdefault("password_hash", "")
data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT)
data.setdefault("kdf_iterations", 50_000)
data.setdefault("kdf_mode", "pbkdf2")
data.setdefault("argon2_time_cost", 2)
data.setdefault("additional_backup_path", "")
data.setdefault("backup_interval", 0)
data.setdefault("secret_mode_enabled", False)
data.setdefault("clipboard_clear_delay", 45)
data.setdefault("quick_unlock", False)
data.setdefault("nostr_max_retries", 2)
data.setdefault("nostr_retry_delay", 1.0)
data.setdefault("quick_unlock_enabled", data.get("quick_unlock", False))
data.setdefault("nostr_max_retries", MAX_RETRIES)
data.setdefault("nostr_retry_delay", float(RETRY_DELAY))
data.setdefault("min_uppercase", 2)
data.setdefault("min_lowercase", 2)
data.setdefault("min_digits", 2)
data.setdefault("min_special", 2)
data.setdefault("include_special_chars", True)
data.setdefault("allowed_special_chars", "")
data.setdefault("special_mode", "standard")
data.setdefault("exclude_ambiguous", False)
data.setdefault("verbose_timing", False)
# Migrate legacy hashed_password.enc if present and password_hash is missing
@@ -93,7 +103,7 @@ class ConfigManager:
self.save_config(data)
if require_pin and data.get("pin_hash"):
for _ in range(3):
pin = getpass.getpass("Enter settings PIN: ").strip()
pin = masked_input("Enter settings PIN: ").strip()
if bcrypt.checkpw(pin.encode(), data["pin_hash"].encode()):
break
print("Invalid PIN")
@@ -188,6 +198,19 @@ class ConfigManager:
config = self.load_config(require_pin=False)
return config.get("kdf_mode", "pbkdf2")
def set_argon2_time_cost(self, time_cost: int) -> None:
"""Persist the Argon2 ``time_cost`` parameter."""
if time_cost <= 0:
raise ValueError("time_cost must be positive")
config = self.load_config(require_pin=False)
config["argon2_time_cost"] = int(time_cost)
self.save_config(config)
def get_argon2_time_cost(self) -> int:
"""Retrieve the Argon2 ``time_cost`` setting."""
config = self.load_config(require_pin=False)
return int(config.get("argon2_time_cost", 2))
def set_additional_backup_path(self, path: Optional[str]) -> None:
"""Persist an optional additional backup path in the config."""
config = self.load_config(require_pin=False)
@@ -220,7 +243,7 @@ class ConfigManager:
def get_offline_mode(self) -> bool:
"""Retrieve the offline mode setting."""
config = self.load_config(require_pin=False)
return bool(config.get("offline_mode", False))
return bool(config.get("offline_mode", True))
def set_clipboard_clear_delay(self, delay: int) -> None:
"""Persist clipboard clear timeout in seconds."""
@@ -251,7 +274,7 @@ class ConfigManager:
# Password policy settings
def get_password_policy(self) -> "PasswordPolicy":
"""Return the password complexity policy."""
from password_manager.password_generation import PasswordPolicy
from .password_generation import PasswordPolicy
cfg = self.load_config(require_pin=False)
return PasswordPolicy(
@@ -259,6 +282,10 @@ class ConfigManager:
min_lowercase=int(cfg.get("min_lowercase", 2)),
min_digits=int(cfg.get("min_digits", 2)),
min_special=int(cfg.get("min_special", 2)),
include_special_chars=bool(cfg.get("include_special_chars", True)),
allowed_special_chars=cfg.get("allowed_special_chars") or None,
special_mode=cfg.get("special_mode") or None,
exclude_ambiguous=bool(cfg.get("exclude_ambiguous", False)),
)
def set_min_uppercase(self, count: int) -> None:
@@ -281,16 +308,40 @@ class ConfigManager:
cfg["min_special"] = int(count)
self.save_config(cfg)
def set_include_special_chars(self, enabled: bool) -> None:
"""Persist whether special characters are allowed."""
cfg = self.load_config(require_pin=False)
cfg["include_special_chars"] = bool(enabled)
self.save_config(cfg)
def set_allowed_special_chars(self, chars: str | None) -> None:
"""Persist the set of allowed special characters."""
cfg = self.load_config(require_pin=False)
cfg["allowed_special_chars"] = chars or ""
self.save_config(cfg)
def set_special_mode(self, mode: str) -> None:
"""Persist the special character mode."""
cfg = self.load_config(require_pin=False)
cfg["special_mode"] = mode
self.save_config(cfg)
def set_exclude_ambiguous(self, enabled: bool) -> None:
"""Persist whether ambiguous characters are excluded."""
cfg = self.load_config(require_pin=False)
cfg["exclude_ambiguous"] = bool(enabled)
self.save_config(cfg)
def set_quick_unlock(self, enabled: bool) -> None:
"""Persist the quick unlock toggle."""
cfg = self.load_config(require_pin=False)
cfg["quick_unlock"] = bool(enabled)
cfg["quick_unlock_enabled"] = bool(enabled)
self.save_config(cfg)
def get_quick_unlock(self) -> bool:
"""Retrieve whether quick unlock is enabled."""
cfg = self.load_config(require_pin=False)
return bool(cfg.get("quick_unlock", False))
return bool(cfg.get("quick_unlock_enabled", False))
def set_nostr_max_retries(self, retries: int) -> None:
"""Persist the maximum number of Nostr retry attempts."""
@@ -303,7 +354,7 @@ class ConfigManager:
def get_nostr_max_retries(self) -> int:
"""Retrieve the configured Nostr retry count."""
cfg = self.load_config(require_pin=False)
return int(cfg.get("nostr_max_retries", 2))
return int(cfg.get("nostr_max_retries", MAX_RETRIES))
def set_nostr_retry_delay(self, delay: float) -> None:
"""Persist the delay between Nostr retry attempts."""
@@ -316,7 +367,7 @@ class ConfigManager:
def get_nostr_retry_delay(self) -> float:
"""Retrieve the delay in seconds between Nostr retries."""
cfg = self.load_config(require_pin=False)
return float(cfg.get("nostr_retry_delay", 1.0))
return float(cfg.get("nostr_retry_delay", float(RETRY_DELAY)))
def set_verbose_timing(self, enabled: bool) -> None:
cfg = self.load_config(require_pin=False)

View File

@@ -0,0 +1,616 @@
# /src/seedpass.core/encryption.py
import logging
import unicodedata
try:
import orjson as json_lib # type: ignore
JSONDecodeError = orjson.JSONDecodeError
USE_ORJSON = True
except Exception: # pragma: no cover - fallback for environments without orjson
import json as json_lib
from json import JSONDecodeError
USE_ORJSON = False
import hashlib
import os
import base64
import zlib
from dataclasses import asdict
from pathlib import Path
from typing import Optional, Tuple
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag
from cryptography.fernet import Fernet, InvalidToken
from termcolor import colored
from utils.file_lock import exclusive_lock
from mnemonic import Mnemonic
from utils.password_prompt import prompt_existing_password
from utils.key_derivation import KdfConfig, CURRENT_KDF_VERSION
# Instantiate the logger
logger = logging.getLogger(__name__)
def _derive_legacy_key_from_password(password: str, iterations: int = 100_000) -> bytes:
"""Derive legacy Fernet key using password only (no fingerprint)."""
normalized = unicodedata.normalize("NFKD", password).strip().encode("utf-8")
key = hashlib.pbkdf2_hmac("sha256", normalized, b"", iterations, dklen=32)
return base64.urlsafe_b64encode(key)
class LegacyFormatRequiresMigrationError(Exception):
"""Raised when legacy-encrypted data needs user-guided migration."""
def __init__(self, context: Optional[str] = None) -> None:
msg = (
f"Legacy data detected for {context}" if context else "Legacy data detected"
)
super().__init__(msg)
self.context = context
class EncryptionManager:
"""
Manages encryption and decryption, handling migration from legacy Fernet
to modern AES-GCM.
"""
def __init__(self, encryption_key: bytes, fingerprint_dir: Path):
"""
Initializes the EncryptionManager with keys for both new (AES-GCM)
and legacy (Fernet) encryption formats.
Parameters:
encryption_key (bytes): A base64-encoded key.
fingerprint_dir (Path): The directory corresponding to the fingerprint.
"""
self.fingerprint_dir = fingerprint_dir
self.parent_seed_file = self.fingerprint_dir / "parent_seed.enc"
try:
if isinstance(encryption_key, str):
encryption_key = encryption_key.encode()
# (1) Keep both the legacy Fernet instance and the new AES-GCM cipher ready.
self.key_b64 = encryption_key
self.fernet = Fernet(self.key_b64)
self.key = base64.urlsafe_b64decode(self.key_b64)
self.cipher = AESGCM(self.key)
logger.debug(f"EncryptionManager initialized for {self.fingerprint_dir}")
except Exception as e:
logger.error(
f"Failed to initialize ciphers with provided encryption key: {e}",
exc_info=True,
)
raise
# Track user preference for handling legacy indexes
self._legacy_migrate_flag = True
self.last_migration_performed = False
# Track nonces to detect accidental reuse
self.nonce_crc_table: set[int] = set()
def encrypt_data(self, data: bytes) -> bytes:
"""
Encrypt data using AES-GCM, emitting ``b"V3|" + nonce + ciphertext + tag``.
A fresh 96-bit nonce is generated for each call and tracked via a CRC
table to detect accidental reuse during batch operations.
"""
try:
nonce = os.urandom(12) # 96-bit nonce is recommended for AES-GCM
crc = zlib.crc32(nonce)
if crc in self.nonce_crc_table:
raise ValueError("Nonce reuse detected")
self.nonce_crc_table.add(crc)
ciphertext = self.cipher.encrypt(nonce, data, None)
return b"V3|" + nonce + ciphertext
except Exception as e:
logger.error(f"Failed to encrypt data: {e}", exc_info=True)
raise
def decrypt_data(
self, encrypted_data: bytes, context: Optional[str] = None
) -> bytes:
"""Decrypt ``encrypted_data`` handling legacy fallbacks.
Parameters
----------
encrypted_data:
The bytes to decrypt.
context:
Optional string describing what is being decrypted ("seed", "index", etc.)
for clearer error messages.
"""
ctx = f" {context}" if context else ""
try:
# Try the new V3 format first
if encrypted_data.startswith(b"V3|"):
try:
nonce = encrypted_data[3:15]
ciphertext = encrypted_data[15:]
if len(ciphertext) < 16:
logger.error("AES-GCM payload too short")
raise InvalidToken("AES-GCM payload too short")
return self.cipher.decrypt(nonce, ciphertext, None)
except InvalidTag as e:
msg = f"Failed to decrypt{ctx}: invalid key or corrupt file"
logger.error(msg)
raise InvalidToken(msg) from e
# Next try the older V2 format
if encrypted_data.startswith(b"V2:"):
try:
nonce = encrypted_data[3:15]
ciphertext = encrypted_data[15:]
if len(ciphertext) < 16:
logger.error("AES-GCM payload too short")
raise InvalidToken("AES-GCM payload too short")
return self.cipher.decrypt(nonce, ciphertext, None)
except InvalidTag as e:
logger.debug(
"AES-GCM decryption failed: Invalid authentication tag."
)
try:
result = self.fernet.decrypt(encrypted_data[3:])
logger.warning(
"Legacy-format file had incorrect 'V2:' header; decrypted with Fernet"
)
return result
except InvalidToken:
msg = f"Failed to decrypt{ctx}: invalid key or corrupt file"
logger.error(msg)
raise InvalidToken(msg) from e
# If it's neither V3 nor V2, assume legacy Fernet format
logger.warning("Data is in legacy Fernet format. Attempting migration.")
try:
return self.fernet.decrypt(encrypted_data)
except InvalidToken as e:
logger.error(
"Legacy Fernet decryption failed. Vault may be corrupt or key is incorrect."
)
raise e
except (InvalidToken, InvalidTag) as e:
if encrypted_data.startswith(b"V3|") or encrypted_data.startswith(b"V2:"):
# Already determined not to be legacy; re-raise
raise
if isinstance(e, InvalidToken) and str(e) == "AES-GCM payload too short":
raise
if not self._legacy_migrate_flag:
raise
logger.debug(f"Could not decrypt data{ctx}: {e}")
raise LegacyFormatRequiresMigrationError(context)
def decrypt_legacy(
self, encrypted_data: bytes, password: str, context: Optional[str] = None
) -> bytes:
"""Decrypt ``encrypted_data`` using legacy password-only key derivation."""
ctx = f" {context}" if context else ""
last_exc: Optional[Exception] = None
for iter_count in [50_000, 100_000]:
try:
legacy_key = _derive_legacy_key_from_password(
password, iterations=iter_count
)
legacy_mgr = EncryptionManager(legacy_key, self.fingerprint_dir)
legacy_mgr._legacy_migrate_flag = False
result = legacy_mgr.decrypt_data(encrypted_data, context=context)
try: # record iteration count for future runs
from .vault import Vault
from .config_manager import ConfigManager
cfg_mgr = ConfigManager(
Vault(self, self.fingerprint_dir), self.fingerprint_dir
)
cfg_mgr.set_kdf_iterations(iter_count)
except Exception: # pragma: no cover - best effort
logger.error(
"Failed to record PBKDF2 iteration count in config",
exc_info=True,
)
logger.warning(
"Data decrypted using legacy password-only key derivation."
)
return result
except Exception as e2: # pragma: no cover - try next iteration
last_exc = e2
logger.error(f"Failed legacy decryption attempt: {last_exc}", exc_info=True)
raise InvalidToken(
f"Could not decrypt{ctx} with any available method."
) from last_exc
# --- All functions below this point now use the smart `decrypt_data` method ---
def resolve_relative_path(self, relative_path: Path) -> Path:
"""Resolve ``relative_path`` within ``fingerprint_dir`` and validate it.
Parameters
----------
relative_path:
The user-supplied path relative to ``fingerprint_dir``.
Returns
-------
Path
The normalized absolute path inside ``fingerprint_dir``.
Raises
------
ValueError
If the resulting path is absolute or escapes ``fingerprint_dir``.
"""
candidate = (self.fingerprint_dir / relative_path).resolve()
if not candidate.is_relative_to(self.fingerprint_dir.resolve()):
raise ValueError("Invalid path outside fingerprint directory")
return candidate
def encrypt_parent_seed(
self, parent_seed: str, kdf: Optional[KdfConfig] = None
) -> None:
"""Encrypts and saves the parent seed to 'parent_seed.enc'."""
data = parent_seed.encode("utf-8")
self.encrypt_and_save_file(data, self.parent_seed_file, kdf=kdf)
logger.info(f"Parent seed encrypted and saved to '{self.parent_seed_file}'.")
def decrypt_parent_seed(self) -> str:
"""Decrypts and returns the parent seed, handling migration."""
with exclusive_lock(self.parent_seed_file) as fh:
fh.seek(0)
blob = fh.read()
kdf, encrypted_data = self._deserialize(blob)
is_legacy = not (
encrypted_data.startswith(b"V3|") or encrypted_data.startswith(b"V2:")
)
decrypted_data = self.decrypt_data(encrypted_data, context="seed")
if is_legacy:
logger.info("Parent seed was in legacy format. Re-encrypting to V3 format.")
self.encrypt_parent_seed(decrypted_data.decode("utf-8").strip(), kdf=kdf)
return decrypted_data.decode("utf-8").strip()
def _serialize(self, kdf: KdfConfig, ciphertext: bytes) -> bytes:
payload = {"kdf": asdict(kdf), "ct": base64.b64encode(ciphertext).decode()}
if USE_ORJSON:
return json_lib.dumps(payload)
return json_lib.dumps(payload, separators=(",", ":")).encode("utf-8")
def _deserialize(self, blob: bytes) -> Tuple[KdfConfig, bytes]:
"""Return ``(KdfConfig, ciphertext)`` from serialized *blob*.
Legacy files stored the raw ciphertext without a JSON wrapper. If
decoding the wrapper fails, treat ``blob`` as the ciphertext and return
a default HKDF configuration.
"""
try:
if USE_ORJSON:
obj = json_lib.loads(blob)
else:
obj = json_lib.loads(blob.decode("utf-8"))
kdf = KdfConfig(**obj.get("kdf", {}))
ct_b64 = obj.get("ct", "")
ciphertext = base64.b64decode(ct_b64)
if ciphertext:
return kdf, ciphertext
except Exception: # pragma: no cover - fall back to legacy path
pass
# Legacy format: ``blob`` already contains the ciphertext
return (
KdfConfig(name="hkdf", version=CURRENT_KDF_VERSION, params={}, salt_b64=""),
blob,
)
def encrypt_and_save_file(
self, data: bytes, relative_path: Path, *, kdf: Optional[KdfConfig] = None
) -> None:
if kdf is None:
kdf = KdfConfig()
file_path = self.resolve_relative_path(relative_path)
file_path.parent.mkdir(parents=True, exist_ok=True)
encrypted_data = self.encrypt_data(data)
payload = self._serialize(kdf, encrypted_data)
with exclusive_lock(file_path) as fh:
fh.seek(0)
fh.truncate()
fh.write(payload)
fh.flush()
os.fsync(fh.fileno())
os.chmod(file_path, 0o600)
def decrypt_file(self, relative_path: Path) -> bytes:
file_path = self.resolve_relative_path(relative_path)
with exclusive_lock(file_path) as fh:
fh.seek(0)
blob = fh.read()
_, encrypted_data = self._deserialize(blob)
return self.decrypt_data(encrypted_data, context=str(relative_path))
def get_file_kdf(self, relative_path: Path) -> KdfConfig:
file_path = self.resolve_relative_path(relative_path)
with exclusive_lock(file_path) as fh:
fh.seek(0)
blob = fh.read()
kdf, _ = self._deserialize(blob)
return kdf
def save_json_data(
self,
data: dict,
relative_path: Optional[Path] = None,
*,
kdf: Optional[KdfConfig] = None,
) -> None:
if relative_path is None:
relative_path = Path("seedpass_entries_db.json.enc")
if USE_ORJSON:
json_data = json_lib.dumps(data)
else:
json_data = json_lib.dumps(data, separators=(",", ":")).encode("utf-8")
self.encrypt_and_save_file(json_data, relative_path, kdf=kdf)
logger.debug(f"JSON data encrypted and saved to '{relative_path}'.")
def load_json_data(
self, relative_path: Optional[Path] = None, *, return_kdf: bool = False
) -> dict | Tuple[dict, KdfConfig]:
"""
Loads and decrypts JSON data, automatically migrating and re-saving
if it's in the legacy format.
"""
if relative_path is None:
relative_path = Path("seedpass_entries_db.json.enc")
file_path = self.resolve_relative_path(relative_path)
if not file_path.exists():
empty: dict = {"entries": {}}
if return_kdf:
return empty, KdfConfig(
name="hkdf", version=CURRENT_KDF_VERSION, params={}, salt_b64=""
)
return empty
with exclusive_lock(file_path) as fh:
fh.seek(0)
blob = fh.read()
kdf, encrypted_data = self._deserialize(blob)
is_legacy = not (
encrypted_data.startswith(b"V3|") or encrypted_data.startswith(b"V2:")
)
self.last_migration_performed = False
try:
decrypted_data = self.decrypt_data(
encrypted_data, context=str(relative_path)
)
if USE_ORJSON:
data = json_lib.loads(decrypted_data)
else:
data = json_lib.loads(decrypted_data.decode("utf-8"))
# If it was a legacy file, re-save it in the new format now
if is_legacy and self._legacy_migrate_flag:
logger.info(f"Migrating and re-saving legacy vault file: {file_path}")
self.save_json_data(data, relative_path, kdf=kdf)
self.update_checksum(relative_path)
self.last_migration_performed = True
if return_kdf:
return data, kdf
return data
except (InvalidToken, InvalidTag) as e:
msg = f"Failed to decrypt or parse data from {file_path}: {e}"
logger.error(msg)
raise InvalidToken(msg) from e
except JSONDecodeError as e:
msg = f"Failed to parse JSON data from {file_path}: {e}"
logger.error(msg)
raise
def get_encrypted_index(self) -> Optional[bytes]:
relative_path = Path("seedpass_entries_db.json.enc")
file_path = self.resolve_relative_path(relative_path)
if not file_path.exists():
return None
with exclusive_lock(file_path) as fh:
fh.seek(0)
return fh.read()
def decrypt_and_save_index_from_nostr(
self,
encrypted_data: bytes,
relative_path: Optional[Path] = None,
*,
strict: bool = True,
merge: bool = False,
) -> bool:
"""Decrypts data from Nostr and saves it.
Parameters
----------
encrypted_data:
The payload downloaded from Nostr.
relative_path:
Destination filename under the profile directory.
strict:
When ``True`` (default) re-raise any decryption error. When ``False``
return ``False`` if decryption fails.
"""
if relative_path is None:
relative_path = Path("seedpass_entries_db.json.enc")
kdf, ciphertext = self._deserialize(encrypted_data)
is_legacy = not (ciphertext.startswith(b"V3|") or ciphertext.startswith(b"V2:"))
self.last_migration_performed = False
def _process(decrypted: bytes) -> dict:
if USE_ORJSON:
data = json_lib.loads(decrypted)
else:
data = json_lib.loads(decrypted.decode("utf-8"))
existing_file = self.resolve_relative_path(relative_path)
if merge and existing_file.exists():
current = self.load_json_data(relative_path)
current_entries = current.get("entries", {})
for idx, entry in data.get("entries", {}).items():
cur_ts = current_entries.get(idx, {}).get("modified_ts", 0)
new_ts = entry.get("modified_ts", 0)
if idx not in current_entries or new_ts >= cur_ts:
current_entries[idx] = entry
current["entries"] = current_entries
if "schema_version" in data:
current["schema_version"] = max(
current.get("schema_version", 0), data.get("schema_version", 0)
)
data = current
return data
try:
decrypted_data = self.decrypt_data(ciphertext, context=str(relative_path))
data = _process(decrypted_data)
self.save_json_data(data, relative_path, kdf=kdf)
self.update_checksum(relative_path)
logger.info("Index file from Nostr was processed and saved successfully.")
self.last_migration_performed = is_legacy
return True
except (InvalidToken, LegacyFormatRequiresMigrationError):
try:
password = prompt_existing_password(
"Enter your master password for legacy decryption: "
)
decrypted_data = self.decrypt_legacy(
ciphertext, password, context=str(relative_path)
)
data = _process(decrypted_data)
self.save_json_data(data, relative_path, kdf=kdf)
self.update_checksum(relative_path)
logger.warning(
"Index decrypted using legacy password-only key derivation."
)
print(
colored(
"Warning: index decrypted with legacy key; it will be re-encrypted.",
"yellow",
)
)
self.last_migration_performed = True
return True
except Exception as e2:
if strict:
logger.error(
f"Failed legacy decryption attempt: {e2}",
exc_info=True,
)
print(
colored(
f"Error: Failed to decrypt and save data from Nostr: {e2}",
"red",
)
)
raise
logger.warning(f"Failed to decrypt index from Nostr: {e2}")
return False
except Exception as e: # pragma: no cover - error handling
if strict:
logger.error(
f"Failed to decrypt and save data from Nostr: {e}",
exc_info=True,
)
print(
colored(
f"Error: Failed to decrypt and save data from Nostr: {e}",
"red",
)
)
raise
logger.warning(f"Failed to decrypt index from Nostr: {e}")
return False
def update_checksum(self, relative_path: Optional[Path] = None) -> None:
"""Updates the checksum file for the specified file."""
if relative_path is None:
relative_path = Path("seedpass_entries_db.json.enc")
file_path = self.resolve_relative_path(relative_path)
if not file_path.exists():
return
try:
with exclusive_lock(file_path) as fh:
fh.seek(0)
encrypted_bytes = fh.read()
checksum = hashlib.sha256(encrypted_bytes).hexdigest()
# Build checksum path by stripping both `.json` and `.enc`
checksum_base = file_path.with_suffix("").with_suffix("")
checksum_file = checksum_base.parent / f"{checksum_base.name}_checksum.txt"
# Remove legacy checksum file if present
legacy_checksum = file_path.parent / f"{file_path.stem}_checksum.txt"
if legacy_checksum != checksum_file and legacy_checksum.exists():
try:
legacy_checksum.unlink()
except Exception:
logger.warning(
f"Could not remove legacy checksum file '{legacy_checksum}'",
exc_info=True,
)
with exclusive_lock(checksum_file) as fh:
fh.seek(0)
fh.truncate()
fh.write(checksum.encode("utf-8"))
fh.flush()
os.fsync(fh.fileno())
os.chmod(checksum_file, 0o600)
except Exception as e:
logger.error(
f"Failed to update checksum for '{relative_path}': {e}",
exc_info=True,
)
raise
def validate_seed(self, seed_phrase: str) -> tuple[bool, Optional[str]]:
"""Validate a BIP-39 mnemonic.
Returns a tuple of ``(is_valid, error_message)`` where ``error_message``
is ``None`` when the mnemonic is valid.
"""
try:
if Mnemonic("english").check(seed_phrase):
logger.debug("Seed phrase validated successfully.")
return True, None
logger.error("Seed phrase failed BIP-39 validation.")
return False, "Invalid seed phrase."
except Exception as e:
logger.error(f"Error validating seed phrase: {e}", exc_info=True)
return False, f"Failed to validate seed phrase: {e}"
def derive_seed_from_mnemonic(self, mnemonic: str, passphrase: str = "") -> bytes:
try:
if not isinstance(mnemonic, str):
if isinstance(mnemonic, list):
mnemonic = " ".join(mnemonic)
else:
mnemonic = str(mnemonic)
if not isinstance(mnemonic, str):
raise TypeError("Mnemonic must be a string after conversion")
from bip_utils import Bip39SeedGenerator
seed = Bip39SeedGenerator(mnemonic).Generate(passphrase)
logger.debug("Seed derived successfully from mnemonic.")
return seed
except Exception as e:
logger.error(f"Failed to derive seed from mnemonic: {e}", exc_info=True)
print(colored(f"Error: Failed to derive seed from mnemonic: {e}", "red"))
raise

View File

@@ -1,4 +1,4 @@
# password_manager/entry_management.py
# seedpass.core/entry_management.py
"""
Entry Management Module
@@ -25,20 +25,29 @@ except Exception: # pragma: no cover - fallback when orjson is missing
USE_ORJSON = False
import logging
import hashlib
import sys
import shutil
import time
from typing import Optional, Tuple, Dict, Any, List
from pathlib import Path
from termcolor import colored
from password_manager.migrations import LATEST_VERSION
from password_manager.entry_types import EntryType
from password_manager.totp import TotpManager
from .migrations import LATEST_VERSION
from .entry_types import EntryType, ALL_ENTRY_TYPES
from .totp import TotpManager, random_totp_secret
from utils.fingerprint import generate_fingerprint
from utils.checksum import canonical_json_dumps
from utils.atomic_write import atomic_write
from utils.key_validation import (
validate_totp_secret,
validate_ssh_key_pair,
validate_pgp_private_key,
validate_nostr_keys,
validate_seed_phrase,
)
from password_manager.vault import Vault
from password_manager.backup import BackupManager
from .vault import Vault
from .backup import BackupManager
from .errors import SeedPassError
# Instantiate the logger
@@ -97,6 +106,7 @@ class EntryManager:
entry["word_count"] = entry["words"]
entry.pop("words", None)
entry.setdefault("tags", [])
entry.setdefault("modified_ts", entry.get("updated", 0))
logger.debug("Index loaded successfully.")
self._index_cache = data
return data
@@ -138,7 +148,7 @@ class EntryManager:
except Exception as e:
logger.error(f"Error determining next index: {e}", exc_info=True)
print(colored(f"Error determining next index: {e}", "red"))
sys.exit(1)
raise SeedPassError(f"Error determining next index: {e}") from e
def add_entry(
self,
@@ -150,6 +160,15 @@ class EntryManager:
notes: str = "",
custom_fields: List[Dict[str, Any]] | None = None,
tags: list[str] | None = None,
*,
include_special_chars: bool | None = None,
allowed_special_chars: str | None = None,
special_mode: str | None = None,
exclude_ambiguous: bool | None = None,
min_uppercase: int | None = None,
min_lowercase: int | None = None,
min_digits: int | None = None,
min_special: int | None = None,
) -> int:
"""
Adds a new entry to the encrypted JSON index file.
@@ -167,7 +186,7 @@ class EntryManager:
data = self._load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {
entry = {
"label": label,
"length": length,
"username": username if username else "",
@@ -176,11 +195,36 @@ class EntryManager:
"type": EntryType.PASSWORD.value,
"kind": EntryType.PASSWORD.value,
"notes": notes,
"modified_ts": int(time.time()),
"custom_fields": custom_fields or [],
"tags": tags or [],
}
logger.debug(f"Added entry at index {index}: {data['entries'][str(index)]}")
policy: dict[str, Any] = {}
if include_special_chars is not None:
policy["include_special_chars"] = include_special_chars
if allowed_special_chars is not None:
policy["allowed_special_chars"] = allowed_special_chars
if special_mode is not None:
policy["special_mode"] = special_mode
if exclude_ambiguous is not None:
policy["exclude_ambiguous"] = exclude_ambiguous
if min_uppercase is not None:
policy["min_uppercase"] = int(min_uppercase)
if min_lowercase is not None:
policy["min_lowercase"] = int(min_lowercase)
if min_digits is not None:
policy["min_digits"] = int(min_digits)
if min_special is not None:
policy["min_special"] = int(min_special)
if policy:
entry["policy"] = policy
data["entries"][str(index)] = entry
logger.debug(
f"Added entry at index {index} with label '{entry.get('label', '')}'."
)
self._save_index(data)
self.update_checksum()
@@ -194,7 +238,7 @@ class EntryManager:
except Exception as e:
logger.error(f"Failed to add entry: {e}", exc_info=True)
print(colored(f"Error: Failed to add entry: {e}", "red"))
sys.exit(1)
raise SeedPassError(f"Failed to add entry: {e}") from e
def get_next_totp_index(self) -> int:
"""Return the next available derivation index for TOTP secrets."""
@@ -213,7 +257,7 @@ class EntryManager:
def add_totp(
self,
label: str,
parent_seed: str,
parent_seed: str | bytes | None = None,
*,
archived: bool = False,
secret: str | None = None,
@@ -222,38 +266,51 @@ class EntryManager:
digits: int = 6,
notes: str = "",
tags: list[str] | None = None,
deterministic: bool = False,
) -> str:
"""Add a new TOTP entry and return the provisioning URI."""
entry_id = self.get_next_index()
data = self._load_index()
data.setdefault("entries", {})
if secret is None:
if deterministic:
if parent_seed is None:
raise ValueError("Seed required for deterministic TOTP")
if index is None:
index = self.get_next_totp_index()
secret = TotpManager.derive_secret(parent_seed, index)
if not validate_totp_secret(secret):
raise ValueError("Invalid derived TOTP secret")
entry = {
"type": EntryType.TOTP.value,
"kind": EntryType.TOTP.value,
"label": label,
"modified_ts": int(time.time()),
"index": index,
"period": period,
"digits": digits,
"archived": archived,
"notes": notes,
"tags": tags or [],
"deterministic": True,
}
else:
if secret is None:
secret = random_totp_secret()
if not validate_totp_secret(secret):
raise ValueError("Invalid TOTP secret")
entry = {
"type": EntryType.TOTP.value,
"kind": EntryType.TOTP.value,
"label": label,
"secret": secret,
"modified_ts": int(time.time()),
"period": period,
"digits": digits,
"archived": archived,
"notes": notes,
"tags": tags or [],
"deterministic": False,
}
data["entries"][str(entry_id)] = entry
@@ -287,6 +344,12 @@ class EntryManager:
if index is None:
index = self.get_next_index()
from .password_generation import derive_ssh_key_pair
priv_pem, pub_pem = derive_ssh_key_pair(parent_seed, index)
if not validate_ssh_key_pair(priv_pem, pub_pem):
raise ValueError("Derived SSH key pair failed validation")
data = self._load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {
@@ -294,6 +357,7 @@ class EntryManager:
"kind": EntryType.SSH.value,
"index": index,
"label": label,
"modified_ts": int(time.time()),
"notes": notes,
"archived": archived,
"tags": tags or [],
@@ -312,7 +376,7 @@ class EntryManager:
if not entry or (etype != EntryType.SSH.value and kind != EntryType.SSH.value):
raise ValueError("Entry is not an SSH key entry")
from password_manager.password_generation import derive_ssh_key_pair
from .password_generation import derive_ssh_key_pair
key_index = int(entry.get("index", index))
return derive_ssh_key_pair(parent_seed, key_index)
@@ -333,6 +397,17 @@ class EntryManager:
if index is None:
index = self.get_next_index()
from .password_generation import derive_pgp_key
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
seed_bytes = Bip39SeedGenerator(parent_seed).Generate()
bip85 = BIP85(seed_bytes)
priv_key, fp = derive_pgp_key(bip85, index, key_type, user_id)
if not validate_pgp_private_key(priv_key, fp):
raise ValueError("Derived PGP key failed validation")
data = self._load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {
@@ -340,6 +415,7 @@ class EntryManager:
"kind": EntryType.PGP.value,
"index": index,
"label": label,
"modified_ts": int(time.time()),
"key_type": key_type,
"user_id": user_id,
"notes": notes,
@@ -360,7 +436,7 @@ class EntryManager:
if not entry or (etype != EntryType.PGP.value and kind != EntryType.PGP.value):
raise ValueError("Entry is not a PGP key entry")
from password_manager.password_generation import derive_pgp_key
from .password_generation import derive_pgp_key
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
@@ -375,6 +451,7 @@ class EntryManager:
def add_nostr_key(
self,
label: str,
parent_seed: str,
index: int | None = None,
notes: str = "",
archived: bool = False,
@@ -385,6 +462,19 @@ class EntryManager:
if index is None:
index = self.get_next_index()
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
from nostr.coincurve_keys import Keys
seed_bytes = Bip39SeedGenerator(parent_seed).Generate()
bip85 = BIP85(seed_bytes)
entropy = bip85.derive_entropy(index=index, entropy_bytes=32)
keys = Keys(priv_k=entropy.hex())
npub = Keys.hex_to_bech32(keys.public_key_hex(), "npub")
nsec = Keys.hex_to_bech32(keys.private_key_hex(), "nsec")
if not validate_nostr_keys(npub, nsec):
raise ValueError("Derived Nostr keys failed validation")
data = self._load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {
@@ -392,6 +482,7 @@ class EntryManager:
"kind": EntryType.NOSTR.value,
"index": index,
"label": label,
"modified_ts": int(time.time()),
"notes": notes,
"archived": archived,
"tags": tags or [],
@@ -404,6 +495,7 @@ class EntryManager:
def add_key_value(
self,
label: str,
key: str,
value: str,
*,
notes: str = "",
@@ -421,6 +513,8 @@ class EntryManager:
"type": EntryType.KEY_VALUE.value,
"kind": EntryType.KEY_VALUE.value,
"label": label,
"key": key,
"modified_ts": int(time.time()),
"value": value,
"notes": notes,
"archived": archived,
@@ -437,8 +531,8 @@ class EntryManager:
"""Return the npub and nsec for the specified entry."""
entry = self.retrieve_entry(index)
etype = entry.get("type") if entry else None
kind = entry.get("kind") if entry else None
etype = entry.get("type", "").lower() if entry else ""
kind = entry.get("kind", "").lower() if entry else ""
if not entry or (
etype != EntryType.NOSTR.value and kind != EntryType.NOSTR.value
):
@@ -452,7 +546,7 @@ class EntryManager:
bip85 = BIP85(seed_bytes)
key_idx = int(entry.get("index", index))
entropy = bip85.derive_entropy(index=key_idx, bytes_len=32)
entropy = bip85.derive_entropy(index=key_idx, entropy_bytes=32)
keys = Keys(priv_k=entropy.hex())
npub = Keys.hex_to_bech32(keys.public_key_hex(), "npub")
nsec = Keys.hex_to_bech32(keys.private_key_hex(), "nsec")
@@ -473,6 +567,16 @@ class EntryManager:
if index is None:
index = self.get_next_index()
from .password_generation import derive_seed_phrase
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
seed_bytes = Bip39SeedGenerator(parent_seed).Generate()
bip85 = BIP85(seed_bytes)
phrase = derive_seed_phrase(bip85, index, words_num)
if not validate_seed_phrase(phrase):
raise ValueError("Derived seed phrase failed validation")
data = self._load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {
@@ -480,6 +584,7 @@ class EntryManager:
"kind": EntryType.SEED.value,
"index": index,
"label": label,
"modified_ts": int(time.time()),
"word_count": words_num,
"notes": notes,
"archived": archived,
@@ -501,7 +606,7 @@ class EntryManager:
):
raise ValueError("Entry is not a seed entry")
from password_manager.password_generation import derive_seed_phrase
from .password_generation import derive_seed_phrase
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
@@ -530,7 +635,7 @@ class EntryManager:
if index is None:
index = self.get_next_index()
from password_manager.password_generation import derive_seed_phrase
from .password_generation import derive_seed_phrase
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
@@ -540,6 +645,8 @@ class EntryManager:
word_count = 12
seed_phrase = derive_seed_phrase(bip85, index, word_count)
if not validate_seed_phrase(seed_phrase):
raise ValueError("Derived managed account seed failed validation")
fingerprint = generate_fingerprint(seed_phrase)
account_dir = self.fingerprint_dir / "accounts" / fingerprint
@@ -552,6 +659,7 @@ class EntryManager:
"kind": EntryType.MANAGED_ACCOUNT.value,
"index": index,
"label": label,
"modified_ts": int(time.time()),
"word_count": word_count,
"notes": notes,
"fingerprint": fingerprint,
@@ -576,7 +684,7 @@ class EntryManager:
):
raise ValueError("Entry is not a managed account entry")
from password_manager.password_generation import derive_seed_phrase
from .password_generation import derive_seed_phrase
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
@@ -588,7 +696,10 @@ class EntryManager:
return derive_seed_phrase(bip85, seed_index, words)
def get_totp_code(
self, index: int, parent_seed: str | None = None, timestamp: int | None = None
self,
index: int,
parent_seed: str | bytes | None = None,
timestamp: int | None = None,
) -> str:
"""Return the current TOTP code for the specified entry."""
entry = self.retrieve_entry(index)
@@ -598,12 +709,12 @@ class EntryManager:
etype != EntryType.TOTP.value and kind != EntryType.TOTP.value
):
raise ValueError("Entry is not a TOTP entry")
if "secret" in entry:
return TotpManager.current_code_from_secret(entry["secret"], timestamp)
if parent_seed is None:
raise ValueError("Seed required for derived TOTP")
totp_index = int(entry.get("index", 0))
return TotpManager.current_code(parent_seed, totp_index, timestamp)
if entry.get("deterministic", False) or "secret" not in entry:
if parent_seed is None:
raise ValueError("Seed required for derived TOTP")
totp_index = int(entry.get("index", 0))
return TotpManager.current_code(parent_seed, totp_index, timestamp)
return TotpManager.current_code_from_secret(entry["secret"], timestamp)
def get_totp_time_remaining(self, index: int) -> int:
"""Return seconds remaining in the TOTP period for the given entry."""
@@ -618,7 +729,9 @@ class EntryManager:
period = int(entry.get("period", 30))
return TotpManager.time_remaining(period)
def export_totp_entries(self, parent_seed: str) -> dict[str, list[dict[str, Any]]]:
def export_totp_entries(
self, parent_seed: str | bytes | None
) -> dict[str, list[dict[str, Any]]]:
"""Return all TOTP secrets and metadata for external use."""
data = self._load_index()
entries = data.get("entries", {})
@@ -630,11 +743,13 @@ class EntryManager:
label = entry.get("label", "")
period = int(entry.get("period", 30))
digits = int(entry.get("digits", 6))
if "secret" in entry:
secret = entry["secret"]
else:
if entry.get("deterministic", False) or "secret" not in entry:
if parent_seed is None:
raise ValueError("Seed required for deterministic TOTP export")
idx = int(entry.get("index", 0))
secret = TotpManager.derive_secret(parent_seed, idx)
else:
secret = entry["secret"]
uri = TotpManager.make_otpauth_uri(label, secret, period, digits)
exported.append(
{
@@ -681,8 +796,11 @@ class EntryManager:
EntryType.MANAGED_ACCOUNT.value,
):
entry.setdefault("custom_fields", [])
logger.debug(f"Retrieved entry at index {index}: {entry}")
return entry
logger.debug(
f"Retrieved entry at index {index} with label '{entry.get('label', '')}'."
)
clean = {k: v for k, v in entry.items() if k != "modified_ts"}
return clean
else:
logger.warning(f"No entry found at index {index}.")
print(colored(f"Warning: No entry found at index {index}.", "yellow"))
@@ -708,9 +826,18 @@ class EntryManager:
label: Optional[str] = None,
period: Optional[int] = None,
digits: Optional[int] = None,
key: Optional[str] = None,
value: Optional[str] = None,
custom_fields: List[Dict[str, Any]] | None = None,
tags: list[str] | None = None,
include_special_chars: bool | None = None,
allowed_special_chars: str | None = None,
special_mode: str | None = None,
exclude_ambiguous: bool | None = None,
min_uppercase: int | None = None,
min_lowercase: int | None = None,
min_digits: int | None = None,
min_special: int | None = None,
**legacy,
) -> None:
"""
@@ -724,6 +851,7 @@ class EntryManager:
:param label: (Optional) The new label for the entry.
:param period: (Optional) The new TOTP period in seconds.
:param digits: (Optional) The new number of digits for TOTP codes.
:param key: (Optional) New key for key/value entries.
:param value: (Optional) New value for key/value entries.
"""
try:
@@ -752,9 +880,18 @@ class EntryManager:
"label": label,
"period": period,
"digits": digits,
"key": key,
"value": value,
"custom_fields": custom_fields,
"tags": tags,
"include_special_chars": include_special_chars,
"allowed_special_chars": allowed_special_chars,
"special_mode": special_mode,
"exclude_ambiguous": exclude_ambiguous,
"min_uppercase": min_uppercase,
"min_lowercase": min_lowercase,
"min_digits": min_digits,
"min_special": min_special,
}
allowed = {
@@ -766,6 +903,14 @@ class EntryManager:
"notes",
"custom_fields",
"tags",
"include_special_chars",
"allowed_special_chars",
"special_mode",
"exclude_ambiguous",
"min_uppercase",
"min_lowercase",
"min_digits",
"min_special",
},
EntryType.TOTP.value: {
"label",
@@ -778,6 +923,7 @@ class EntryManager:
},
EntryType.KEY_VALUE.value: {
"label",
"key",
"value",
"archived",
"notes",
@@ -858,6 +1004,9 @@ class EntryManager:
EntryType.KEY_VALUE.value,
EntryType.MANAGED_ACCOUNT.value,
):
if key is not None and entry_type == EntryType.KEY_VALUE.value:
entry["key"] = key
logger.debug(f"Updated key for index {index}.")
if value is not None:
entry["value"] = value
logger.debug(f"Updated value for index {index}.")
@@ -879,16 +1028,40 @@ class EntryManager:
if custom_fields is not None:
entry["custom_fields"] = custom_fields
logger.debug(
f"Updated custom fields for index {index}: {custom_fields}"
)
logger.debug(f"Updated custom fields for index {index}.")
if tags is not None:
entry["tags"] = tags
logger.debug(f"Updated tags for index {index}: {tags}")
logger.debug(f"Updated tags for index {index}.")
policy_updates: dict[str, Any] = {}
if include_special_chars is not None:
policy_updates["include_special_chars"] = include_special_chars
if allowed_special_chars is not None:
policy_updates["allowed_special_chars"] = allowed_special_chars
if special_mode is not None:
policy_updates["special_mode"] = special_mode
if exclude_ambiguous is not None:
policy_updates["exclude_ambiguous"] = exclude_ambiguous
if min_uppercase is not None:
policy_updates["min_uppercase"] = int(min_uppercase)
if min_lowercase is not None:
policy_updates["min_lowercase"] = int(min_lowercase)
if min_digits is not None:
policy_updates["min_digits"] = int(min_digits)
if min_special is not None:
policy_updates["min_special"] = int(min_special)
if policy_updates:
entry_policy = entry.get("policy", {})
entry_policy.update(policy_updates)
entry["policy"] = entry_policy
entry["modified_ts"] = int(time.time())
data["entries"][str(index)] = entry
logger.debug(f"Modified entry at index {index}: {entry}")
logger.debug(
f"Modified entry at index {index} with label '{entry.get('label', '')}'."
)
self._save_index(data)
self.update_checksum()
@@ -917,14 +1090,23 @@ class EntryManager:
def list_entries(
self,
sort_by: str = "index",
filter_kind: str | None = None,
filter_kinds: list[str] | None = None,
*,
include_archived: bool = False,
verbose: bool = True,
) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]:
"""List entries in the index with optional sorting and filtering.
"""List entries sorted and filtered according to the provided options.
By default archived entries are omitted unless ``include_archived`` is
``True``.
Parameters
----------
sort_by:
Field to sort by. Supported values are ``"index"``, ``"label"`` and
``"updated"``.
filter_kinds:
Optional list of entry kinds to restrict the results. Defaults to
``ALL_ENTRY_TYPES``.
Archived entries are omitted unless ``include_archived`` is ``True``.
"""
try:
data = self._load_index()
@@ -932,27 +1114,33 @@ class EntryManager:
if not entries_data:
logger.info("No entries found.")
print(colored("No entries found.", "yellow"))
if verbose:
print(colored("No entries found.", "yellow"))
return []
def sort_key(item: Tuple[str, Dict[str, Any]]):
idx_str, entry = item
if sort_by == "index":
return int(idx_str)
if sort_by in {"website", "label"}:
if sort_by == "label":
# labels are stored in the index so no additional
# decryption is required when sorting
return entry.get("label", entry.get("website", "")).lower()
if sort_by == "username":
return entry.get("username", "").lower()
raise ValueError("sort_by must be 'index', 'label', or 'username'")
if sort_by == "updated":
# sort newest first
return -int(entry.get("updated", 0))
raise ValueError("sort_by must be 'index', 'label', or 'updated'")
sorted_items = sorted(entries_data.items(), key=sort_key)
if filter_kinds is None:
filter_kinds = ALL_ENTRY_TYPES
filtered_items: List[Tuple[int, Dict[str, Any]]] = []
for idx_str, entry in sorted_items:
if (
filter_kind is not None
and entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
!= filter_kind
entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
not in filter_kinds
):
continue
if not include_archived and entry.get(
@@ -987,57 +1175,70 @@ class EntryManager:
)
logger.debug(f"Total entries found: {len(entries)}")
for idx, entry in filtered_items:
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
print(colored(f"Index: {idx}", "cyan"))
if etype == EntryType.TOTP.value:
print(colored(" Type: TOTP", "cyan"))
print(colored(f" Label: {entry.get('label', '')}", "cyan"))
print(colored(f" Derivation Index: {entry.get('index')}", "cyan"))
print(
colored(
f" Period: {entry.get('period', 30)}s Digits: {entry.get('digits', 6)}",
"cyan",
if verbose:
for idx, entry in filtered_items:
etype = entry.get(
"type", entry.get("kind", EntryType.PASSWORD.value)
)
print(colored(f"Index: {idx}", "cyan"))
if etype == EntryType.TOTP.value:
print(colored(" Type: TOTP", "cyan"))
print(colored(f" Label: {entry.get('label', '')}", "cyan"))
print(
colored(f" Derivation Index: {entry.get('index')}", "cyan")
)
)
elif etype == EntryType.PASSWORD.value:
print(
colored(
f" Label: {entry.get('label', entry.get('website', ''))}",
"cyan",
print(
colored(
f" Period: {entry.get('period', 30)}s Digits: {entry.get('digits', 6)}",
"cyan",
)
)
)
print(
colored(f" Username: {entry.get('username') or 'N/A'}", "cyan")
)
print(colored(f" URL: {entry.get('url') or 'N/A'}", "cyan"))
print(
colored(
f" Archived: {'Yes' if entry.get('archived', entry.get('blacklisted', False)) else 'No'}",
"cyan",
elif etype == EntryType.PASSWORD.value:
print(
colored(
f" Label: {entry.get('label', entry.get('website', ''))}",
"cyan",
)
)
)
else:
print(colored(f" Label: {entry.get('label', '')}", "cyan"))
print(
colored(
f" Derivation Index: {entry.get('index', idx)}",
"cyan",
print(
colored(
f" Username: {entry.get('username') or 'N/A'}", "cyan"
)
)
)
print("-" * 40)
print(colored(f" URL: {entry.get('url') or 'N/A'}", "cyan"))
print(
colored(
f" Archived: {'Yes' if entry.get('archived', entry.get('blacklisted', False)) else 'No'}",
"cyan",
)
)
else:
print(colored(f" Label: {entry.get('label', '')}", "cyan"))
print(
colored(
f" Derivation Index: {entry.get('index', idx)}",
"cyan",
)
)
print("-" * 40)
return entries
except Exception as e:
logger.error(f"Failed to list entries: {e}", exc_info=True)
print(colored(f"Error: Failed to list entries: {e}", "red"))
if verbose:
print(colored(f"Error: Failed to list entries: {e}", "red"))
return []
def search_entries(
self, query: str
) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]:
"""Return entries matching the query across common fields."""
self, query: str, kinds: List[str] | None = None
) -> List[Tuple[int, str, Optional[str], Optional[str], bool, EntryType]]:
"""Return entries matching ``query`` across whitelisted metadata fields.
Each match is represented as ``(index, label, username, url, archived, etype)``
where ``etype`` is the :class:`EntryType` of the entry.
"""
data = self._load_index()
entries_data = data.get("entries", {})
@@ -1045,78 +1246,42 @@ class EntryManager:
return []
query_lower = query.lower()
results: List[Tuple[int, str, Optional[str], Optional[str], bool]] = []
results: List[
Tuple[int, str, Optional[str], Optional[str], bool, EntryType]
] = []
for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])):
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
etype = EntryType(
entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
)
if kinds is not None and etype.value not in kinds:
continue
label = entry.get("label", entry.get("website", ""))
notes = entry.get("notes", "")
username = (
entry.get("username", "") if etype == EntryType.PASSWORD else None
)
url = entry.get("url", "") if etype == EntryType.PASSWORD else None
tags = entry.get("tags", [])
archived = entry.get("archived", entry.get("blacklisted", False))
label_match = query_lower in label.lower()
notes_match = query_lower in notes.lower()
username_match = bool(username) and query_lower in username.lower()
url_match = bool(url) and query_lower in url.lower()
tags_match = any(query_lower in str(t).lower() for t in tags)
if etype == EntryType.PASSWORD.value:
username = entry.get("username", "")
url = entry.get("url", "")
custom_fields = entry.get("custom_fields", [])
custom_match = any(
query_lower in str(cf.get("label", "")).lower()
or query_lower in str(cf.get("value", "")).lower()
for cf in custom_fields
if label_match or username_match or url_match or tags_match:
results.append(
(
int(idx),
label,
username if username is not None else None,
url if url is not None else None,
archived,
etype,
)
)
if (
label_match
or query_lower in username.lower()
or query_lower in url.lower()
or notes_match
or custom_match
or tags_match
):
results.append(
(
int(idx),
label,
username,
url,
entry.get("archived", entry.get("blacklisted", False)),
)
)
elif etype in (EntryType.KEY_VALUE.value, EntryType.MANAGED_ACCOUNT.value):
value_field = str(entry.get("value", ""))
custom_fields = entry.get("custom_fields", [])
custom_match = any(
query_lower in str(cf.get("label", "")).lower()
or query_lower in str(cf.get("value", "")).lower()
for cf in custom_fields
)
if (
label_match
or query_lower in value_field.lower()
or notes_match
or custom_match
or tags_match
):
results.append(
(
int(idx),
label,
None,
None,
entry.get("archived", entry.get("blacklisted", False)),
)
)
else:
if label_match or notes_match or tags_match:
results.append(
(
int(idx),
label,
None,
None,
entry.get("archived", entry.get("blacklisted", False)),
)
)
return results
@@ -1169,8 +1334,7 @@ class EntryManager:
# The checksum file path already includes the fingerprint directory
checksum_path = self.checksum_file
with open(checksum_path, "w") as f:
f.write(checksum)
atomic_write(checksum_path, lambda f: f.write(checksum))
logger.debug(f"Checksum updated and written to '{checksum_path}'.")
print(colored(f"[+] Checksum updated successfully.", "green"))
@@ -1224,7 +1388,7 @@ class EntryManager:
def list_all_entries(
self,
sort_by: str = "index",
filter_kind: str | None = None,
filter_kinds: list[str] | None = None,
*,
include_archived: bool = False,
) -> None:
@@ -1232,7 +1396,7 @@ class EntryManager:
try:
entries = self.list_entries(
sort_by=sort_by,
filter_kind=filter_kind,
filter_kinds=filter_kinds,
include_archived=include_archived,
)
if not entries:
@@ -1256,7 +1420,7 @@ class EntryManager:
def get_entry_summaries(
self,
filter_kind: str | None = None,
filter_kinds: list[str] | None = None,
*,
include_archived: bool = False,
) -> list[tuple[int, str, str]]:
@@ -1265,10 +1429,13 @@ class EntryManager:
data = self._load_index()
entries_data = data.get("entries", {})
if filter_kinds is None:
filter_kinds = ALL_ENTRY_TYPES
summaries: list[tuple[int, str, str]] = []
for idx_str, entry in entries_data.items():
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
if filter_kind and etype != filter_kind:
if etype not in filter_kinds:
continue
if not include_archived and entry.get(
"archived", entry.get("blacklisted", False)

View File

@@ -0,0 +1,233 @@
from __future__ import annotations
import logging
import time
from typing import TYPE_CHECKING
from termcolor import colored
from constants import (
DEFAULT_PASSWORD_LENGTH,
MAX_PASSWORD_LENGTH,
MIN_PASSWORD_LENGTH,
)
import seedpass.core.manager as manager_module
from utils.terminal_utils import clear_header_with_notification, pause
if TYPE_CHECKING: # pragma: no cover - typing only
from .manager import PasswordManager
class EntryService:
"""Entry management operations for :class:`PasswordManager`."""
def __init__(self, manager: PasswordManager) -> None:
self.manager = manager
def handle_add_password(self) -> None:
pm = self.manager
try:
fp, parent_fp, child_fp = pm.header_fingerprint_args
clear_header_with_notification(
pm,
fp,
"Main Menu > Add Entry > Password",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
def prompt_length() -> int | None:
length_input = input(
f"Enter desired password length (default {DEFAULT_PASSWORD_LENGTH}): "
).strip()
length = DEFAULT_PASSWORD_LENGTH
if length_input:
if not length_input.isdigit():
print(
colored("Error: Password length must be a number.", "red")
)
return None
length = int(length_input)
if not (MIN_PASSWORD_LENGTH <= length <= MAX_PASSWORD_LENGTH):
print(
colored(
f"Error: Password length must be between {MIN_PASSWORD_LENGTH} and {MAX_PASSWORD_LENGTH}.",
"red",
)
)
return None
return length
def finalize_entry(index: int, label: str, length: int) -> None:
pm.is_dirty = True
pm.last_update = time.time()
entry = pm.entry_manager.retrieve_entry(index)
password = pm._generate_password_for_entry(entry, index, length)
print(
colored(
f"\n[+] Password generated and indexed with ID {index}.\n",
"green",
)
)
if pm.secret_mode_enabled:
if manager_module.copy_to_clipboard(
password, pm.clipboard_clear_delay
):
print(
colored(
f"[+] Password copied to clipboard. Will clear in {pm.clipboard_clear_delay} seconds.",
"green",
)
)
else:
print(colored(f"Password for {label}: {password}\n", "yellow"))
try:
pm.start_background_vault_sync()
logging.info(
"Encrypted index posted to Nostr after entry addition."
)
except Exception as nostr_error: # pragma: no cover - best effort
logging.error(
f"Failed to post updated index to Nostr: {nostr_error}",
exc_info=True,
)
pause()
mode = input("Choose mode: [Q]uick or [A]dvanced? ").strip().lower()
website_name = input("Enter the label or website name: ").strip()
if not website_name:
print(colored("Error: Label cannot be empty.", "red"))
return
username = input("Enter the username (optional): ").strip()
url = input("Enter the URL (optional): ").strip()
if mode.startswith("q"):
length = prompt_length()
if length is None:
return
include_special_input = (
input("Include special characters? (Y/n): ").strip().lower()
)
include_special_chars: bool | None = None
if include_special_input:
include_special_chars = include_special_input != "n"
index = pm.entry_manager.add_entry(
website_name,
length,
username,
url,
include_special_chars=include_special_chars,
)
finalize_entry(index, website_name, length)
return
notes = input("Enter notes (optional): ").strip()
tags_input = input("Enter tags (comma-separated, optional): ").strip()
tags = (
[t.strip() for t in tags_input.split(",") if t.strip()]
if tags_input
else []
)
custom_fields: list[dict[str, object]] = []
while True:
add_field = input("Add custom field? (y/N): ").strip().lower()
if add_field != "y":
break
label = input(" Field label: ").strip()
value = input(" Field value: ").strip()
hidden = input(" Hidden field? (y/N): ").strip().lower() == "y"
custom_fields.append(
{"label": label, "value": value, "is_hidden": hidden}
)
length = prompt_length()
if length is None:
return
include_special_input = (
input("Include special characters? (Y/n): ").strip().lower()
)
include_special_chars: bool | None = None
if include_special_input:
include_special_chars = include_special_input != "n"
allowed_special_chars = input(
"Allowed special characters (leave blank for default): "
).strip()
if not allowed_special_chars:
allowed_special_chars = None
special_mode = input("Special character mode (safe/leave blank): ").strip()
if not special_mode:
special_mode = None
exclude_ambiguous_input = (
input("Exclude ambiguous characters? (y/N): ").strip().lower()
)
exclude_ambiguous: bool | None = None
if exclude_ambiguous_input:
exclude_ambiguous = exclude_ambiguous_input == "y"
min_uppercase_input = input(
"Minimum uppercase letters (blank for default): "
).strip()
if min_uppercase_input and not min_uppercase_input.isdigit():
print(colored("Error: Minimum uppercase must be a number.", "red"))
return
min_uppercase = int(min_uppercase_input) if min_uppercase_input else None
min_lowercase_input = input(
"Minimum lowercase letters (blank for default): "
).strip()
if min_lowercase_input and not min_lowercase_input.isdigit():
print(colored("Error: Minimum lowercase must be a number.", "red"))
return
min_lowercase = int(min_lowercase_input) if min_lowercase_input else None
min_digits_input = input("Minimum digits (blank for default): ").strip()
if min_digits_input and not min_digits_input.isdigit():
print(colored("Error: Minimum digits must be a number.", "red"))
return
min_digits = int(min_digits_input) if min_digits_input else None
min_special_input = input(
"Minimum special characters (blank for default): "
).strip()
if min_special_input and not min_special_input.isdigit():
print(colored("Error: Minimum special must be a number.", "red"))
return
min_special = int(min_special_input) if min_special_input else None
index = pm.entry_manager.add_entry(
website_name,
length,
username,
url,
archived=False,
notes=notes,
custom_fields=custom_fields,
tags=tags,
include_special_chars=include_special_chars,
allowed_special_chars=allowed_special_chars,
special_mode=special_mode,
exclude_ambiguous=exclude_ambiguous,
min_uppercase=min_uppercase,
min_lowercase=min_lowercase,
min_digits=min_digits,
min_special=min_special,
)
finalize_entry(index, website_name, length)
except Exception as e: # pragma: no cover - defensive
logging.error(f"Error during password generation: {e}", exc_info=True)
print(colored(f"Error: Failed to generate password: {e}", "red"))
pause()

View File

@@ -1,4 +1,4 @@
# password_manager/entry_types.py
# seedpass.core/entry_types.py
"""Enumerations for entry types used by SeedPass."""
from enum import Enum
@@ -15,3 +15,7 @@ class EntryType(str, Enum):
NOSTR = "nostr"
KEY_VALUE = "key_value"
MANAGED_ACCOUNT = "managed_account"
# List of all entry type values for convenience
ALL_ENTRY_TYPES = [e.value for e in EntryType]

View File

@@ -0,0 +1,21 @@
"""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
class SeedPassError(ClickException):
"""Base exception for SeedPass-related errors."""
def __init__(self, message: str):
super().__init__(message)
__all__ = ["SeedPassError"]

View File

@@ -0,0 +1,185 @@
from __future__ import annotations
import logging
import sys
from typing import TYPE_CHECKING
from termcolor import colored
from .entry_types import EntryType, ALL_ENTRY_TYPES
import seedpass.core.manager as manager_module
from utils.color_scheme import color_text
from utils.terminal_utils import clear_header_with_notification
if TYPE_CHECKING: # pragma: no cover - typing only
from .manager import PasswordManager
class MenuHandler:
"""Handle interactive menu operations for :class:`PasswordManager`."""
def __init__(self, manager: PasswordManager) -> None:
self.manager = manager
def handle_list_entries(self) -> None:
"""List entries and optionally show details."""
pm = self.manager
try:
while True:
fp, parent_fp, child_fp = pm.header_fingerprint_args
clear_header_with_notification(
pm,
fp,
"Main Menu > List Entries",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
print(color_text("\nList Entries:", "menu"))
print(color_text("1. All", "menu"))
option_map: dict[str, str] = {}
for i, etype in enumerate(ALL_ENTRY_TYPES, start=2):
label = etype.replace("_", " ").title()
print(color_text(f"{i}. {label}", "menu"))
option_map[str(i)] = etype
choice = input("Select entry type or press Enter to go back: ").strip()
if choice == "1":
filter_kinds = None
elif choice in option_map:
filter_kinds = [option_map[choice]]
elif not choice:
return
else:
print(colored("Invalid choice.", "red"))
continue
while True:
summaries = pm.entry_manager.get_entry_summaries(
filter_kinds, include_archived=False
)
if not summaries:
break
fp, parent_fp, child_fp = pm.header_fingerprint_args
clear_header_with_notification(
pm,
fp,
"Main Menu > List Entries",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
print(colored("\n[+] Entries:\n", "green"))
for idx, etype, label in summaries:
if filter_kinds is None:
display_type = etype.capitalize()
print(colored(f"{idx}. {display_type} - {label}", "cyan"))
else:
print(colored(f"{idx}. {label}", "cyan"))
idx_input = input(
"Enter index to view details or press Enter to go back: "
).strip()
if not idx_input:
break
if not idx_input.isdigit():
print(colored("Invalid index.", "red"))
continue
pm.show_entry_details_by_index(int(idx_input))
except Exception as e: # pragma: no cover - defensive
logging.error(f"Failed to list entries: {e}", exc_info=True)
print(colored(f"Error: Failed to list entries: {e}", "red"))
def handle_display_totp_codes(self) -> None:
"""Display all stored TOTP codes with a countdown progress bar."""
pm = self.manager
try:
fp, parent_fp, child_fp = pm.header_fingerprint_args
clear_header_with_notification(
pm,
fp,
"Main Menu > 2FA Codes",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
data = pm.entry_manager.vault.load_index()
entries = data.get("entries", {})
totp_list: list[tuple[str, int, int, bool]] = []
for idx_str, entry in entries.items():
if pm._entry_type_str(entry) == EntryType.TOTP.value and not entry.get(
"archived", entry.get("blacklisted", False)
):
label = entry.get("label", "")
period = int(entry.get("period", 30))
imported = "secret" in entry
totp_list.append((label, int(idx_str), period, imported))
if not totp_list:
pm.notify("No 2FA entries found.", level="WARNING")
return
totp_list.sort(key=lambda t: t[0].lower())
print(colored("Press Enter to return to the menu.", "cyan"))
while True:
fp, parent_fp, child_fp = pm.header_fingerprint_args
clear_header_with_notification(
pm,
fp,
"Main Menu > 2FA Codes",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
print(colored("Press Enter to return to the menu.", "cyan"))
generated = [t for t in totp_list if not t[3]]
imported_list = [t for t in totp_list if t[3]]
if generated:
print(colored("\nGenerated 2FA Codes:", "green"))
for label, idx, period, _ in generated:
key = getattr(pm, "KEY_TOTP_DET", None) or getattr(
pm, "parent_seed", None
)
code = pm.entry_manager.get_totp_code(idx, key)
remaining = pm.entry_manager.get_totp_time_remaining(idx)
filled = int(20 * (period - remaining) / period)
bar = "[" + "#" * filled + "-" * (20 - filled) + "]"
if pm.secret_mode_enabled:
if manager_module.copy_to_clipboard(
code, pm.clipboard_clear_delay
):
print(
f"[{idx}] {label}: [HIDDEN] {bar} {remaining:2d}s - copied to clipboard"
)
else:
print(
f"[{idx}] {label}: {color_text(code, 'deterministic')} {bar} {remaining:2d}s"
)
if imported_list:
print(colored("\nImported 2FA Codes:", "green"))
for label, idx, period, _ in imported_list:
key = getattr(pm, "KEY_TOTP_DET", None) or getattr(
pm, "parent_seed", None
)
code = pm.entry_manager.get_totp_code(idx, key)
remaining = pm.entry_manager.get_totp_time_remaining(idx)
filled = int(20 * (period - remaining) / period)
bar = "[" + "#" * filled + "-" * (20 - filled) + "]"
if pm.secret_mode_enabled:
if manager_module.copy_to_clipboard(
code, pm.clipboard_clear_delay
):
print(
f"[{idx}] {label}: [HIDDEN] {bar} {remaining:2d}s - copied to clipboard"
)
else:
print(
f"[{idx}] {label}: {color_text(code, 'imported')} {bar} {remaining:2d}s"
)
sys.stdout.flush()
try:
user_input = manager_module.timed_input("", 1)
if user_input.strip() == "" or user_input.strip().lower() == "b":
break
except TimeoutError:
pass
except KeyboardInterrupt:
print()
break
except Exception as e: # pragma: no cover - defensive
logging.error(f"Error displaying TOTP codes: {e}", exc_info=True)
print(colored(f"Error: Failed to display TOTP codes: {e}", "red"))

View File

@@ -1,4 +1,4 @@
# password_manager/password_generation.py
# seedpass.core/password_generation.py
"""
Password Generation Module
@@ -11,14 +11,18 @@ Ensure that all dependencies are installed and properly configured in your envir
Never ever ever use Random Salt. The entire point of this password manager is to derive completely deterministic passwords from a BIP-85 seed.
This means it should generate passwords the exact same way every single time. Salts would break this functionality and is not appropriate for this software's use case.
To keep behaviour stable across Python versions, the shuffling logic uses an
HMAC-SHA256-based FisherYates shuffle instead of ``random.Random``. The HMAC
is keyed with the derived password bytes, providing deterministic yet
cryptographically strong pseudo-randomness without relying on Python's
non-stable random implementation.
"""
import os
import logging
import hashlib
import string
import random
import traceback
import hmac
import base64
from typing import Optional
from dataclasses import dataclass
@@ -42,8 +46,13 @@ except ModuleNotFoundError: # pragma: no cover - fallback for removed module
from local_bip85.bip85 import BIP85
from constants import DEFAULT_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH
from password_manager.encryption import EncryptionManager
from constants import (
DEFAULT_PASSWORD_LENGTH,
MIN_PASSWORD_LENGTH,
MAX_PASSWORD_LENGTH,
SAFE_SPECIAL_CHARS,
)
from .encryption import EncryptionManager
# Instantiate the logger
logger = logging.getLogger(__name__)
@@ -51,12 +60,27 @@ logger = logging.getLogger(__name__)
@dataclass
class PasswordPolicy:
"""Minimum complexity requirements for generated passwords."""
"""Minimum complexity requirements for generated passwords.
Attributes:
min_uppercase: Minimum required uppercase letters.
min_lowercase: Minimum required lowercase letters.
min_digits: Minimum required digits.
min_special: Minimum required special characters.
include_special_chars: Whether to include any special characters.
allowed_special_chars: Explicit set of allowed special characters.
special_mode: Preset mode for special characters (e.g. "safe").
exclude_ambiguous: Exclude easily confused characters like ``O`` and ``0``.
"""
min_uppercase: int = 2
min_lowercase: int = 2
min_digits: int = 2
min_special: int = 2
include_special_chars: bool = True
allowed_special_chars: str | None = None
special_mode: str | None = None
exclude_ambiguous: bool = False
class PasswordGenerator:
@@ -89,10 +113,12 @@ class PasswordGenerator:
self.bip85 = bip85
self.policy = policy or PasswordPolicy()
# Derive seed bytes from parent_seed using BIP39 (handled by EncryptionManager)
self.seed_bytes = self.encryption_manager.derive_seed_from_mnemonic(
self.parent_seed
)
if isinstance(parent_seed, (bytes, bytearray)):
self.seed_bytes = bytes(parent_seed)
else:
self.seed_bytes = self.encryption_manager.derive_seed_from_mnemonic(
self.parent_seed
)
logger.debug("PasswordGenerator initialized successfully.")
except Exception as e:
@@ -102,8 +128,8 @@ class PasswordGenerator:
def _derive_password_entropy(self, index: int) -> bytes:
"""Derive deterministic entropy for password generation."""
entropy = self.bip85.derive_entropy(index=index, bytes_len=64, app_no=32)
logger.debug(f"Derived entropy: {entropy.hex()}")
entropy = self.bip85.derive_entropy(index=index, entropy_bytes=64, app_no=32)
logger.debug("Entropy derived for password generation.")
hkdf = HKDF(
algorithm=hashes.SHA256(),
@@ -113,26 +139,43 @@ class PasswordGenerator:
backend=default_backend(),
)
hkdf_derived = hkdf.derive(entropy)
logger.debug(f"Derived key using HKDF: {hkdf_derived.hex()}")
logger.debug("Derived key using HKDF.")
dk = hashlib.pbkdf2_hmac("sha256", entropy, b"", 100000)
logger.debug(f"Derived key using PBKDF2: {dk.hex()}")
logger.debug("Derived key using PBKDF2.")
return dk
def _map_entropy_to_chars(self, dk: bytes, alphabet: str) -> str:
"""Map derived bytes to characters from the provided alphabet."""
password = "".join(alphabet[byte % len(alphabet)] for byte in dk)
logger.debug(f"Password after mapping to all allowed characters: {password}")
logger.debug("Mapped entropy to allowed characters.")
return password
def _fisher_yates_hmac(self, items: list[str], key: bytes) -> list[str]:
"""Shuffle ``items`` in a deterministic yet cryptographically sound manner.
A FisherYates shuffle is driven by an HMAC-SHA256 based
pseudo-random number generator seeded with ``key``. Unlike
:class:`random.Random`, this approach is stable across Python
versions while still deriving all of its entropy from ``key``.
"""
counter = 0
for i in range(len(items) - 1, 0, -1):
msg = counter.to_bytes(4, "big")
digest = hmac.new(key, msg, hashlib.sha256).digest()
j = int.from_bytes(digest, "big") % (i + 1)
items[i], items[j] = items[j], items[i]
counter += 1
return items
def _shuffle_deterministically(self, password: str, dk: bytes) -> str:
"""Deterministically shuffle characters using derived bytes."""
shuffle_seed = int.from_bytes(dk, "big")
rng = random.Random(shuffle_seed)
"""Deterministically shuffle characters using an HMAC-based PRNG."""
password_chars = list(password)
rng.shuffle(password_chars)
shuffled = "".join(password_chars)
logger.debug("Shuffled password deterministically.")
shuffled_chars = self._fisher_yates_hmac(password_chars, dk)
shuffled = "".join(shuffled_chars)
logger.debug("Shuffled password deterministically using HMAC-Fisher-Yates.")
return shuffled
def generate_password(
@@ -175,9 +218,28 @@ class PasswordGenerator:
dk = self._derive_password_entropy(index=index)
all_allowed = string.ascii_letters + string.digits + string.punctuation
letters = string.ascii_letters
digits = string.digits
if self.policy.exclude_ambiguous:
ambiguous = "O0Il1"
letters = "".join(c for c in letters if c not in ambiguous)
digits = "".join(c for c in digits if c not in ambiguous)
if not self.policy.include_special_chars:
allowed_special = ""
elif self.policy.allowed_special_chars is not None:
allowed_special = self.policy.allowed_special_chars
elif self.policy.special_mode == "safe":
allowed_special = SAFE_SPECIAL_CHARS
else:
allowed_special = string.punctuation
all_allowed = letters + digits + allowed_special
password = self._map_entropy_to_chars(dk, all_allowed)
password = self._enforce_complexity(password, all_allowed, dk)
password = self._enforce_complexity(
password, all_allowed, allowed_special, dk
)
password = self._shuffle_deterministically(password, dk)
# Ensure password length by extending if necessary
@@ -187,7 +249,7 @@ class PasswordGenerator:
extra = self._map_entropy_to_chars(dk, all_allowed)
password += extra
password = self._shuffle_deterministically(password, dk)
logger.debug(f"Extended password: {password}")
logger.debug("Extended password to meet length requirement.")
# Trim the password to the desired length and enforce complexity on
# the final result. Complexity enforcement is repeated here because
@@ -195,10 +257,12 @@ class PasswordGenerator:
# produced above when the requested length is shorter than the
# initial entropy size.
password = password[:length]
password = self._enforce_complexity(password, all_allowed, dk)
password = self._enforce_complexity(
password, all_allowed, allowed_special, dk
)
password = self._shuffle_deterministically(password, dk)
logger.debug(
f"Final password (trimmed to {length} chars with complexity enforced): {password}"
f"Generated final password of length {length} with complexity enforced."
)
return password
@@ -208,7 +272,9 @@ class PasswordGenerator:
print(colored(f"Error: Failed to generate password: {e}", "red"))
raise
def _enforce_complexity(self, password: str, alphabet: str, dk: bytes) -> str:
def _enforce_complexity(
self, password: str, alphabet: str, allowed_special: str, dk: bytes
) -> str:
"""
Ensures that the password contains at least two uppercase letters, two lowercase letters,
two digits, and two special characters, modifying it deterministically if necessary.
@@ -226,7 +292,13 @@ class PasswordGenerator:
uppercase = string.ascii_uppercase
lowercase = string.ascii_lowercase
digits = string.digits
special = string.punctuation
special = allowed_special
if self.policy.exclude_ambiguous:
ambiguous = "O0Il1"
uppercase = "".join(c for c in uppercase if c not in ambiguous)
lowercase = "".join(c for c in lowercase if c not in ambiguous)
digits = "".join(c for c in digits if c not in ambiguous)
password_chars = list(password)
@@ -244,7 +316,7 @@ class PasswordGenerator:
min_upper = self.policy.min_uppercase
min_lower = self.policy.min_lowercase
min_digits = self.policy.min_digits
min_special = self.policy.min_special
min_special = self.policy.min_special if special else 0
# Initialize derived key index
dk_index = 0
@@ -262,53 +334,51 @@ class PasswordGenerator:
index = get_dk_value() % len(password_chars)
char = uppercase[get_dk_value() % len(uppercase)]
password_chars[index] = char
logger.debug(
f"Added uppercase letter '{char}' at position {index}."
)
logger.debug(f"Added uppercase letter at position {index}.")
if current_lower < min_lower:
for _ in range(min_lower - current_lower):
index = get_dk_value() % len(password_chars)
char = lowercase[get_dk_value() % len(lowercase)]
password_chars[index] = char
logger.debug(
f"Added lowercase letter '{char}' at position {index}."
)
logger.debug(f"Added lowercase letter at position {index}.")
if current_digits < min_digits:
for _ in range(min_digits - current_digits):
index = get_dk_value() % len(password_chars)
char = digits[get_dk_value() % len(digits)]
password_chars[index] = char
logger.debug(f"Added digit '{char}' at position {index}.")
logger.debug(f"Added digit at position {index}.")
if current_special < min_special:
if special and current_special < min_special:
for _ in range(min_special - current_special):
index = get_dk_value() % len(password_chars)
char = special[get_dk_value() % len(special)]
password_chars[index] = char
logger.debug(
f"Added special character '{char}' at position {index}."
)
logger.debug(f"Added special character at position {index}.")
# Additional deterministic inclusion of symbols to increase score
symbol_target = 3 # Increase target number of symbols
current_symbols = sum(1 for c in password_chars if c in special)
additional_symbols_needed = max(symbol_target - current_symbols, 0)
if special:
symbol_target = 3 # Increase target number of symbols
current_symbols = sum(1 for c in password_chars if c in special)
additional_symbols_needed = max(symbol_target - current_symbols, 0)
for _ in range(additional_symbols_needed):
if dk_index >= dk_length:
break # Avoid exceeding the derived key length
index = get_dk_value() % len(password_chars)
char = special[get_dk_value() % len(special)]
password_chars[index] = char
logger.debug(f"Added additional symbol '{char}' at position {index}.")
for _ in range(additional_symbols_needed):
if dk_index >= dk_length:
break # Avoid exceeding the derived key length
index = get_dk_value() % len(password_chars)
char = special[get_dk_value() % len(special)]
password_chars[index] = char
logger.debug(f"Added additional symbol at position {index}.")
# Ensure balanced distribution by assigning different character types to specific segments
# Example: Divide password into segments and assign different types
segment_length = len(password_chars) // 4
char_types = [uppercase, lowercase, digits]
if special:
char_types.append(special)
segment_length = len(password_chars) // len(char_types)
if segment_length > 0:
for i, char_type in enumerate([uppercase, lowercase, digits, special]):
for i, char_type in enumerate(char_types):
segment_start = i * segment_length
segment_end = segment_start + segment_length
if segment_end > len(password_chars):
@@ -317,33 +387,34 @@ class PasswordGenerator:
if i == 0 and password_chars[j] not in uppercase:
char = uppercase[get_dk_value() % len(uppercase)]
password_chars[j] = char
logger.debug(
f"Assigned uppercase letter '{char}' to position {j}."
)
logger.debug(f"Assigned uppercase letter to position {j}.")
elif i == 1 and password_chars[j] not in lowercase:
char = lowercase[get_dk_value() % len(lowercase)]
password_chars[j] = char
logger.debug(
f"Assigned lowercase letter '{char}' to position {j}."
)
logger.debug(f"Assigned lowercase letter to position {j}.")
elif i == 2 and password_chars[j] not in digits:
char = digits[get_dk_value() % len(digits)]
password_chars[j] = char
logger.debug(f"Assigned digit '{char}' to position {j}.")
elif i == 3 and password_chars[j] not in special:
logger.debug(f"Assigned digit to position {j}.")
elif (
special
and i == len(char_types) - 1
and password_chars[j] not in special
):
char = special[get_dk_value() % len(special)]
password_chars[j] = char
logger.debug(
f"Assigned special character '{char}' to position {j}."
)
logger.debug(f"Assigned special character to position {j}.")
# Shuffle again to distribute the characters more evenly
shuffle_seed = (
int.from_bytes(dk, "big") + dk_index
) # Modify seed to vary shuffle
rng = random.Random(shuffle_seed)
rng.shuffle(password_chars)
logger.debug(f"Shuffled password characters for balanced distribution.")
# Shuffle again to distribute the characters more evenly. The key is
# tweaked with the current ``dk_index`` so that each call produces a
# unique but deterministic ordering.
shuffle_key = hmac.new(
dk, dk_index.to_bytes(4, "big"), hashlib.sha256
).digest()
password_chars = self._fisher_yates_hmac(password_chars, shuffle_key)
logger.debug(
"Shuffled password characters for balanced distribution using HMAC-Fisher-Yates."
)
# Final counts after modifications
final_upper = sum(1 for c in password_chars if c in uppercase)
@@ -364,7 +435,7 @@ class PasswordGenerator:
def derive_ssh_key(bip85: BIP85, idx: int) -> bytes:
"""Derive 32 bytes of entropy suitable for an SSH key."""
return bip85.derive_entropy(index=idx, bytes_len=32, app_no=32)
return bip85.derive_entropy(index=idx, entropy_bytes=32, app_no=32)
def derive_ssh_key_pair(parent_seed: str, index: int) -> tuple[str, str]:
@@ -398,7 +469,13 @@ def derive_seed_phrase(bip85: BIP85, idx: int, words: int = 24) -> str:
def derive_pgp_key(
bip85: BIP85, idx: int, key_type: str = "ed25519", user_id: str = ""
) -> tuple[str, str]:
"""Derive a deterministic PGP private key and return it with its fingerprint."""
"""Derive a deterministic PGP private key and return it with its fingerprint.
For RSA keys the randomness required during key generation is provided by
an HMAC-SHA256 based deterministic generator seeded from the BIP-85
entropy. This avoids use of Python's ``random`` module while ensuring the
output remains stable across Python versions.
"""
from pgpy import PGPKey, PGPUID
from pgpy.packet.packets import PrivKeyV4
@@ -424,20 +501,24 @@ def derive_pgp_key(
import hashlib
import datetime
entropy = bip85.derive_entropy(index=idx, bytes_len=32, app_no=32)
entropy = bip85.derive_entropy(index=idx, entropy_bytes=32, app_no=32)
created = datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc)
if key_type.lower() == "rsa":
class DRNG:
"""HMAC-SHA256 based deterministic random generator."""
def __init__(self, seed: bytes) -> None:
self.seed = seed
self.key = seed
self.counter = 0
def __call__(self, n: int) -> bytes: # pragma: no cover - deterministic
out = b""
while len(out) < n:
self.seed = hashlib.sha256(self.seed).digest()
out += self.seed
msg = self.counter.to_bytes(4, "big")
out += hmac.new(self.key, msg, hashlib.sha256).digest()
self.counter += 1
return out[:n]
rsa_key = RSA.generate(2048, randfunc=DRNG(entropy))

View File

@@ -12,15 +12,16 @@ import asyncio
from enum import Enum
from pathlib import Path
from password_manager.vault import Vault
from password_manager.backup import BackupManager
from .vault import Vault
from .backup import BackupManager
from nostr.client import NostrClient
from utils.key_derivation import (
derive_index_key,
EncryptionMode,
)
from password_manager.encryption import EncryptionManager
from .encryption import EncryptionManager
from utils.checksum import json_checksum, canonical_json_dumps
from .state_manager import StateManager
logger = logging.getLogger(__name__)
@@ -32,6 +33,7 @@ class PortableMode(Enum):
"""Encryption mode for portable exports."""
SEED_ONLY = EncryptionMode.SEED_ONLY.value
NONE = "none"
def _derive_export_key(seed: str) -> bytes:
@@ -47,8 +49,15 @@ def export_backup(
*,
publish: bool = False,
parent_seed: str | None = None,
encrypt: bool = True,
) -> Path:
"""Export the current vault state to a portable encrypted file."""
"""Export the current vault state to a portable file.
When ``encrypt`` is ``True`` (the default) the payload is encrypted with a
key derived from the parent seed. When ``encrypt`` is ``False`` the payload
is written in plaintext and the wrapper records an ``encryption_mode`` of
:data:`PortableMode.NONE`.
"""
if dest_path is None:
ts = int(time.time())
@@ -57,24 +66,32 @@ def export_backup(
dest_path = dest_dir / EXPORT_NAME_TEMPLATE.format(ts=ts)
index_data = vault.load_index()
seed = (
parent_seed
if parent_seed is not None
else vault.encryption_manager.decrypt_parent_seed()
)
key = _derive_export_key(seed)
enc_mgr = EncryptionManager(key, vault.fingerprint_dir)
canonical = canonical_json_dumps(index_data)
payload_bytes = enc_mgr.encrypt_data(canonical.encode("utf-8"))
if encrypt:
seed = (
parent_seed
if parent_seed is not None
else vault.encryption_manager.decrypt_parent_seed()
)
key = _derive_export_key(seed)
enc_mgr = EncryptionManager(key, vault.fingerprint_dir)
payload_bytes = enc_mgr.encrypt_data(canonical.encode("utf-8"))
mode = PortableMode.SEED_ONLY
cipher = "aes-gcm"
else:
payload_bytes = canonical.encode("utf-8")
mode = PortableMode.NONE
cipher = "none"
checksum = json_checksum(index_data)
wrapper = {
"format_version": FORMAT_VERSION,
"created_at": int(time.time()),
"fingerprint": vault.fingerprint_dir.name,
"encryption_mode": PortableMode.SEED_ONLY.value,
"cipher": "aes-gcm",
"encryption_mode": mode.value,
"cipher": cipher,
"checksum": checksum,
"payload": base64.b64encode(payload_bytes).decode("utf-8"),
}
@@ -90,10 +107,12 @@ def export_backup(
enc_file.write_bytes(encrypted)
os.chmod(enc_file, 0o600)
try:
idx = StateManager(vault.fingerprint_dir).state.get("nostr_account_idx", 0)
client = NostrClient(
vault.encryption_manager,
vault.fingerprint_dir.name,
config_manager=backup_manager.config_manager,
account_index=idx,
)
asyncio.run(client.publish_snapshot(encrypted))
except Exception:
@@ -112,24 +131,30 @@ def import_backup(
raw = Path(path).read_bytes()
if path.suffix.endswith(".enc"):
raw = vault.encryption_manager.decrypt_data(raw)
raw = vault.encryption_manager.decrypt_data(raw, context=str(path))
wrapper = json.loads(raw.decode("utf-8"))
if wrapper.get("format_version") != FORMAT_VERSION:
raise ValueError("Unsupported backup format")
if wrapper.get("encryption_mode") != PortableMode.SEED_ONLY.value:
raise ValueError("Unsupported encryption mode")
mode = wrapper.get("encryption_mode")
payload = base64.b64decode(wrapper["payload"])
seed = (
parent_seed
if parent_seed is not None
else vault.encryption_manager.decrypt_parent_seed()
)
key = _derive_export_key(seed)
enc_mgr = EncryptionManager(key, vault.fingerprint_dir)
index_bytes = enc_mgr.decrypt_data(payload)
if mode == PortableMode.SEED_ONLY.value:
seed = (
parent_seed
if parent_seed is not None
else vault.encryption_manager.decrypt_parent_seed()
)
key = _derive_export_key(seed)
enc_mgr = EncryptionManager(key, vault.fingerprint_dir)
enc_mgr._legacy_migrate_flag = False
index_bytes = enc_mgr.decrypt_data(payload, context="backup payload")
elif mode == PortableMode.NONE.value:
index_bytes = payload
else:
raise ValueError("Unsupported encryption mode")
index = json.loads(index_bytes.decode("utf-8"))
checksum = json_checksum(index)

View File

@@ -0,0 +1,109 @@
from __future__ import annotations
import logging
from typing import Optional, TYPE_CHECKING
from termcolor import colored
import seedpass.core.manager as manager_module
from utils.password_prompt import prompt_existing_password
if TYPE_CHECKING: # pragma: no cover - typing only
from .manager import PasswordManager
from nostr.client import NostrClient
class ProfileService:
"""Profile-related operations for :class:`PasswordManager`."""
def __init__(self, manager: PasswordManager) -> None:
self.manager = manager
def handle_switch_fingerprint(self, *, password: Optional[str] = None) -> bool:
"""Handle switching to a different seed profile."""
pm = self.manager
try:
print(colored("\nAvailable Seed Profiles:", "cyan"))
fingerprints = pm.fingerprint_manager.list_fingerprints()
for idx, fp in enumerate(fingerprints, start=1):
display = (
pm.fingerprint_manager.display_name(fp)
if hasattr(pm.fingerprint_manager, "display_name")
else fp
)
print(colored(f"{idx}. {display}", "cyan"))
choice = input("Select a seed profile by number to switch: ").strip()
if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)):
print(colored("Invalid selection. Returning to main menu.", "red"))
return False
selected_fingerprint = fingerprints[int(choice) - 1]
pm.fingerprint_manager.current_fingerprint = selected_fingerprint
pm.current_fingerprint = selected_fingerprint
if not getattr(pm, "manifest_id", None):
pm.manifest_id = None
pm.fingerprint_dir = pm.fingerprint_manager.get_current_fingerprint_dir()
if not pm.fingerprint_dir:
print(
colored(
f"Error: Seed profile directory for {selected_fingerprint} not found.",
"red",
)
)
return False
if password is None:
password = prompt_existing_password(
"Enter the master password for the selected seed profile: "
)
if not pm.setup_encryption_manager(
pm.fingerprint_dir, password, exit_on_fail=False
):
return False
pm.initialize_bip85()
pm.initialize_managers()
pm.start_background_sync()
print(colored(f"Switched to seed profile {selected_fingerprint}.", "green"))
try:
pm.nostr_client = manager_module.NostrClient(
encryption_manager=pm.encryption_manager,
fingerprint=pm.current_fingerprint,
config_manager=getattr(pm, "config_manager", None),
parent_seed=getattr(pm, "parent_seed", None),
key_index=pm.KEY_INDEX,
account_index=pm.nostr_account_idx,
)
if getattr(pm, "manifest_id", None) and hasattr(
pm.nostr_client, "_state_lock"
):
from nostr.backup_models import Manifest
with pm.nostr_client._state_lock:
pm.nostr_client.current_manifest_id = pm.manifest_id
pm.nostr_client.current_manifest = Manifest(
ver=1,
algo="gzip",
chunks=[],
delta_since=pm.delta_since or None,
)
logging.info(
f"NostrClient re-initialized with seed profile {pm.current_fingerprint}."
)
except Exception as e:
logging.error(f"Failed to re-initialize NostrClient: {e}")
print(
colored(f"Error: Failed to re-initialize NostrClient: {e}", "red")
)
return False
return True
except Exception as e: # pragma: no cover - defensive
logging.error(f"Error during seed profile switching: {e}", exc_info=True)
print(colored(f"Error: Failed to switch seed profiles: {e}", "red"))
return False

View File

@@ -0,0 +1,27 @@
from collections import defaultdict
from typing import Callable, Dict, List, Any
class PubSub:
"""Simple in-process event bus using the observer pattern."""
def __init__(self) -> None:
self._subscribers: Dict[str, List[Callable[..., None]]] = defaultdict(list)
def subscribe(self, event: str, callback: Callable[..., None]) -> None:
"""Register ``callback`` to be invoked when ``event`` is published."""
self._subscribers[event].append(callback)
def unsubscribe(self, event: str, callback: Callable[..., None]) -> None:
"""Unregister ``callback`` from ``event`` notifications."""
if callback in self._subscribers.get(event, []):
self._subscribers[event].remove(callback)
def publish(self, event: str, *args: Any, **kwargs: Any) -> None:
"""Notify all subscribers of ``event`` passing ``*args`` and ``**kwargs``."""
for callback in list(self._subscribers.get(event, [])):
callback(*args, **kwargs)
# Global bus instance for convenience
bus = PubSub()

View File

@@ -0,0 +1,94 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import List
from utils.file_lock import exclusive_lock, shared_lock
from nostr.client import DEFAULT_RELAYS
class StateManager:
"""Persist simple state values per profile."""
STATE_FILENAME = "seedpass_state.json"
def __init__(self, fingerprint_dir: Path) -> None:
self.fingerprint_dir = Path(fingerprint_dir)
self.state_path = self.fingerprint_dir / self.STATE_FILENAME
def _load(self) -> dict:
if not self.state_path.exists():
return {
"last_bip85_idx": 0,
"last_sync_ts": 0,
"manifest_id": None,
"delta_since": 0,
"relays": list(DEFAULT_RELAYS),
"nostr_account_idx": 0,
}
with shared_lock(self.state_path) as fh:
fh.seek(0)
data = fh.read()
if not data:
return {
"last_bip85_idx": 0,
"last_sync_ts": 0,
"manifest_id": None,
"delta_since": 0,
"relays": list(DEFAULT_RELAYS),
"nostr_account_idx": 0,
}
try:
obj = json.loads(data.decode())
except Exception:
obj = {}
obj.setdefault("last_bip85_idx", 0)
obj.setdefault("last_sync_ts", 0)
obj.setdefault("manifest_id", None)
obj.setdefault("delta_since", 0)
obj.setdefault("relays", list(DEFAULT_RELAYS))
obj.setdefault("nostr_account_idx", 0)
return obj
def _save(self, data: dict) -> None:
with exclusive_lock(self.state_path) as fh:
fh.seek(0)
fh.truncate()
fh.write(json.dumps(data, separators=(",", ":")).encode())
fh.flush()
os.fsync(fh.fileno())
@property
def state(self) -> dict:
return self._load()
def update_state(self, **kwargs) -> None:
data = self._load()
data.update(kwargs)
self._save(data)
# Relay helpers
def list_relays(self) -> List[str]:
return self._load().get("relays", [])
def add_relay(self, url: str) -> None:
data = self._load()
relays = data.get("relays", [])
if url in relays:
raise ValueError("Relay already present")
relays.append(url)
data["relays"] = relays
self._save(data)
def remove_relay(self, idx: int) -> None:
data = self._load()
relays = data.get("relays", [])
if not 1 <= idx <= len(relays):
raise ValueError("Invalid index")
if len(relays) == 1:
raise ValueError("At least one relay required")
relays.pop(idx - 1)
data["relays"] = relays
self._save(data)

View File

@@ -0,0 +1,20 @@
"""Manage display of stats screens."""
from __future__ import annotations
class StatsManager:
"""Track whether stats have been displayed."""
def __init__(self) -> None:
self._displayed = False
def display_stats_once(self, manager) -> None:
"""Display stats using ``manager`` once per reset."""
if not self._displayed:
manager.display_stats()
self._displayed = True
def reset(self) -> None:
"""Reset the displayed flag."""
self._displayed = False

View File

@@ -2,8 +2,11 @@
from __future__ import annotations
import os
import sys
import time
import base64
from typing import Union
from urllib.parse import quote
from urllib.parse import urlparse, parse_qs, unquote
@@ -14,17 +17,24 @@ import pyotp
from utils import key_derivation
def random_totp_secret(length: int = 20) -> str:
"""Return a random Base32 encoded TOTP secret."""
return base64.b32encode(os.urandom(length)).decode("ascii").rstrip("=")
class TotpManager:
"""Helper methods for TOTP secrets and codes."""
@staticmethod
def derive_secret(seed: str, index: int) -> str:
"""Derive a TOTP secret from a BIP39 seed and index."""
def derive_secret(seed: Union[str, bytes], index: int) -> str:
"""Derive a TOTP secret from a seed or raw key and index."""
return key_derivation.derive_totp_secret(seed, index)
@classmethod
def current_code(cls, seed: str, index: int, timestamp: int | None = None) -> str:
"""Return the TOTP code for the given seed and index."""
def current_code(
cls, seed: Union[str, bytes], index: int, timestamp: int | None = None
) -> str:
"""Return the TOTP code for the given seed/key and index."""
secret = cls.derive_secret(seed, index)
totp = pyotp.TOTP(secret)
if timestamp is None:

283
src/seedpass/core/vault.py Normal file
View File

@@ -0,0 +1,283 @@
"""Vault utilities for reading and writing encrypted files."""
from pathlib import Path
from typing import Optional, Union
from os import PathLike
import shutil
from termcolor import colored
from cryptography.fernet import InvalidToken
from .encryption import (
EncryptionManager,
LegacyFormatRequiresMigrationError,
USE_ORJSON,
json_lib,
)
from utils.key_derivation import KdfConfig, CURRENT_KDF_VERSION
from utils.password_prompt import prompt_existing_password
class Vault:
"""Simple wrapper around :class:`EncryptionManager` for vault storage."""
INDEX_FILENAME = "seedpass_entries_db.json.enc"
CONFIG_FILENAME = "seedpass_config.json.enc"
def __init__(
self,
encryption_manager: EncryptionManager,
fingerprint_dir: Union[str, PathLike[str], Path],
):
self.encryption_manager = encryption_manager
self.fingerprint_dir = Path(fingerprint_dir)
self.index_file = self.fingerprint_dir / self.INDEX_FILENAME
self.config_file = self.fingerprint_dir / self.CONFIG_FILENAME
self.migrated_from_legacy = False
def set_encryption_manager(self, manager: EncryptionManager) -> None:
"""Replace the internal encryption manager."""
self.encryption_manager = manager
def _hkdf_kdf(self) -> KdfConfig:
return KdfConfig(
name="hkdf", version=CURRENT_KDF_VERSION, params={}, salt_b64=""
)
# ----- Password index helpers -----
def load_index(self, *, return_migration_flags: bool = False):
"""Return decrypted password index data, applying migrations.
If a legacy ``seedpass_passwords_db.json.enc`` file is detected, the
user is prompted to migrate it. A backup copy of the legacy file (and
its checksum) is saved under ``legacy_backups`` within the fingerprint
directory before renaming to the new filename.
When ``return_migration_flags`` is ``True`` the tuple
``(data, migrated, last_migration_performed)`` is returned where
``migrated`` indicates whether any migration occurred and
``last_migration_performed`` reflects whether the underlying
:class:`EncryptionManager` reported a conversion.
"""
legacy_file = self.fingerprint_dir / "seedpass_passwords_db.json.enc"
self.migrated_from_legacy = False
legacy_detected = False
backup_dir = None
if legacy_file.exists() and not self.index_file.exists():
print(colored("Legacy index detected.", "yellow"))
resp = (
input("Would you like to migrate this to the new index format? [y/N]: ")
.strip()
.lower()
)
if resp != "y":
raise RuntimeError("Migration declined by user")
legacy_checksum = (
self.fingerprint_dir / "seedpass_passwords_db_checksum.txt"
)
backup_dir = self.fingerprint_dir / "legacy_backups"
backup_dir.mkdir(exist_ok=True)
shutil.copy2(legacy_file, backup_dir / legacy_file.name)
if legacy_checksum.exists():
shutil.copy2(legacy_checksum, backup_dir / legacy_checksum.name)
legacy_file.rename(self.index_file)
if legacy_checksum.exists():
legacy_checksum.rename(
self.fingerprint_dir / "seedpass_entries_db_checksum.txt"
)
# Remove any leftover legacy files to avoid triggering migration again
for stray in self.fingerprint_dir.glob("seedpass_passwords_db*.enc"):
try:
stray.unlink()
except FileNotFoundError:
pass
stray_checksum = self.fingerprint_dir / "seedpass_passwords_db_checksum.txt"
if stray_checksum.exists():
stray_checksum.unlink()
legacy_detected = True
print(
colored(
"Migration complete. Original index backed up to 'legacy_backups'",
"green",
)
)
try:
data, kdf = self.encryption_manager.load_json_data(
self.index_file, return_kdf=True
)
migration_performed = getattr(
self.encryption_manager, "last_migration_performed", False
)
if kdf.version < CURRENT_KDF_VERSION:
new_kdf = KdfConfig(
name=kdf.name,
version=CURRENT_KDF_VERSION,
params=kdf.params,
salt_b64=kdf.salt_b64,
)
self.encryption_manager.save_json_data(
data, self.index_file, kdf=new_kdf
)
self.encryption_manager.update_checksum(self.index_file)
migration_performed = True
except LegacyFormatRequiresMigrationError:
print(
colored(
"Failed to decrypt index with current key. This may be a legacy index.",
"red",
)
)
resp = input(
"\nChoose an option:\n"
"1. Open legacy index without migrating\n"
"2. Migrate to new format.\n"
"Selection [1/2]: "
).strip()
if resp == "1":
self.encryption_manager._legacy_migrate_flag = False
self.encryption_manager.last_migration_performed = False
elif resp == "2":
self.encryption_manager._legacy_migrate_flag = True
self.encryption_manager.last_migration_performed = True
else:
raise InvalidToken(
"User declined legacy decryption or provided invalid choice."
)
password = prompt_existing_password(
"Enter your master password for legacy decryption: "
)
with self.index_file.open("rb") as fh:
encrypted_data = fh.read()
decrypted = self.encryption_manager.decrypt_legacy(
encrypted_data, password, context=str(self.index_file)
)
if USE_ORJSON:
data = json_lib.loads(decrypted)
else:
data = json_lib.loads(decrypted.decode("utf-8"))
if self.encryption_manager._legacy_migrate_flag:
self.encryption_manager.save_json_data(
data, self.index_file, kdf=self._hkdf_kdf()
)
self.encryption_manager.update_checksum(self.index_file)
migration_performed = getattr(
self.encryption_manager, "last_migration_performed", False
)
except Exception as exc: # noqa: BLE001 - surface clear error and restore
if legacy_detected and backup_dir is not None:
backup_file = backup_dir / legacy_file.name
legacy_checksum_path = (
self.fingerprint_dir / "seedpass_passwords_db_checksum.txt"
)
backup_checksum = backup_dir / legacy_checksum_path.name
try:
if self.index_file.exists():
self.index_file.unlink()
shutil.copy2(backup_file, legacy_file)
checksum_new = (
self.fingerprint_dir / "seedpass_entries_db_checksum.txt"
)
if checksum_new.exists():
checksum_new.unlink()
if backup_checksum.exists():
shutil.copy2(backup_checksum, legacy_checksum_path)
finally:
self.migrated_from_legacy = False
raise RuntimeError(f"Migration failed: {exc}") from exc
from .migrations import apply_migrations, LATEST_VERSION
version = data.get("schema_version", 0)
if version > LATEST_VERSION:
raise ValueError(
f"File schema version {version} is newer than supported {LATEST_VERSION}"
)
schema_migrated = version < LATEST_VERSION
try:
data = apply_migrations(data)
if schema_migrated:
self.encryption_manager.save_json_data(
data, self.index_file, kdf=self._hkdf_kdf()
)
self.encryption_manager.update_checksum(self.index_file)
except Exception as exc: # noqa: BLE001 - surface clear error and restore
if legacy_detected and backup_dir is not None:
backup_file = backup_dir / legacy_file.name
legacy_checksum_path = (
self.fingerprint_dir / "seedpass_passwords_db_checksum.txt"
)
backup_checksum = backup_dir / legacy_checksum_path.name
try:
if self.index_file.exists():
self.index_file.unlink()
shutil.copy2(backup_file, legacy_file)
checksum_new = (
self.fingerprint_dir / "seedpass_entries_db_checksum.txt"
)
if checksum_new.exists():
checksum_new.unlink()
if backup_checksum.exists():
shutil.copy2(backup_checksum, legacy_checksum_path)
finally:
self.migrated_from_legacy = False
raise RuntimeError(f"Migration failed: {exc}") from exc
self.migrated_from_legacy = (
legacy_detected or migration_performed or schema_migrated
)
if return_migration_flags:
return data, self.migrated_from_legacy, migration_performed
return data
def save_index(self, data: dict) -> None:
"""Encrypt and write password index."""
self.encryption_manager.save_json_data(
data, self.index_file, kdf=self._hkdf_kdf()
)
def get_encrypted_index(self) -> Optional[bytes]:
"""Return the encrypted index bytes if present."""
return self.encryption_manager.get_encrypted_index()
def decrypt_and_save_index_from_nostr(
self,
encrypted_data: bytes,
*,
strict: bool = True,
merge: bool = False,
return_migration_flag: bool = False,
):
"""Decrypt Nostr payload and update the local index.
Returns ``True``/``False`` for success by default. When
``return_migration_flag`` is ``True`` a tuple ``(success, migrated)`` is
returned, where ``migrated`` indicates whether any legacy migration
occurred.
"""
result = self.encryption_manager.decrypt_and_save_index_from_nostr(
encrypted_data, strict=strict, merge=merge
)
self.migrated_from_legacy = result and getattr(
self.encryption_manager, "last_migration_performed", False
)
if return_migration_flag:
return result, self.migrated_from_legacy
return result
# ----- Config helpers -----
def load_config(self) -> dict:
"""Load decrypted configuration."""
return self.encryption_manager.load_json_data(self.config_file)
def save_config(self, config: dict) -> None:
"""Encrypt and persist configuration."""
self.encryption_manager.save_json_data(
config, self.config_file, kdf=self._hkdf_kdf()
)

13
src/seedpass/errors.py Normal file
View File

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

View File

@@ -0,0 +1,11 @@
"""Graphical user interface for SeedPass."""
from .app import SeedPassApp, build
def main() -> None:
"""Launch the GUI application."""
build().main_loop()
__all__ = ["SeedPassApp", "main"]

View File

@@ -0,0 +1,4 @@
from . import main
if __name__ == "__main__":
main()

486
src/seedpass_gui/app.py Normal file
View File

@@ -0,0 +1,486 @@
from __future__ import annotations
import asyncio
import time
import toga
from toga.style import Pack
from toga.sources import ListSource
from toga.style.pack import COLUMN, ROW
from seedpass.core.entry_types import EntryType
from seedpass.core.manager import PasswordManager
from seedpass.core.totp import TotpManager
from seedpass.core.api import (
VaultService,
EntryService,
NostrService,
UnlockRequest,
)
from seedpass.core.pubsub import bus
class LockScreenWindow(toga.Window):
"""Window prompting for the master password."""
def __init__(
self,
controller: SeedPassApp,
vault: VaultService,
entries: EntryService,
) -> None:
super().__init__("Unlock Vault")
# Store a reference to the SeedPass application instance separately from
# the ``toga`` ``Window.app`` attribute to avoid conflicts.
self.controller = controller
self.vault = vault
self.entries = entries
self.password_input = toga.PasswordInput(style=Pack(flex=1))
self.message = toga.Label("", style=Pack(color="red"))
unlock_button = toga.Button(
"Unlock", on_press=self.handle_unlock, style=Pack(padding_top=10)
)
box = toga.Box(style=Pack(direction=COLUMN, padding=20))
box.add(toga.Label("Master Password:"))
box.add(self.password_input)
box.add(unlock_button)
box.add(self.message)
self.content = box
def handle_unlock(self, widget: toga.Widget) -> None:
password = self.password_input.value or ""
try:
self.vault.unlock(UnlockRequest(password=password))
except Exception as exc: # pragma: no cover - GUI error handling
self.message.text = str(exc)
return
main = MainWindow(
self.controller,
self.vault,
self.entries,
self.controller.nostr_service,
)
self.controller.main_window = main
main.show()
self.close()
class MainWindow(toga.Window):
"""Main application window showing vault entries."""
def __init__(
self,
controller: SeedPassApp,
vault: VaultService,
entries: EntryService,
nostr: NostrService,
) -> None:
super().__init__("SeedPass", on_close=self.cleanup)
# ``Window.app`` is reserved for the Toga ``App`` instance. Store the
# SeedPass application reference separately.
self.controller = controller
self.vault = vault
self.entries = entries
self.nostr = nostr
bus.subscribe("sync_started", self.sync_started)
bus.subscribe("sync_finished", self.sync_finished)
bus.subscribe("vault_locked", self.vault_locked)
self.last_sync = None
self.entry_source = ListSource(["id", "label", "kind", "info1", "info2"])
self.table = toga.Table(
headings=["ID", "Label", "Kind", "Info 1", "Info 2"],
data=self.entry_source,
style=Pack(flex=1),
)
add_button = toga.Button("Add", on_press=self.add_entry)
edit_button = toga.Button("Edit", on_press=self.edit_entry)
search_button = toga.Button("Search", on_press=self.search_entries)
relay_button = toga.Button("Relays", on_press=self.manage_relays)
totp_button = toga.Button("TOTP", on_press=self.show_totp_codes)
sync_button = toga.Button("Sync", on_press=self.start_vault_sync)
button_box = toga.Box(style=Pack(direction=ROW, padding_top=5))
button_box.add(add_button)
button_box.add(edit_button)
button_box.add(search_button)
button_box.add(relay_button)
button_box.add(totp_button)
button_box.add(sync_button)
self.status = toga.Label("Last sync: never", style=Pack(padding_top=5))
box = toga.Box(style=Pack(direction=COLUMN, padding=10))
box.add(self.table)
box.add(button_box)
box.add(self.status)
self.content = box
self.refresh_entries()
def refresh_entries(self) -> None:
self.entry_source.clear()
for idx, label, username, url, _arch in self.entries.list_entries():
entry = self.entries.retrieve_entry(idx)
kind = (entry or {}).get("kind", (entry or {}).get("type", ""))
info1 = ""
info2 = ""
if kind == EntryType.PASSWORD.value:
info1 = username or ""
info2 = url or ""
elif kind == EntryType.KEY_VALUE.value:
info1 = entry.get("value", "") if entry else ""
else:
info1 = str(entry.get("index", "")) if entry else ""
self.entry_source.append(
{
"id": idx,
"label": label,
"kind": kind,
"info1": info1,
"info2": info2,
}
)
# --- Button handlers -------------------------------------------------
def add_entry(self, widget: toga.Widget) -> None:
dlg = EntryDialog(self, None)
dlg.show()
def edit_entry(self, widget: toga.Widget) -> None:
if self.table.selection is None:
return
entry_id = int(self.table.selection[0])
dlg = EntryDialog(self, entry_id)
dlg.show()
def search_entries(self, widget: toga.Widget) -> None:
dlg = SearchDialog(self)
dlg.show()
def manage_relays(self, widget: toga.Widget) -> None:
dlg = RelayManagerDialog(self, self.nostr)
dlg.show()
def show_totp_codes(self, widget: toga.Widget) -> None:
win = TotpViewerWindow(self.controller, self.entries)
win.show()
def start_vault_sync(self, widget: toga.Widget | None = None) -> None:
"""Schedule a background vault synchronization."""
async def _runner() -> None:
self.nostr.start_background_vault_sync()
self.controller.loop.create_task(_runner())
# --- PubSub callbacks -------------------------------------------------
def sync_started(self, *args: object, **kwargs: object) -> None:
self.status.text = "Syncing..."
def sync_finished(self, *args: object, **kwargs: object) -> None:
self.last_sync = time.strftime("%H:%M:%S")
self.status.text = f"Last sync: {self.last_sync}"
def vault_locked(self, *args: object, **kwargs: object) -> None:
self.close()
self.controller.main_window = None
self.controller.lock_window.show()
def cleanup(self, *args: object, **kwargs: object) -> None:
bus.unsubscribe("sync_started", self.sync_started)
bus.unsubscribe("sync_finished", self.sync_finished)
bus.unsubscribe("vault_locked", self.vault_locked)
manager = getattr(self.nostr, "_manager", None)
if manager is not None:
manager.cleanup()
class EntryDialog(toga.Window):
"""Dialog for adding or editing an entry."""
def __init__(self, main: MainWindow, entry_id: int | None) -> None:
title = "Add Entry" if entry_id is None else "Edit Entry"
super().__init__(title)
self.main = main
self.entry_id = entry_id
self.label_input = toga.TextInput(style=Pack(flex=1))
self.kind_input = toga.Selection(
items=[e.value for e in EntryType],
style=Pack(flex=1),
)
self.kind_input.value = EntryType.PASSWORD.value
self.username_input = toga.TextInput(style=Pack(flex=1))
self.url_input = toga.TextInput(style=Pack(flex=1))
self.length_input = toga.NumberInput(
min=8, max=128, style=Pack(width=80), value=16
)
self.key_input = toga.TextInput(style=Pack(flex=1))
self.value_input = toga.TextInput(style=Pack(flex=1))
save_button = toga.Button(
"Save", on_press=self.save, style=Pack(padding_top=10)
)
box = toga.Box(style=Pack(direction=COLUMN, padding=20))
box.add(toga.Label("Label"))
box.add(self.label_input)
box.add(toga.Label("Kind"))
box.add(self.kind_input)
box.add(toga.Label("Username"))
box.add(self.username_input)
box.add(toga.Label("URL"))
box.add(self.url_input)
box.add(toga.Label("Length"))
box.add(self.length_input)
box.add(toga.Label("Key"))
box.add(self.key_input)
box.add(toga.Label("Value"))
box.add(self.value_input)
box.add(save_button)
self.content = box
if entry_id is not None:
entry = self.main.entries.retrieve_entry(entry_id)
if entry:
self.label_input.value = entry.get("label", "")
kind = entry.get("kind", entry.get("type", EntryType.PASSWORD.value))
self.kind_input.value = kind
self.kind_input.enabled = False
self.username_input.value = entry.get("username", "") or ""
self.url_input.value = entry.get("url", "") or ""
self.length_input.value = entry.get("length", 16)
self.key_input.value = entry.get("key", "")
self.value_input.value = entry.get("value", "")
def save(self, widget: toga.Widget) -> None:
label = self.label_input.value or ""
username = self.username_input.value or None
url = self.url_input.value or None
length = int(self.length_input.value or 16)
kind = self.kind_input.value
key = self.key_input.value or None
value = self.value_input.value or None
if self.entry_id is None:
if kind == EntryType.PASSWORD.value:
entry_id = self.main.entries.add_entry(
label, length, username=username, url=url
)
elif kind == EntryType.TOTP.value:
entry_id = self.main.entries.add_totp(label)
elif kind == EntryType.SSH.value:
entry_id = self.main.entries.add_ssh_key(label)
elif kind == EntryType.SEED.value:
entry_id = self.main.entries.add_seed(label)
elif kind == EntryType.PGP.value:
entry_id = self.main.entries.add_pgp_key(label)
elif kind == EntryType.NOSTR.value:
entry_id = self.main.entries.add_nostr_key(label)
elif kind == EntryType.KEY_VALUE.value:
entry_id = self.main.entries.add_key_value(
label, key or "", value or ""
)
elif kind == EntryType.MANAGED_ACCOUNT.value:
entry_id = self.main.entries.add_managed_account(label)
else:
entry_id = self.entry_id
kwargs = {"label": label}
if kind == EntryType.PASSWORD.value:
kwargs.update({"username": username, "url": url})
elif kind == EntryType.KEY_VALUE.value:
kwargs.update({"key": key, "value": value})
self.main.entries.modify_entry(entry_id, **kwargs)
entry = self.main.entries.retrieve_entry(entry_id) or {}
kind = entry.get("kind", entry.get("type", kind))
info1 = ""
info2 = ""
if kind == EntryType.PASSWORD.value:
info1 = username or ""
info2 = url or ""
elif kind == EntryType.KEY_VALUE.value:
info1 = entry.get("value", value or "")
else:
info1 = str(entry.get("index", ""))
row = {
"id": entry_id,
"label": label,
"kind": kind,
"info1": info1,
"info2": info2,
}
if self.entry_id is None:
self.main.entry_source.append(row)
else:
for existing in self.main.entry_source:
if getattr(existing, "id", None) == entry_id:
for key, value in row.items():
setattr(existing, key, value)
break
self.close()
# schedule vault sync after saving
getattr(self.main, "start_vault_sync", lambda *_: None)()
class SearchDialog(toga.Window):
"""Dialog for searching entries."""
def __init__(self, main: MainWindow) -> None:
super().__init__("Search Entries")
self.main = main
self.query_input = toga.TextInput(style=Pack(flex=1))
search_button = toga.Button(
"Search", on_press=self.do_search, style=Pack(padding_top=10)
)
box = toga.Box(style=Pack(direction=COLUMN, padding=20))
box.add(toga.Label("Query"))
box.add(self.query_input)
box.add(search_button)
self.content = box
def do_search(self, widget: toga.Widget) -> None:
query = self.query_input.value or ""
results = self.main.entries.search_entries(query)
self.main.entry_source.clear()
for idx, label, username, url, _arch, _etype in results:
self.main.entry_source.append(
{
"id": idx,
"label": label,
"kind": "",
"info1": username or "",
"info2": url or "",
}
)
self.close()
class TotpViewerWindow(toga.Window):
"""Window displaying active TOTP codes."""
def __init__(self, controller: SeedPassApp, entries: EntryService) -> None:
super().__init__("TOTP Codes", on_close=self.cleanup)
self.controller = controller
self.entries = entries
self.table = toga.Table(
headings=["Label", "Code", "Seconds"],
style=Pack(flex=1),
)
box = toga.Box(style=Pack(direction=COLUMN, padding=20))
box.add(self.table)
self.content = box
self._running = True
self.controller.loop.create_task(self._update_loop())
self.refresh_codes()
async def _update_loop(self) -> None:
while self._running:
self.refresh_codes()
await asyncio.sleep(1)
def refresh_codes(self) -> None:
self.table.data = []
for idx, label, *_rest in self.entries.list_entries(
filter_kinds=[EntryType.TOTP.value]
):
entry = self.entries.retrieve_entry(idx)
code = self.entries.get_totp_code(idx)
period = int(entry.get("period", 30)) if entry else 30
remaining = TotpManager.time_remaining(period)
self.table.data.append((label, code, remaining))
def cleanup(self, *args: object, **kwargs: object) -> None:
self._running = False
class RelayManagerDialog(toga.Window):
"""Dialog for managing relay URLs."""
def __init__(self, main: MainWindow, nostr: NostrService) -> None:
super().__init__("Relays")
self.main = main
self.nostr = nostr
self.table = toga.Table(headings=["Index", "URL"], style=Pack(flex=1))
self.new_input = toga.TextInput(style=Pack(flex=1))
add_btn = toga.Button("Add", on_press=self.add_relay)
remove_btn = toga.Button("Remove", on_press=self.remove_relay)
self.message = toga.Label("", style=Pack(color="red"))
box = toga.Box(style=Pack(direction=COLUMN, padding=20))
box.add(self.table)
form = toga.Box(style=Pack(direction=ROW, padding_top=5))
form.add(self.new_input)
form.add(add_btn)
form.add(remove_btn)
box.add(form)
box.add(self.message)
self.content = box
self.refresh()
def refresh(self) -> None:
self.table.data = []
for i, url in enumerate(self.nostr.list_relays(), start=1):
self.table.data.append((i, url))
def add_relay(self, widget: toga.Widget) -> None:
url = self.new_input.value or ""
if not url:
return
try:
self.nostr.add_relay(url)
except Exception as exc: # pragma: no cover - pass errors
self.message.text = str(exc)
return
self.new_input.value = ""
self.refresh()
def remove_relay(self, widget: toga.Widget, *, index: int | None = None) -> None:
if index is None:
if self.table.selection is None:
return
index = int(self.table.selection[0])
try:
self.nostr.remove_relay(index)
except Exception as exc: # pragma: no cover - pass errors
self.message.text = str(exc)
return
self.refresh()
def build() -> SeedPassApp:
"""Return a configured :class:`SeedPassApp` instance."""
return SeedPassApp(formal_name="SeedPass", app_id="org.seedpass.gui")
class SeedPassApp(toga.App):
def startup(self) -> None: # pragma: no cover - GUI bootstrap
pm = PasswordManager()
self.vault_service = VaultService(pm)
self.entry_service = EntryService(pm)
self.nostr_service = NostrService(pm)
self.lock_window = LockScreenWindow(
self,
self.vault_service,
self.entry_service,
)
self.main_window = None
self.lock_window.show()
def main() -> None: # pragma: no cover - GUI bootstrap
"""Run the BeeWare application."""
build().main_loop()

Some files were not shown because too many files have changed in this diff Show More