551 Commits

Author SHA1 Message Date
thePR0M3TH3AN
0d3d972abb Merge pull request #712 from PR0M3TH3AN/beta
Beta
2025-08-02 21:18:20 -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
0396e99e0f Merge pull request #681 from PR0M3TH3AN/beta
Beta
2025-07-26 20:26:10 -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
17e5d48fdf Merge pull request #670 from PR0M3TH3AN/revert-669-codex/add-dark-mode-ui-styling
Revert "Add simple dark mode styling"
2025-07-23 08:25:00 -04:00
thePR0M3TH3AN
8b180b8d9a Revert "Add simple dark mode styling" 2025-07-23 08:24:43 -04:00
thePR0M3TH3AN
08f496e1e6 Merge pull request #669 from PR0M3TH3AN/codex/add-dark-mode-ui-styling
Add simple dark mode styling
2025-07-22 22:34:50 -04:00
thePR0M3TH3AN
93587a7502 Add simple dark mode styling 2025-07-22 22:34:21 -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
f1c24fb2ca Merge pull request #610 from PR0M3TH3AN/beta
Beta
2025-07-17 16:06:01 -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
bda90cec03 Merge pull request #604 from PR0M3TH3AN/beta
Beta
2025-07-17 11:06:24 -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
78cd847c25 Merge pull request #602 from PR0M3TH3AN/beta
Beta
2025-07-17 10:38:45 -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
9e2d469743 Merge pull request #594 from PR0M3TH3AN/beta
Beta
2025-07-16 20:47:54 -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
7e0505a729 Merge pull request #591 from PR0M3TH3AN/beta
Beta
2025-07-16 19:28:14 -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
c17bb8f8d8 Merge pull request #586 from PR0M3TH3AN/beta
Beta
2025-07-16 14:32:09 -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
thePR0M3TH3AN
6926026377 Update README.md 2025-07-14 17:40:36 -04:00
thePR0M3TH3AN
cda56ce0ac Merge pull request #549 from PR0M3TH3AN/beta
Beta
2025-07-14 17:39:33 -04:00
thePR0M3TH3AN
56e3cece26 Merge pull request #548 from PR0M3TH3AN/codex/modify-test-helper-constructors-for-notifications
Update test notification mocks
2025-07-14 17:29:25 -04:00
thePR0M3TH3AN
16982f489d test: update helper PasswordManager mocks 2025-07-14 17:24:08 -04:00
thePR0M3TH3AN
59790543ab Merge pull request #547 from PR0M3TH3AN/codex/ensure-notifications-api-functionality
Add test for API notifications persistence
2025-07-14 17:13:24 -04:00
thePR0M3TH3AN
63d7d21991 test: ensure notifications endpoint leaves current message 2025-07-14 17:09:30 -04:00
thePR0M3TH3AN
ed3cdad21e Merge pull request #546 from PR0M3TH3AN/codex/modify-clear_header_with_notification-behavior
Fix menu notifications
2025-07-14 17:03:21 -04:00
thePR0M3TH3AN
74f5911bf7 Use notification helper consistently 2025-07-14 17:01:49 -04:00
thePR0M3TH3AN
28a86a3e59 Merge pull request #545 from PR0M3TH3AN/codex/new-task
Replace yellow prints with warning notifications
2025-07-14 16:40:02 -04:00
thePR0M3TH3AN
55df7a3c56 Use notify for warnings 2025-07-14 16:36:05 -04:00
thePR0M3TH3AN
8c56bfef66 Merge pull request #544 from PR0M3TH3AN/codex/update-api-reference-for-notifications
Update docs with notification box info
2025-07-14 16:06:42 -04:00
thePR0M3TH3AN
300cf4a0a1 Bump aiosignal for aiohttp 2025-07-14 16:02:44 -04:00
thePR0M3TH3AN
85881f3d64 Bump aiohttp dependency 2025-07-14 15:55:19 -04:00
thePR0M3TH3AN
83bdb9ae7a docs: add notification box details 2025-07-14 15:47:52 -04:00
thePR0M3TH3AN
7e43b5f7f5 Merge pull request #543 from PR0M3TH3AN/codex/new-task
Update notification system
2025-07-14 15:42:48 -04:00
thePR0M3TH3AN
9184222514 Refactor notification handling 2025-07-14 15:40:46 -04:00
thePR0M3TH3AN
fb5fd7bfb8 Merge pull request #542 from PR0M3TH3AN/codex/add-clear-header-function-with-notification
Add notification-aware header function
2025-07-14 15:25:14 -04:00
thePR0M3TH3AN
b8a5ed9f66 Add header clearing with notifications 2025-07-14 15:23:37 -04:00
thePR0M3TH3AN
9d0338de63 Merge pull request #541 from PR0M3TH3AN/codex/implement-notification-system-in-passwordmanager
Add notification TTL tracking
2025-07-14 15:14:09 -04:00
thePR0M3TH3AN
9615dcdb31 Fix notification test 2025-07-14 15:10:43 -04:00
thePR0M3TH3AN
ee240cbd1e Add notification tracking and tests 2025-07-14 14:58:58 -04:00
thePR0M3TH3AN
ac68ed9753 Merge pull request #540 from PR0M3TH3AN/codex/add-press-enter-message-to-stats-screen
Improve stats screen usability
2025-07-14 14:44:14 -04:00
thePR0M3TH3AN
8413908c94 Add message and notifications to stats screen 2025-07-14 14:40:18 -04:00
thePR0M3TH3AN
c7c365e5b9 Merge pull request #539 from PR0M3TH3AN/codex/ensure-tui-does-not-hang-after-entry-creation
Improve TUI responsiveness
2025-07-14 14:22:13 -04:00
thePR0M3TH3AN
ba066bf0d4 Run sync in background for TUI actions 2025-07-14 14:16:59 -04:00
thePR0M3TH3AN
a8ba99875f Merge pull request #538 from PR0M3TH3AN/codex/update-notification-handling-and-tests
Improve menu notification output
2025-07-14 12:56:07 -04:00
thePR0M3TH3AN
5bf2bc458c Update menu notification handling 2025-07-14 12:53:53 -04:00
thePR0M3TH3AN
af1a33b904 Merge pull request #537 from PR0M3TH3AN/codex/replace-notify-call-in-start_background_relay_check
Refine relay warning notification
2025-07-14 12:30:32 -04:00
thePR0M3TH3AN
741845799a Warn when few relays respond 2025-07-14 12:28:44 -04:00
thePR0M3TH3AN
549b3903b1 Merge pull request #536 from PR0M3TH3AN/codex/add-tests-for-relay-check-and-notifications
Add notification handling tests
2025-07-14 11:56:17 -04:00
thePR0M3TH3AN
78fbe5e88f Add tests for notifications and relay warnings 2025-07-14 11:51:42 -04:00
thePR0M3TH3AN
a502b9f6b4 Merge pull request #535 from PR0M3TH3AN/codex/add-get-/api/v1/notifications-endpoint
Add notifications API endpoint
2025-07-14 11:44:43 -04:00
thePR0M3TH3AN
d87d9ed59f Add notifications API endpoint 2025-07-14 11:43:09 -04:00
thePR0M3TH3AN
306480896e Merge pull request #534 from PR0M3TH3AN/codex/add-color-categories-and-notification-helper
Add notification drainage and new color scheme categories
2025-07-14 11:21:36 -04:00
thePR0M3TH3AN
19d9c45d6f Add notification draining with color categories 2025-07-14 11:20:05 -04:00
thePR0M3TH3AN
4ea1e98e52 Merge pull request #533 from PR0M3TH3AN/codex/implement-notification-system-in-passwordmanager
Add notification queue for background checks
2025-07-14 11:07:08 -04:00
thePR0M3TH3AN
ba40c5108d Add notification queue to PasswordManager 2025-07-14 11:05:39 -04:00
thePR0M3TH3AN
f28f20b5f0 Merge pull request #532 from PR0M3TH3AN/codex/enable-real-time-stats-updates-during-sync
Enable live sync stats display
2025-07-14 10:19:12 -04:00
thePR0M3TH3AN
62c006c4cb feat: live stats while syncing 2025-07-14 10:17:35 -04:00
thePR0M3TH3AN
c84bc6c81f Merge pull request #531 from PR0M3TH3AN/codex/update-generate_test_profile.py-for-key/value-and-managed-ac
Update test profile generator with more entry types
2025-07-14 09:54:23 -04:00
thePR0M3TH3AN
1c2bb84e75 Update test profile generator with new entry types 2025-07-14 09:54:00 -04:00
thePR0M3TH3AN
9dab240c14 Merge pull request #529 from PR0M3TH3AN/codex/update-dummyrelayclient-to-track-created_at
Record delta timestamps in dummy relay
2025-07-14 09:03:22 -04:00
thePR0M3TH3AN
e45483c6eb Add timestamp tracking to dummy relay and update tests 2025-07-14 08:55:55 -04:00
thePR0M3TH3AN
2a329a40bb Merge pull request #528 from PR0M3TH3AN/codex/extend-test-for-relay-management-endpoints
Add relay reload assertion in API tests
2025-07-14 06:11:24 -04:00
thePR0M3TH3AN
2f8659c49f test: assert nostr client reload on relay update 2025-07-14 06:04:25 -04:00
thePR0M3TH3AN
8949b1631a Merge pull request #527 from PR0M3TH3AN/codex/update-dummyrelayclient-to-support-created_at
Update dummy relay events with timestamps
2025-07-13 22:51:37 -04:00
thePR0M3TH3AN
6b401d85c8 test: record delta timestamp and manifest delta_since 2025-07-13 22:49:52 -04:00
thePR0M3TH3AN
c6d27fe3f9 Merge pull request #526 from PR0M3TH3AN/codex/add-test_full_sync_roundtrip-test
Add full sync roundtrip test
2025-07-13 22:39:48 -04:00
thePR0M3TH3AN
e7837bcfbe Add full sync roundtrip test 2025-07-13 22:37:42 -04:00
thePR0M3TH3AN
c6ed25fc04 Merge pull request #525 from PR0M3TH3AN/codex/add-unit-test-for-client-initialization
Reconnect Nostr client after relay updates
2025-07-13 22:31:00 -04:00
thePR0M3TH3AN
1d92d5e1ca Reconnect after relay update 2025-07-13 22:29:18 -04:00
thePR0M3TH3AN
f9ba5ac41d Merge pull request #524 from PR0M3TH3AN/codex/update-nostrclient-to-manage-manifest-events
Record manifest timestamp and update docs
2025-07-13 22:20:26 -04:00
thePR0M3TH3AN
905b4ec8ba Record manifest details and timestamp 2025-07-13 22:18:28 -04:00
thePR0M3TH3AN
4674f9c440 Merge pull request #523 from PR0M3TH3AN/codex/create-full_sync_roundtrip-test
Add Nostr roundtrip sync test
2025-07-13 22:07:33 -04:00
thePR0M3TH3AN
2f89c02f9b Add full sync roundtrip test 2025-07-13 22:04:50 -04:00
thePR0M3TH3AN
bf17ba8234 Merge pull request #522 from PR0M3TH3AN/codex/add-unit-test-for-client-initialization
Reconnect Nostr client pool on relay update
2025-07-13 21:40:22 -04:00
thePR0M3TH3AN
cbad7ccf75 Ensure client pool reinitializes on relay update 2025-07-13 21:38:52 -04:00
thePR0M3TH3AN
aed25f9810 Merge pull request #521 from PR0M3TH3AN/codex/update-nostrclient-for-manifest-and-delta-handling
Track manifest timestamp for deltas
2025-07-13 21:33:36 -04:00
thePR0M3TH3AN
57997e4958 Record manifest ID and timestamp 2025-07-13 21:32:11 -04:00
thePR0M3TH3AN
8ee97b4a05 Merge pull request #520 from PR0M3TH3AN/codex/update-documentation-for-seed-profile-usage
Clarify test profile behavior
2025-07-13 20:47:39 -04:00
thePR0M3TH3AN
11d78a98c9 docs: clarify test profile location 2025-07-13 20:47:26 -04:00
thePR0M3TH3AN
6a1fd45427 Merge pull request #519 from PR0M3TH3AN/codex/add-test-for-generate_test_profile_sync
Add generate_test_profile sync test
2025-07-13 20:41:22 -04:00
thePR0M3TH3AN
e396c1f2b7 Add sync test for generate_test_profile 2025-07-13 20:40:54 -04:00
thePR0M3TH3AN
74398733fd Merge pull request #518 from PR0M3TH3AN/codex/verify-ciphertext-length-in-decrypt_data
Add AES-GCM short payload check
2025-07-13 20:17:51 -04:00
thePR0M3TH3AN
78104681e4 Validate AES-GCM payload length 2025-07-13 20:15:58 -04:00
thePR0M3TH3AN
e5dbbac762 Merge pull request #517 from PR0M3TH3AN/codex/update-encryptionmanager-for-decryption-fallback
Add Fernet fallback for invalid V2 header
2025-07-13 20:09:32 -04:00
thePR0M3TH3AN
a3fd02f0c9 Add test for legacy Fernet data with V2 prefix 2025-07-13 20:07:56 -04:00
thePR0M3TH3AN
3038646fcb Merge pull request #516 from PR0M3TH3AN/codex/invoke-start_background_sync-after-seed-generation
Ensure sync after seed creation
2025-07-13 19:48:45 -04:00
thePR0M3TH3AN
26badd8cd7 Start sync after new seed 2025-07-13 19:46:59 -04:00
thePR0M3TH3AN
189118bb9c Merge pull request #515 from PR0M3TH3AN/codex/add-test-for-initialize_profile-and-passwordmanager
Add profile initialization integration test
2025-07-13 19:42:38 -04:00
thePR0M3TH3AN
0840ee63c0 Add integration test for initialize_profile with PasswordManager 2025-07-13 19:41:16 -04:00
thePR0M3TH3AN
c110540e06 Merge pull request #514 from PR0M3TH3AN/codex/update-pbkdf2-iteration-count-handling
Fix KDF iteration default in test profile generator
2025-07-13 19:00:41 -04:00
thePR0M3TH3AN
79488b4373 Fix KDF iteration config in test profile script 2025-07-13 18:58:59 -04:00
thePR0M3TH3AN
9d69044895 Merge pull request #513 from PR0M3TH3AN/codex/find-conditional-network-calls-in-vault-and-tui
Avoid blocking startup with network calls
2025-07-13 18:35:57 -04:00
thePR0M3TH3AN
0e7c3e8a84 flush writes for concurrency safety 2025-07-13 18:29:47 -04:00
thePR0M3TH3AN
6fe4b86a19 Prevent background sync thread from blocking cleanup 2025-07-13 18:15:58 -04:00
thePR0M3TH3AN
b01b73c1d5 Avoid blocking network calls at startup 2025-07-13 17:53:07 -04:00
thePR0M3TH3AN
00332fb145 Merge pull request #512 from PR0M3TH3AN/codex/update-documentation-for-vault-synchronization
Update docs about background sync and quick unlock
2025-07-13 17:30:21 -04:00
thePR0M3TH3AN
c669bf0e9f docs: clarify sync behavior and quick unlock 2025-07-13 17:26:52 -04:00
thePR0M3TH3AN
f422715fc8 Merge pull request #511 from PR0M3TH3AN/codex/add-unit-tests-for-background-sync
Add background sync trigger tests
2025-07-13 17:18:53 -04:00
thePR0M3TH3AN
863348f194 Add tests for background sync triggers 2025-07-13 17:17:34 -04:00
thePR0M3TH3AN
983b806906 Merge pull request #510 from PR0M3TH3AN/codex/refactor-sync-calls-in-password-manager
Use background sync after profile changes
2025-07-13 17:10:56 -04:00
thePR0M3TH3AN
d559477342 Use background sync unconditionally 2025-07-13 17:09:30 -04:00
thePR0M3TH3AN
921ef43f2a Merge pull request #509 from PR0M3TH3AN/codex/fix-quick-unlock-settings-visibility
Expose Quick Unlock and Offline Mode
2025-07-13 16:51:21 -04:00
thePR0M3TH3AN
aabc935097 Expose quick unlock and offline mode 2025-07-13 16:49:57 -04:00
thePR0M3TH3AN
c548a8f42f Merge pull request #508 from PR0M3TH3AN/codex/add-timing-logs-and-tests
Add verbose timing logs
2025-07-13 16:14:25 -04:00
thePR0M3TH3AN
4893daa1b4 Add verbose timing logs 2025-07-13 16:09:58 -04:00
thePR0M3TH3AN
5db024b340 Merge pull request #507 from PR0M3TH3AN/codex/reduce-max-retries-and-retry-delay
Add configurable Nostr retry settings
2025-07-13 16:01:38 -04:00
thePR0M3TH3AN
80c67905ae Allow passing ConfigManager to NostrClient 2025-07-13 15:57:31 -04:00
thePR0M3TH3AN
78499b267e Add configurable Nostr retry settings 2025-07-13 15:42:24 -04:00
thePR0M3TH3AN
357e9f28bd Merge pull request #506 from PR0M3TH3AN/codex/modify-passwordmanager-to-support-quick-unlock
Enable background sync when quick unlock
2025-07-13 15:29:39 -04:00
thePR0M3TH3AN
8350504d00 Use background sync when quick unlock 2025-07-13 15:27:24 -04:00
thePR0M3TH3AN
540b33da0e Merge pull request #505 from PR0M3TH3AN/codex/add-quick_unlock-option-to-configmanager
Add quick unlock config option
2025-07-13 15:01:45 -04:00
thePR0M3TH3AN
f5dcaf9af4 Add quick unlock config option 2025-07-13 14:59:22 -04:00
thePR0M3TH3AN
159f4a413f Merge pull request #504 from PR0M3TH3AN/codex/update-kdf_iterations-default-in-config_manager
Lower KDF iteration default
2025-07-13 14:41:57 -04:00
thePR0M3TH3AN
96d5a1bb57 Lower KDF iteration default 2025-07-13 14:40:15 -04:00
thePR0M3TH3AN
7f43d79f6e Merge pull request #503 from PR0M3TH3AN/codex/add-fuzz-tests-for-key-derivation
Add Hypothesis fuzz tests for EncryptionManager
2025-07-13 13:52:32 -04:00
thePR0M3TH3AN
f1018a5c2b Add Hypothesis fuzz test for EncryptionManager 2025-07-13 13:51:15 -04:00
thePR0M3TH3AN
6d092d08ba Merge pull request #502 from PR0M3TH3AN/codex/add-offline-mode-feature
Add offline mode option
2025-07-13 13:21:48 -04:00
thePR0M3TH3AN
cca860adf5 Add offline mode feature 2025-07-13 13:15:05 -04:00
thePR0M3TH3AN
3d71fc5298 Merge pull request #501 from PR0M3TH3AN/codex/implement-password-policy-feature
Add configurable password policy
2025-07-13 13:01:23 -04:00
thePR0M3TH3AN
b4dfd4b292 Add configurable password policy 2025-07-13 13:00:02 -04:00
thePR0M3TH3AN
c0269801f8 Merge pull request #500 from PR0M3TH3AN/codex/add-argon2-support-to-password-manager
Add argon2 KDF option
2025-07-13 12:25:35 -04:00
thePR0M3TH3AN
f86067c1d8 Add Argon2 key derivation option 2025-07-13 12:24:10 -04:00
thePR0M3TH3AN
37d4cc260d Merge pull request #499 from PR0M3TH3AN/codex/update-readme.md-and-documentation
Document KDF and backup interval settings
2025-07-13 11:52:09 -04:00
thePR0M3TH3AN
1c4a8c0aa4 Document kdf and backup settings 2025-07-13 11:50:26 -04:00
thePR0M3TH3AN
a1737eba9c Merge pull request #498 from PR0M3TH3AN/codex/update-mapping-in-config_set-and-add-tests
Add kdf_iterations to CLI config set mapping
2025-07-13 11:39:28 -04:00
thePR0M3TH3AN
d333564aa7 Add kdf_iterations config set option and test 2025-07-13 11:37:51 -04:00
thePR0M3TH3AN
28292b33b8 Merge pull request #497 from PR0M3TH3AN/codex/extend-configmanager-with-backup_interval
Add backup interval throttling
2025-07-13 11:32:27 -04:00
thePR0M3TH3AN
ba53cf2332 Add backup interval setting and throttled backups 2025-07-13 11:30:47 -04:00
thePR0M3TH3AN
51acd21478 Merge pull request #496 from PR0M3TH3AN/codex/update-update_checksum-to-use-compact-serializer
Use canonical serializer for entry checksum
2025-07-13 10:57:20 -04:00
thePR0M3TH3AN
526d31325a Use canonical serializer for entry checksum 2025-07-13 10:55:51 -04:00
thePR0M3TH3AN
9eeda816fe Merge pull request #495 from PR0M3TH3AN/codex/add-orjson-support-to-password-manager
Use compact orjson serialization
2025-07-13 10:49:05 -04:00
thePR0M3TH3AN
6bc8fe70f6 Add optional orjson support 2025-07-13 10:47:45 -04:00
thePR0M3TH3AN
b3f92d12cd Merge pull request #494 from PR0M3TH3AN/codex/fix-decryption-logic-migration-for-nostr-data
Fix encryption migration logic
2025-07-13 10:24:28 -04:00
thePR0M3TH3AN
2f143b6710 Fix decryption migration logic 2025-07-13 10:22:43 -04:00
thePR0M3TH3AN
c27eec26e2 Merge pull request #493 from PR0M3TH3AN/codex/implement-dual-encryption-support
Centralize AES-GCM decryption with version header
2025-07-13 09:50:21 -04:00
thePR0M3TH3AN
dacc591c25 feat: centralize decryption with version prefix 2025-07-13 09:48:36 -04:00
thePR0M3TH3AN
73196e20ec Merge pull request #492 from PR0M3TH3AN/codex/update-migration-documentation-and-readme
Document parent seed migration
2025-07-13 09:28:03 -04:00
thePR0M3TH3AN
0f67518493 docs: describe automatic parent seed migration 2025-07-13 09:26:46 -04:00
thePR0M3TH3AN
5c0c8fd87b Merge pull request #491 from PR0M3TH3AN/codex/add-test-for-seed-migration-functionality
Add test for Fernet seed migration
2025-07-13 09:22:01 -04:00
thePR0M3TH3AN
065835d470 Add test for parent seed migration 2025-07-13 09:20:54 -04:00
thePR0M3TH3AN
e203ce0e1b Merge pull request #490 from PR0M3TH3AN/codex/modify-decrypt_parent_seed-for-aes-gcm-and-fernet
Handle legacy Fernet parent seed files
2025-07-13 09:12:51 -04:00
thePR0M3TH3AN
0ba38c3dc2 Add Fernet migration fallback for parent seed 2025-07-13 09:08:09 -04:00
thePR0M3TH3AN
a93f7c6628 Merge pull request #489 from PR0M3TH3AN/codex/add-fernet-cipher-to-encryptionmanager
Add Fernet fallback cipher
2025-07-13 09:00:13 -04:00
thePR0M3TH3AN
4f7ff30657 Add Fernet fallback cipher 2025-07-13 08:58:56 -04:00
thePR0M3TH3AN
8fd29401d7 Merge pull request #488 from PR0M3TH3AN/codex/edit-encryption.py-to-remove-import-and-format
Fix duplicate import in encryption.py
2025-07-13 08:54:19 -04:00
thePR0M3TH3AN
e411d2f80a Remove duplicate Fernet import 2025-07-13 08:53:03 -04:00
thePR0M3TH3AN
f3f4faf062 Merge pull request #487 from PR0M3TH3AN/codex/update-migration-documentation-for-vault
Document automatic vault migration
2025-07-12 22:31:48 -04:00
thePR0M3TH3AN
865149826e docs: explain automatic legacy vault migration 2025-07-12 22:31:30 -04:00
thePR0M3TH3AN
6391654b3e Merge pull request #486 from PR0M3TH3AN/codex/implement-legacy-fernet-decryption-and-migration
Add migration for legacy Fernet index
2025-07-12 22:25:31 -04:00
thePR0M3TH3AN
c7cb9aa6ec Add legacy Fernet migration 2025-07-12 22:24:15 -04:00
thePR0M3TH3AN
ec61243c0c Merge pull request #485 from PR0M3TH3AN/codex/update-setup_existing_seed-to-use-iterations
Fix iterations call in setup_existing_seed
2025-07-12 22:17:21 -04:00
thePR0M3TH3AN
bfdadebd5f Handle missing config manager for existing seed setup 2025-07-12 22:16:04 -04:00
thePR0M3TH3AN
3ae250bb25 Merge pull request #484 from PR0M3TH3AN/41vkog-codex/refactor-password-manager-entry-methods
Improve EntryManager index caching
2025-07-12 22:10:07 -04:00
thePR0M3TH3AN
10f447c930 Use cached index in EntryManager 2025-07-12 22:09:03 -04:00
thePR0M3TH3AN
f350d44460 Merge pull request #483 from PR0M3TH3AN/codex/avoid-storing-local-data-for-incomplete-profiles
Fix partial profile cleanup
2025-07-12 21:07:16 -04:00
thePR0M3TH3AN
817c8d6330 cleanup seed profile on failure 2025-07-12 21:06:59 -04:00
thePR0M3TH3AN
dad33bf364 Merge pull request #482 from PR0M3TH3AN/codex/refactor-encryption-in-test-suite
Update tests for AES-GCM
2025-07-12 20:33:27 -04:00
thePR0M3TH3AN
f7c2017f1c tests: use AES-GCM key generation 2025-07-12 20:32:04 -04:00
thePR0M3TH3AN
f4a169ca80 Merge pull request #481 from PR0M3TH3AN/codex/revise-encryption-logic-in-password-manager
Switch to AES-GCM encryption
2025-07-12 14:06:25 -04:00
thePR0M3TH3AN
d27e3708c5 Switch encryption to AES-GCM 2025-07-12 14:05:06 -04:00
thePR0M3TH3AN
7b09bb0fdb Merge pull request #480 from PR0M3TH3AN/codex/update-lock_vault-to-clear-cache
Clear entry cache on vault lock
2025-07-12 13:58:25 -04:00
thePR0M3TH3AN
1f460b3aae Clear entry cache on vault lock 2025-07-12 13:53:20 -04:00
thePR0M3TH3AN
4d5f1e5f14 Merge pull request #479 from PR0M3TH3AN/codex/add-caching-to-entrymanager
Implement EntryManager index caching
2025-07-12 13:49:07 -04:00
thePR0M3TH3AN
f4fe208b7f Add index caching to EntryManager 2025-07-12 13:48:10 -04:00
thePR0M3TH3AN
393763c9c8 Merge pull request #478 from PR0M3TH3AN/codex/add-handle_set_kdf_iterations-function
Add KDF iteration settings option
2025-07-12 13:41:43 -04:00
thePR0M3TH3AN
5f78d20685 Add KDF iteration settings option 2025-07-12 13:40:11 -04:00
thePR0M3TH3AN
00262dad4f Merge pull request #477 from PR0M3TH3AN/codex/show-tui-actions-for-supported-entries
Improve entry menu actions visibility
2025-07-12 13:24:38 -04:00
thePR0M3TH3AN
196aaa4dcf feat(tui): hide unsupported actions 2025-07-12 13:23:06 -04:00
thePR0M3TH3AN
78a0561163 Merge pull request #476 from PR0M3TH3AN/codex/enforce-field-restrictions-for-entry-modification
Fix entry type validation
2025-07-12 13:06:35 -04:00
thePR0M3TH3AN
b52d027ec7 Validate entry type fields 2025-07-12 13:03:39 -04:00
thePR0M3TH3AN
543942da76 Merge pull request #475 from PR0M3TH3AN/codex/check-data-and-setup-for-new-profiles
Handle fresh managed accounts with empty vault
2025-07-12 10:03:45 -04:00
thePR0M3TH3AN
ca7f51d226 Initialize managed accounts from nostr or create new 2025-07-12 10:03:30 -04:00
thePR0M3TH3AN
0e3d65dca2 Merge pull request #474 from PR0M3TH3AN/codex/improve-wrong-password-error-message
Improve password retry experience
2025-07-12 09:38:16 -04:00
thePR0M3TH3AN
496950c501 Improve password retry flow 2025-07-12 09:37:42 -04:00
thePR0M3TH3AN
0ef3c2207f Merge pull request #473 from PR0M3TH3AN/codex/add-print-statements-to-setup_encryption_manager
Add debug prints during manager setup
2025-07-12 09:24:02 -04:00
thePR0M3TH3AN
86d233f5a9 Add progress prints for setup and initialization 2025-07-12 09:23:44 -04:00
thePR0M3TH3AN
3e68029ca9 Merge pull request #472 from PR0M3TH3AN/codex/add-kdf_iterations-setting-to-configmanager
Support configurable PBKDF2 iterations
2025-07-12 09:19:03 -04:00
thePR0M3TH3AN
888d50a6a7 Add configurable KDF iterations 2025-07-12 09:18:17 -04:00
thePR0M3TH3AN
9ba7f25962 Merge pull request #471 from PR0M3TH3AN/codex/move-check_relay_health-calls-to-background
Run relay health checks async
2025-07-12 09:08:40 -04:00
thePR0M3TH3AN
7745155ee8 check relays asynchronously 2025-07-12 09:04:48 -04:00
thePR0M3TH3AN
5f2604fa5a Merge pull request #470 from PR0M3TH3AN/codex/avoid-client-pool-initialization-in-__init__
Lazy initialization for NostrClient
2025-07-11 22:50:21 -04:00
thePR0M3TH3AN
1e270c9ab1 Defer nostr client connections 2025-07-11 22:50:08 -04:00
thePR0M3TH3AN
babb4d49f0 Merge pull request #469 from PR0M3TH3AN/codex/remove-sync_index_from_nostr-calls
Refactor sync trigger to background thread
2025-07-11 22:42:53 -04:00
thePR0M3TH3AN
dcff360508 Refactor sync trigger 2025-07-11 22:42:12 -04:00
thePR0M3TH3AN
de92a80798 Merge pull request #468 from PR0M3TH3AN/codex/update-seedpass-command-output
Improve seed profile selection
2025-07-11 22:22:42 -04:00
thePR0M3TH3AN
5af3228d4b Prompt for fingerprint selection if multiple 2025-07-11 22:20:56 -04:00
thePR0M3TH3AN
a08c27132a Merge pull request #467 from PR0M3TH3AN/codex/add-timer-for-seedpass-unlock-duration
Add unlock timing output
2025-07-11 22:02:28 -04:00
thePR0M3TH3AN
7ad60c71fe Add unlock duration timer 2025-07-11 22:01:11 -04:00
thePR0M3TH3AN
637ba1e291 Merge pull request #466 from PR0M3TH3AN/codex/update-documentation-for-vault-import-nostr-sync
Document Nostr sync on vault import
2025-07-11 21:45:50 -04:00
thePR0M3TH3AN
be3bc28cbc docs: note vault import Nostr sync 2025-07-11 21:45:16 -04:00
thePR0M3TH3AN
62bca2a374 Merge pull request #465 from PR0M3TH3AN/codex/add-tests-for-cli-entry-add-commands
Ensure CLI add commands trigger sync
2025-07-11 20:14:44 -04:00
thePR0M3TH3AN
238a07a8e6 test: ensure sync_vault on entry add commands 2025-07-11 20:14:32 -04:00
thePR0M3TH3AN
ff9608fee7 Merge pull request #464 from PR0M3TH3AN/codex/update-sync_vault-calls-after-imports
Trigger vault sync on imports
2025-07-11 20:06:24 -04:00
thePR0M3TH3AN
8414ee53c6 Trigger vault sync after imports 2025-07-11 20:06:05 -04:00
thePR0M3TH3AN
82b6bbaba3 Merge pull request #463 from PR0M3TH3AN/codex/update-cli-commands-to-sync-vault
Add vault sync to CLI entry commands
2025-07-11 19:43:55 -04:00
thePR0M3TH3AN
d4284f1bab Sync vault after entry commands 2025-07-11 19:43:22 -04:00
thePR0M3TH3AN
cd6ce3eaa8 Merge branch 'beta' of https://github.com/PR0M3TH3AN/SeedPass into beta 2025-07-11 17:19:10 -04:00
thePR0M3TH3AN
811c4f883d update 2025-07-11 17:18:57 -04:00
thePR0M3TH3AN
765d73bd92 Merge pull request #462 from PR0M3TH3AN/codex/fix-retrieval-issue-for-entry-2910
Fix fingerprint selection
2025-07-11 17:09:48 -04:00
thePR0M3TH3AN
b82a816cab Persist last used fingerprint 2025-07-11 16:39:51 -04:00
thePR0M3TH3AN
d261a244a0 update 2025-07-11 15:26:48 -04:00
thePR0M3TH3AN
4f4dda1fa2 Merge pull request #461 from PR0M3TH3AN/codex/fix-fingerprint-command-password-prompt
Fix CLI fingerprint handling
2025-07-11 15:23:55 -04:00
thePR0M3TH3AN
b46943f7f8 Support fingerprint option in legacy CLI 2025-07-11 15:23:31 -04:00
thePR0M3TH3AN
cac1eafab1 Merge pull request #460 from PR0M3TH3AN/codex/find-how-to-search-seed-entries
Fix fingerprint option in SeedPass CLI
2025-07-11 14:20:56 -04:00
thePR0M3TH3AN
afefb5415b cli: avoid fingerprint prompt when option provided 2025-07-11 14:19:16 -04:00
thePR0M3TH3AN
e5de6c885e Merge pull request #459 from PR0M3TH3AN/codex/update-install-script-for-tui-usage
Document how to launch SeedPass TUI
2025-07-11 12:51:32 -04:00
thePR0M3TH3AN
fc56ec169f docs: describe launching TUI 2025-07-11 12:41:31 -04:00
thePR0M3TH3AN
077893a389 Merge pull request #458 from PR0M3TH3AN/codex/investigate-tui-not-opening-for-seedpass
Restore TUI launch when running `seedpass`
2025-07-11 12:26:59 -04:00
thePR0M3TH3AN
172314b86b Make seedpass command launch TUI by default 2025-07-11 12:26:04 -04:00
thePR0M3TH3AN
efa52d3593 Merge pull request #457 from PR0M3TH3AN/codex/fix-seedpass-command-not-working
Fix SeedPass CLI launcher
2025-07-11 11:33:09 -04:00
thePR0M3TH3AN
29b06d5b40 Fix CLI launcher and module entry 2025-07-11 11:24:24 -04:00
thePR0M3TH3AN
e528485833 Merge pull request #456 from PR0M3TH3AN/codex/enhance-uninstall-script-error-handling
Handle rm failures in uninstall script
2025-07-11 10:43:53 -04:00
thePR0M3TH3AN
8d64a94d83 Handle rm failure in uninstall 2025-07-11 10:41:33 -04:00
thePR0M3TH3AN
8a70b65e02 Merge pull request #455 from PR0M3TH3AN/codex/fix-404-error-in-uninstall-script
Improve uninstall script
2025-07-11 10:30:05 -04:00
thePR0M3TH3AN
fb27d49ad7 enhance uninstall script to clean old executables 2025-07-11 10:28:48 -04:00
thePR0M3TH3AN
a50fbe9aa9 Merge pull request #454 from PR0M3TH3AN/codex/fix-advanced-cli-entry-command-error
Add uninstall scripts
2025-07-11 09:39:07 -04:00
thePR0M3TH3AN
a32bfd4523 Add uninstall scripts for all platforms 2025-07-11 09:37:07 -04:00
thePR0M3TH3AN
e8e7e67fc3 Merge pull request #453 from PR0M3TH3AN/codex/locate-command-usage-issue-in-seedpass
Clarify CLI reinstall steps
2025-07-10 22:57:48 -04:00
thePR0M3TH3AN
9358f620c2 Merge branch 'beta' into codex/locate-command-usage-issue-in-seedpass 2025-07-10 22:57:34 -04:00
thePR0M3TH3AN
2609064016 Clarify CLI reinstall steps 2025-07-10 22:47:23 -04:00
thePR0M3TH3AN
b8cf22e0bb Merge pull request #452 from PR0M3TH3AN/codex/locate-command-usage-issue-in-seedpass
Improve CLI install instructions
2025-07-10 22:38:37 -04:00
thePR0M3TH3AN
b1d58b206b docs: clarify CLI install 2025-07-10 22:38:24 -04:00
thePR0M3TH3AN
26030611b1 update 2025-07-10 22:13:46 -04:00
thePR0M3TH3AN
01248b9d2a Merge pull request #451 from PR0M3TH3AN/codex/align-cli-with-advanced-api-documentation
Align docs with Typer CLI and update install scripts
2025-07-10 22:10:05 -04:00
thePR0M3TH3AN
fd1db2cdde Update installer scripts and docs for Typer CLI 2025-07-10 22:09:49 -04:00
thePR0M3TH3AN
d3320d9ff9 Merge pull request #450 from PR0M3TH3AN/codex/update-advanced-cli-documentation
Update advanced CLI config docs
2025-07-10 21:46:00 -04:00
thePR0M3TH3AN
4e184f9e7d docs: update advanced CLI config options 2025-07-10 21:45:27 -04:00
thePR0M3TH3AN
c8a96a292a update 2025-07-10 21:37:40 -04:00
thePR0M3TH3AN
10bb880a4d update 2025-07-10 21:07:26 -04:00
thePR0M3TH3AN
b6a8604b1f update 2025-07-10 21:06:14 -04:00
thePR0M3TH3AN
61a94a8fcc Merge pull request #449 from PR0M3TH3AN/codex/create-comprehensive-cli-command-combo-tests
Add test for advanced CLI documentation commands
2025-07-10 20:58:21 -04:00
thePR0M3TH3AN
d1d11a46ac test: verify advanced CLI examples 2025-07-10 20:53:54 -04:00
thePR0M3TH3AN
6052a1543b update 2025-07-10 20:05:13 -04:00
thePR0M3TH3AN
5caf316644 update 2025-07-10 20:01:29 -04:00
thePR0M3TH3AN
daa3a0c377 update 2025-07-10 19:55:28 -04:00
thePR0M3TH3AN
5238a62b10 update 2025-07-10 19:53:45 -04:00
thePR0M3TH3AN
7129e82110 update 2025-07-10 19:44:39 -04:00
thePR0M3TH3AN
40d16101e0 update 2025-07-10 19:37:30 -04:00
thePR0M3TH3AN
de5be5f09b Merge pull request #448 from PR0M3TH3AN/codex/find-feature-parity-between-cli,-api-and-tui
Add HTML docs with ReadTheDocs styling
2025-07-09 22:13:02 -04:00
thePR0M3TH3AN
050e8ec782 Add Read the Docs style documentation 2025-07-09 22:08:31 -04:00
thePR0M3TH3AN
04a0c3ee54 Merge pull request #447 from PR0M3TH3AN/codex/update-documentation-for-new-cli-commands
Update docs for new CLI commands and API routes
2025-07-09 21:47:50 -04:00
thePR0M3TH3AN
11bdbb9962 Document new CLI commands and API endpoints 2025-07-09 21:43:12 -04:00
thePR0M3TH3AN
6892455364 Merge pull request #446 from PR0M3TH3AN/g6i0it-codex/add-new-api-routes-in-seedpass
Implement vault export and relay management API endpoints
2025-07-09 21:25:55 -04:00
thePR0M3TH3AN
39ec8026db Add vault export and relay management API 2025-07-09 21:21:29 -04:00
244 changed files with 24547 additions and 3039 deletions

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

@@ -0,0 +1,27 @@
name: Build GUI
on:
push:
tags:
- 'seedpass-gui*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r src/requirements.txt
pip install briefcase
- name: Build with Briefcase
run: briefcase build
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: seedpass-gui
path: dist/**

View File

@@ -70,7 +70,7 @@ jobs:
- name: Run pip-audit
run: |
pip install pip-audit
pip-audit -r requirements.lock
pip-audit -r requirements.lock --ignore-vuln GHSA-wj6h-64fc-37mp
- name: Determine stress args
shell: bash
run: |
@@ -81,10 +81,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:

11
.gitignore vendored
View File

@@ -33,3 +33,14 @@ coverage.xml
# Other
.hypothesis
totp_export.json.enc
# src
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
# Allow vendored dependencies to be committed
!src/vendor/

561
README.md
View File

@@ -2,7 +2,7 @@
![SeedPass Logo](https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/refs/heads/main/logo/png/SeedPass-Logo-03.png)
**SeedPass** is a secure password generator and manager built on **Bitcoin's BIP-85 standard**. It uses deterministic key derivation to generate **passwords that are never stored**, but can be easily regenerated when needed. By integrating with the **Nostr network**, SeedPass compresses your encrypted vault and splits it into 50KB chunks. Each chunk is published as a parameterised replaceable event (`kind 30071`), with a manifest (`kind 30070`) describing the snapshot and deltas (`kind 30072`) capturing changes between snapshots. This allows secure password recovery across devices without exposing your data.
**SeedPass** is a secure password generator and manager built on **Bitcoin's BIP-85 standard**. It uses deterministic key derivation to generate **passwords that are never stored**, but can be easily regenerated when needed. By integrating with the **Nostr network**, SeedPass compresses your encrypted vault and splits it into 50 KB chunks. Each chunk is published as a parameterised replaceable event (`kind 30071`), with a manifest (`kind 30070`) describing the snapshot and deltas (`kind 30072`) capturing changes between snapshots. This allows secure password recovery across devices without exposing your data.
[Tip Jar](https://nostrtipjar.netlify.app/?n=npub16y70nhp56rwzljmr8jhrrzalsx5x495l4whlf8n8zsxww204k8eqrvamnp)
@@ -10,7 +10,7 @@
**⚠️ Disclaimer**
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.
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.
---
### Supported OS
@@ -18,10 +18,10 @@ This software was not developed by an experienced security expert and should be
✔ Windows 10/11 • macOS 12+ • Any modern Linux
SeedPass now uses the `portalocker` library for cross-platform file locking. No WSL or Cygwin required.
## Table of Contents
- [Features](#features)
- [Architecture Overview](#architecture-overview)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [1. Clone the Repository](#1-clone-the-repository)
@@ -32,6 +32,9 @@ 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)
- [Building a standalone executable](#building-a-standalone-executable)
- [Packaging with Briefcase](#packaging-with-briefcase)
- [Security Considerations](#security-considerations)
- [Contributing](#contributing)
- [License](#license)
@@ -42,7 +45,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.
- **Chunked Snapshots:** Encrypted vaults are compressed and split into 50KB chunks published as `kind 30071` events with a `kind 30070` manifest and `kind 30072` deltas.
- **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.
- **Nested Managed Account Seeds:** SeedPass can derive nested managed account seeds.
@@ -52,21 +55,68 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
- **Export 2FA Codes:** Save all stored TOTP entries to an encrypted JSON file for use with other apps.
- **Display TOTP Codes:** Show all active 2FA codes with a countdown timer.
- **Optional External Backup Location:** Configure a second directory where backups are automatically copied.
- **AutoLock on Inactivity:** Vault locks after a configurable timeout for additional security.
- **Secret Mode:** Copy retrieved passwords directly to your clipboard and automatically clear it after a delay.
- **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:** 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.
- **Vault Statistics:** View counts for entries and other profile metrics.
- **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 all Nostr communication for local-only operation.
A small on-screen notification area now shows queued messages for 10 seconds
before fading.
## Architecture Overview
SeedPass follows a layered design. The **`seedpass.core`** package exposes the
`PasswordManager` along with service classes (e.g. `VaultService` and
`EntryService`) that implement the main API used across interfaces.
The command line tool in **`seedpass.cli`** is a thin adapter built with Typer
that delegates operations to this API layer.
The BeeWare desktop interface lives in **`seedpass_gui.app`** and can be
started with either `seedpass-gui` or `python -m seedpass_gui`. It reuses the
same service objects to unlock the vault, list entries and search through them.
An optional browser extension can communicate with the FastAPI server exposed by
`seedpass.api` to manage entries from within the browser.
```mermaid
graph TD
core["seedpass.core"]
cli["CLI"]
api["FastAPI server"]
gui["BeeWare GUI"]
ext["Browser Extension"]
cli --> core
gui --> core
api --> core
ext --> api
```
See `docs/ARCHITECTURE.md` for details.
## Prerequisites
- **Python 3.8+** (3.11 or 3.12 recommended): Install Python from [python.org](https://www.python.org/downloads/) and be sure to check **"Add Python to PATH"** during setup. Using Python 3.13 is currently discouraged because some dependencies do not ship wheels for it yet, which can cause build failures on Windows unless you install the Visual C++ Build Tools.
*Windows only:* Install the [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and select the **C++ build tools** workload.
- **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.
*Windows only:* Install the [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and select the **C++ build tools** workload.
## Installation
### Quick Installer
Use the automated installer to download SeedPass and its dependencies in one step.
The scripts also install the correct BeeWare backend for your platform automatically.
If the GTK `gi` bindings are missing, the installer attempts to install the
necessary system packages using `apt`, `yum`, `pacman`, or Homebrew.
**Linux and macOS:**
```bash
@@ -76,82 +126,86 @@ bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/
```bash
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)" _ -b beta
```
Make sure the command ends right after `-b beta` with **no trailing parenthesis**.
**Windows (PowerShell):**
```powershell
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))
```
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.
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.
*Install the beta branch:*
#### 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:
**Linux and macOS:**
```bash
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/uninstall.sh)"
```
If you see a warning that an old executable couldn't be removed, delete the file manually.
**Windows (PowerShell):**
```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)) -Branch beta
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/uninstall.ps1'); & ([scriptblock]::create($scriptContent))
```
### Manual Setup
Follow these steps to set up SeedPass on your local machine.
### 1. Clone the Repository
1. **Clone the Repository**
First, clone the SeedPass repository from GitHub:
```bash
git clone https://github.com/PR0M3TH3AN/SeedPass.git
cd SeedPass
```
```bash
git clone https://github.com/PR0M3TH3AN/SeedPass.git
```
2. **Create a Virtual Environment**
Navigate to the project directory:
```bash
python3 -m venv venv
```
```bash
cd SeedPass
```
3. **Activate the Virtual Environment**
### 2. Create a Virtual Environment
- **Linux/macOS:**
```bash
source venv/bin/activate
```
- **Windows:**
```bash
venv\Scripts\activate
```
It's recommended to use a virtual environment to manage your project's dependencies. Create a virtual environment named `venv`:
4. **Install Dependencies**
```bash
python3 -m venv venv
```
### 3. Activate the Virtual Environment
Activate the virtual environment using the appropriate command for your operating system.
- **On Linux and macOS:**
```bash
source venv/bin/activate
```
- **On Windows:**
```bash
venv\Scripts\activate
```
Once activated, your terminal prompt should be prefixed with `(venv)` indicating that the virtual environment is active.
### 4. Install Dependencies
Install the required Python packages and build dependencies using `pip`.
When upgrading pip, use `python -m pip` inside the virtual environment so that pip can update itself cleanly:
```bash
python -m pip install --upgrade pip
python -m pip install -r src/requirements.txt
```
```bash
python -m pip install --upgrade pip
python -m pip install -r src/requirements.txt
python -m pip install -e .
```
// 🔧 merged conflicting changes from codex/locate-command-usage-issue-in-seedpass vs beta
After reinstalling, run `which seedpass` on Linux/macOS or `where seedpass` on Windows to confirm the command resolves to your virtual environment's `seedpass` executable.
#### 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 will attempt to install **xclip** automatically if neither tool is available. If the automatic installation fails, you can install it manually:
```bash
sudo apt-get install xclip
@@ -159,12 +213,18 @@ sudo apt-get install xclip
## Quick Start
After installing dependencies and activating your virtual environment, launch
SeedPass and create a backup:
After installing dependencies and activating your virtual environment, install the package in editable mode so the `seedpass` command is available:
```bash
# Start the application
python src/main.py
python -m pip install -e .
```
You can then launch SeedPass and create a backup:
```bash
# Start the application (interactive TUI)
seedpass
# Export your index
seedpass export --file "~/seedpass_backup.json"
@@ -176,6 +236,7 @@ seedpass import --file "~/seedpass_backup.json"
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
@@ -183,13 +244,61 @@ 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.
```
For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md).
Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md).
For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md). Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md).
### Getting Started with the GUI
SeedPass also ships with a simple BeeWare desktop interface. Launch it from
your virtual environment using any of the following commands:
```bash
seedpass gui
python -m seedpass_gui
seedpass-gui
```
Only `toga-core` and the headless `toga-dummy` backend are included by default.
The quick installer automatically installs the correct BeeWare backend so the
GUI works out of the box. If you set up SeedPass manually, install the backend
for your platform:
```bash
# Linux
pip install toga-gtk
# If you see build errors about "cairo" on Linux, install the cairo
# development headers using your package manager, e.g.:
sudo apt-get install libcairo2 libcairo2-dev
# Windows
pip install toga-winforms
# macOS
pip install toga-cocoa
```
The GUI works with the same vault and configuration files as the CLI.
```mermaid
graph TD
core["seedpass.core"]
cli["CLI"]
api["FastAPI server"]
gui["BeeWare GUI"]
ext["Browser Extension"]
cli --> core
gui --> core
api --> core
ext --> api
```
### Vault JSON Layout
@@ -209,28 +318,64 @@ The encrypted index file `seedpass_entries_db.json.enc` begins with `schema_vers
}
```
> **Note**
>
> Opening a vault created by older versions automatically converts the legacy
> `seedpass_passwords_db.json.enc` (Fernet) to AES-GCM as
> `seedpass_entries_db.json.enc`. The original file is kept with a `.fernet`
> extension.
> The same migration occurs for a legacy `parent_seed.enc` encrypted with
> Fernet: it is transparently decrypted, re-encrypted with AES-GCM and the old
> file saved as `parent_seed.enc.fernet`.
## Usage
After successfully installing the dependencies, you can run SeedPass using the following command:
After successfully installing the dependencies, install the package with:
```bash
python -m pip install -e .
```
Once installed, launch the interactive TUI with:
```bash
seedpass
```
You can also run directly from the repository with:
```bash
python src/main.py
```
You can also use the new Typer-based CLI:
You can explore other CLI commands using:
```bash
seedpass --help
```
If this command displays `usage: main.py` instead of the Typer help output, an old `seedpass` executable is still on your `PATH`. Remove it with `pip uninstall seedpass` or delete the stale launcher and rerun:
```bash
python -m pip install -e .
```
// 🔧 merged conflicting changes from codex/locate-command-usage-issue-in-seedpass vs beta
You can confirm which executable will run with:
```bash
which seedpass # or 'where seedpass' on Windows
```
For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). The REST API is described in [docs/api_reference.md](docs/api_reference.md).
### Running the Application
1. **Start the Application:**
```bash
python src/main.py
```
```bash
seedpass
```
*(or `python src/main.py` when running directly from the repository)*
2. **Follow the Prompts:**
@@ -240,18 +385,18 @@ For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). Th
Example menu:
```bash
Select an option:
1. Add Entry
2. Retrieve Entry
3. Search Entries
4. List Entries
5. Modify an Existing Entry
6. 2FA Codes
7. Settings
```bash
Select an option:
1. Add Entry
2. Retrieve Entry
3. Search Entries
4. List Entries
5. Modify an Existing Entry
6. 2FA Codes
7. Settings
Enter your choice (1-7) or press Enter to exit:
```
Enter your choice (1-7) or press Enter to exit:
```
When choosing **Add Entry**, you can now select from:
@@ -264,6 +409,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)**.
@@ -285,11 +439,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
@@ -297,45 +460,45 @@ SeedPass supports storing more than just passwords and 2FA secrets. You can also
- **SSH Key** deterministically derive an Ed25519 key pair for servers or git hosting platforms.
- **Seed Phrase** store only the BIP-85 index and word count. The mnemonic is regenerated on demand.
- **PGP Key** derive an OpenPGP key pair from your master seed.
- **Nostr Key Pair** store the index used to derive an `npub`/`nsec` pair for Nostr clients.
When you retrieve one of these entries, SeedPass can display QR codes for the
keys. The `npub` is wrapped in the `nostr:` URI scheme so any client can scan
it, while the `nsec` QR is shown only after a security warning.
- **Nostr Key Pair** store the index used to derive an `npub`/`nsec` pair for Nostr clients. When you retrieve one of these entries, SeedPass can display QR codes for the keys. The `npub` is wrapped in the `nostr:` URI scheme so any client can scan it, while the `nsec` QR is shown only after a security warning.
- **Key/Value** store a simple key and value for miscellaneous secrets or configuration data.
- **Managed Account** derive a child seed under the current profile. Loading a managed account switches to a nested profile and the header shows `<parent_fp> > Managed Account > <child_fp>`. Press Enter on the main menu to return to the parent profile.
The table below summarizes the extra fields stored for each entry type. Every
entry includes a `label`, while only password entries track a `url`.
| Entry Type | Extra Fields |
|---------------|---------------------------------------------------------------------------------------------------------------------------------------|
| Password | `username`, `url`, `length`, `archived`, optional `notes`, optional `custom_fields` (may include hidden fields), optional `tags` |
| 2FA (TOTP) | `index` or `secret`, `period`, `digits`, `archived`, optional `notes`, optional `tags` |
| SSH Key | `index`, `archived`, optional `notes`, optional `tags` |
| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes`, optional `tags` |
| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes`, optional `tags` |
| Nostr Key Pair| `index`, `archived`, optional `notes`, optional `tags` |
| Key/Value | `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` |
| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes`, optional `tags` |
The table below summarizes the extra fields stored for each entry type. Every entry includes a `label`, while only password entries track a `url`.
| Entry Type | Extra Fields |
|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| Password | `username`, `url`, `length`, `archived`, optional `notes`, optional `custom_fields` (may include hidden fields), optional `tags` |
| 2FA (TOTP) | `index` or `secret`, `period`, `digits`, `archived`, optional `notes`, optional `tags` |
| SSH Key | `index`, `archived`, optional `notes`, optional `tags` |
| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes`, optional `tags` |
| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes`, optional `tags` |
| Nostr Key Pair | `index`, `archived`, optional `notes`, 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
SeedPass allows you to manage multiple seed profiles (previously referred to as "fingerprints"). Each seed profile has its own parent seed and associated data, enabling you to compartmentalize your passwords.
- **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.
- 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.**
1. From the main menu, select **Settings** then **Profiles** and choose "Add a New Seed Profile".
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:**
- From the **Profiles** menu, select "Switch Seed Profile".
- You'll see a list of available seed profiles.
- Enter the number corresponding to the seed profile you wish to switch to.
- Enter the master password associated with that seed profile.
1. From the **Profiles** menu, select "Switch Seed Profile".
2. You'll see a list of available seed profiles.
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:**
- In the **Profiles** menu, choose "List All Seed Profiles" to view all existing 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.
@@ -364,29 +527,50 @@ You can manage your relays and sync with Nostr from the **Settings** menu:
Back in the Settings menu you can:
* Select `3` to change your master password.
* Choose `4` to verify the script checksum.
* Select `5` to generate a new script checksum.
* Choose `6` to back up the parent seed.
* Select `7` to export the database to an encrypted file.
* Choose `8` to import a database from a backup file.
* Select `9` to export all 2FA codes.
* Choose `10` to set an additional backup location. A backup is created
immediately after the directory is configured.
* Select `11` to change the inactivity timeout.
* Choose `12` to lock the vault and require re-entry of your password.
* Select `13` to view seed profile stats. The summary lists counts for
passwords, TOTP codes, SSH keys, seed phrases, and PGP keys. It also shows
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 return to the main menu.
- Select `3` to change your master password.
- Choose `4` to verify the script checksum.
- Select `5` to generate a new script checksum.
- Choose `6` to back up the parent seed.
- Select `7` to export the database to an encrypted file.
- Choose `8` to import a database from a backup file.
- Select `9` to export all 2FA codes.
- Choose `10` to set an additional backup location. A backup is created immediately after the directory is configured.
- Select `11` to set the PBKDF2 iteration count used for encryption.
- Choose `12` to change the inactivity timeout.
- Select `13` to lock the vault and require re-entry of your password.
- Select `14` to view seed profile stats. The summary lists counts for passwords, TOTP codes, SSH keys, seed phrases, and PGP keys. It also shows whether both the encrypted database and the script itself pass checksum validation.
- Choose `15` to toggle Secret Mode and set the clipboard clear delay.
- Select `16` to toggle Offline Mode and disable Nostr synchronization.
- Choose `17` to toggle Quick Unlock for skipping the password prompt after the first unlock.
Press **Enter** at any time to return to the main menu.
You can adjust these settings directly from the command line:
```bash
seedpass config set kdf_iterations 200000
seedpass config set backup_interval 3600
seedpass config set quick_unlock true
seedpass config set nostr_max_retries 2
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:
1. Start SeedPass and choose option **4** when prompted to set up a seed.
2. Paste your BIP-85 seed phrase when asked.
3. SeedPass initializes the profile and attempts to download the encrypted vault
from the configured relays.
4. A success message confirms the vault was restored. If no data is found a
failure message is shown and a new empty vault is created.
## Running Tests
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:
```bash
pip install -r src/requirements.txt
pytest -vv
@@ -394,10 +578,7 @@ pytest -vv
### Exploring Nostr Index Size Limits
`test_nostr_index_size.py` demonstrates how SeedPass rotates snapshots after too many delta events.
Each chunk is limited to 50KB, so the test gradually grows the vault to observe
when a new snapshot is triggered. Use the `NOSTR_TEST_DELAY` environment
variable to control the delay between publishes when experimenting with large vaults.
`test_nostr_index_size.py` demonstrates how SeedPass rotates snapshots after too many delta events. Each chunk is limited to 50 KB, so the test gradually grows the vault to observe when a new snapshot is triggered. Use the `NOSTR_TEST_DELAY` environment variable to control the delay between publishes when experimenting with large vaults.
```bash
pytest -vv -s -n 0 src/tests/test_nostr_index_size.py --desktop --max-entries=1000
@@ -411,23 +592,26 @@ Use the helper script below to populate a profile with sample entries for testin
python scripts/generate_test_profile.py --profile demo_profile --count 100
```
The script now determines the fingerprint from the generated seed and stores the
vault under `~/.seedpass/<fingerprint>`. It also prints the fingerprint after
creation and publishes the encrypted index 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.
The script determines the fingerprint from the generated seed and stores the
vault under `~/.seedpass/tests/<fingerprint>`. SeedPass only looks for profiles
in `~/.seedpass/`, so move or copy the fingerprint directory out of the `tests`
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. 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
SeedPass stores a SHA-256 checksum for the main program in `~/.seedpass/seedpass_script_checksum.txt`.
To keep this value in sync with the source code, install the prepush git hook:
SeedPass stores a SHA-256 checksum for the main program in `~/.seedpass/seedpass_script_checksum.txt`. To keep this value in sync with the source code, install the pre-push git hook:
```bash
pre-commit install -t pre-push
```
After running this command, every `git push` will execute `scripts/update_checksum.py`,
updating the checksum file automatically.
After running this command, every `git push` will execute `scripts/update_checksum.py`, updating the checksum file automatically.
If the checksum file is missing, generate it manually:
@@ -435,6 +619,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
@@ -444,6 +632,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 -r src/requirements.txt
```
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
@@ -455,35 +709,32 @@ Mutation testing is disabled in the GitHub workflow due to reliability issues an
- **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.
- **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.
- **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.
- **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.
## Contributing
Contributions are welcome! If you have suggestions for improvements, bug fixes, or new features, please follow these steps:
1. **Fork the Repository:** Click the "Fork" button on the top right of the repository page.
2. **Create a Branch:** Create a new branch for your feature or bugfix.
1. **Create a Branch:** Create a new branch for your feature or bugfix.
```bash
git checkout -b feature/YourFeatureName
```
3. **Commit Your Changes:** Make your changes and commit them with clear messages.
1. **Commit Your Changes:** Make your changes and commit them with clear messages.
```bash
git commit -m "Add feature X"
```
4. **Push to GitHub:** Push your changes to your forked repository.
1. **Push to GitHub:** Push your changes to your forked repository.
```bash
git push origin feature/YourFeatureName
```
5. **Create a Pull Request:** Navigate to the original repository and create a pull request describing your changes.
1. **Create a Pull Request:** Navigate to the original repository and create a pull request describing your changes.
## License
@@ -496,5 +747,3 @@ For any questions, suggestions, or support, please open an issue on the [GitHub
---
*Stay secure and keep your passwords safe with SeedPass!*
---

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.

2
docs/.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

17
docs/.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm install
- run: npm test

2
docs/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
_site/
node_modules/

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.

View File

@@ -1,25 +1,55 @@
# SeedPass Documentation
# Archivox
This directory contains supplementary guides for using SeedPass.
Archivox is a lightweight static site generator aimed at producing documentation sites similar to "Read the Docs". Write your content in Markdown, run the generator, and deploy the static files anywhere.
## Quick Example: Get a TOTP Code
[![Build Status](https://github.com/PR0M3TH3AN/Archivox/actions/workflows/ci.yml/badge.svg)](https://github.com/PR0M3TH3AN/Archivox/actions/workflows/ci.yml)
Run `seedpass entry get <query>` to retrieve a time-based one-time password (TOTP).
The `<query>` can be a label, title, or index. A progress bar shows the remaining
seconds in the current period.
## Features
- Markdown based pages with automatic navigation
- Responsive layout with sidebar and search powered by Lunr.js
- Simple configuration through `config.yaml`
- Extensible via plugins and custom templates
## Getting Started
Install the dependencies and start the development server:
```bash
$ seedpass entry get "email"
[##########----------] 15s
Code: 123456
npm install
npm run dev
```
To show all stored TOTP codes with their countdown timers, run:
The site will be available at `http://localhost:8080`. Edit files inside the `content/` directory to update pages.
To create a new project from the starter template you can run:
```bash
$ seedpass entry totp-codes
npx create-archivox my-docs --install
```
## CLI and API Reference
## Building
When you are ready to publish your documentation run:
See [advanced_cli.md](advanced_cli.md) for a list of command examples. Detailed information about the REST API is available in [api_reference.md](api_reference.md). When starting the API, set `SEEDPASS_CORS_ORIGINS` if you need to allow requests from specific web origins.
```bash
npm run build
```
The generated site is placed in the `_site/` folder.
## Customization
- **`config.yaml`** change the site title, theme options and other settings.
- **`plugins/`** add JavaScript files exporting hook functions such as `onPageRendered` to extend the build process.
- **`templates/`** modify or replace the Nunjucks templates for full control over the HTML.
## Hosting
Upload the contents of `_site/` to any static host. For Netlify you can use the provided `netlify.toml`:
```toml
[build]
command = "npm run build"
publish = "_site"
```
## Documentation
See the files under the `docs/` directory for a full guide to Archivox including an integration tutorial for existing projects.
Archivox is released under the MIT License.

View File

@@ -0,0 +1,34 @@
const { buildNav } = require('../src/generator');
test('generates navigation tree', () => {
const pages = [
{ file: 'guide/install.md', data: { title: 'Install', order: 1 } },
{ file: 'guide/usage.md', data: { title: 'Usage', order: 2 } },
{ file: 'guide/nested/info.md', data: { title: 'Info', order: 1 } }
];
const tree = buildNav(pages);
const guide = tree.find(n => n.name === 'guide');
expect(guide).toBeDefined();
expect(guide.children.length).toBe(3);
const install = guide.children.find(c => c.name === 'install.md');
expect(install.path).toBe('/guide/install.html');
});
test('adds display names and section flags', () => {
const pages = [
{ file: '02-api.md', data: { title: 'API', order: 2 } },
{ file: '01-guide/index.md', data: { title: 'Guide', order: 1 } },
{ file: '01-guide/setup.md', data: { title: 'Setup', order: 2 } },
{ file: 'index.md', data: { title: 'Home', order: 10 } }
];
const nav = buildNav(pages);
expect(nav[0].name).toBe('index.md');
const guide = nav.find(n => n.name === '01-guide');
expect(guide.displayName).toBe('Guide');
expect(guide.isSection).toBe(true);
const api = nav.find(n => n.name === '02-api.md');
expect(api.displayName).toBe('API');
// alphabetical within same order
expect(nav[1].name).toBe('01-guide');
expect(nav[2].name).toBe('02-api.md');
});

View File

@@ -0,0 +1,13 @@
const fs = require('fs');
const path = require('path');
const loadConfig = require('../src/config/loadConfig');
test('loads configuration and merges defaults', () => {
const dir = fs.mkdtempSync(path.join(__dirname, 'cfg-'));
const file = path.join(dir, 'config.yaml');
fs.writeFileSync(file, 'site:\n title: Test Site\n');
const cfg = loadConfig(file);
expect(cfg.site.title).toBe('Test Site');
expect(cfg.navigation.search).toBe(true);
fs.rmSync(dir, { recursive: true, force: true });
});

View File

@@ -0,0 +1,23 @@
const fs = require('fs');
const path = require('path');
const loadPlugins = require('../src/config/loadPlugins');
test('plugin hook modifies data', async () => {
const dir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'plugins-'));
const pluginFile = path.join(dir, 'test.plugin.js');
fs.writeFileSync(
pluginFile,
"module.exports = { onParseMarkdown: ({ content }) => ({ content: content + '!!' }) };\n"
);
const plugins = loadPlugins({ pluginsDir: dir, plugins: ['test.plugin'] });
let data = { content: 'hello' };
for (const plugin of plugins) {
if (typeof plugin.onParseMarkdown === 'function') {
const res = await plugin.onParseMarkdown(data);
if (res !== undefined) data = res;
}
}
expect(data.content).toBe('hello!!');
fs.rmSync(dir, { recursive: true, force: true });
});

View File

@@ -0,0 +1,77 @@
jest.mock('@11ty/eleventy', () => {
const fs = require('fs');
const path = require('path');
return class Eleventy {
constructor(input, output) {
this.input = input;
this.output = output;
}
setConfig() {}
async write() {
const walk = d => {
const entries = fs.readdirSync(d, { withFileTypes: true });
let files = [];
for (const e of entries) {
const p = path.join(d, e.name);
if (e.isDirectory()) files = files.concat(walk(p));
else if (p.endsWith('.md')) files.push(p);
}
return files;
};
for (const file of walk(this.input)) {
const rel = path.relative(this.input, file).replace(/\.md$/, '.html');
const dest = path.join(this.output, rel);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, '<header></header><aside class="sidebar"></aside>');
}
}
};
});
const fs = require('fs');
const path = require('path');
const os = require('os');
const { generate } = require('../src/generator');
function getPaths(tree) {
const paths = [];
for (const node of tree) {
if (node.path) paths.push(node.path);
if (node.children) paths.push(...getPaths(node.children));
}
return paths;
}
test('markdown files render with layout and appear in nav/search', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'df-test-'));
const contentDir = path.join(tmp, 'content');
const outputDir = path.join(tmp, '_site');
fs.mkdirSync(path.join(contentDir, 'guide'), { recursive: true });
fs.writeFileSync(path.join(contentDir, 'index.md'), '# Home\nWelcome');
fs.writeFileSync(path.join(contentDir, 'guide', 'install.md'), '# Install\nSteps');
const configPath = path.join(tmp, 'config.yaml');
fs.writeFileSync(configPath, 'site:\n title: Test\n');
await generate({ contentDir, outputDir, configPath });
const indexHtml = fs.readFileSync(path.join(outputDir, 'index.html'), 'utf8');
const installHtml = fs.readFileSync(path.join(outputDir, 'guide', 'install.html'), 'utf8');
expect(indexHtml).toContain('<header');
expect(indexHtml).toContain('<aside class="sidebar"');
expect(installHtml).toContain('<header');
expect(installHtml).toContain('<aside class="sidebar"');
const nav = JSON.parse(fs.readFileSync(path.join(outputDir, 'navigation.json'), 'utf8'));
const navPaths = getPaths(nav);
expect(navPaths).toContain('/index.html');
expect(navPaths).toContain('/guide/install.html');
const search = JSON.parse(fs.readFileSync(path.join(outputDir, 'search-index.json'), 'utf8'));
const docs = search.docs.map(d => d.id);
expect(docs).toContain('index.html');
expect(docs).toContain('guide/install.html');
const installDoc = search.docs.find(d => d.id === 'guide/install.html');
expect(installDoc.body).toContain('Steps');
fs.rmSync(tmp, { recursive: true, force: true });
});

View File

@@ -0,0 +1,128 @@
jest.mock('@11ty/eleventy', () => {
const fs = require('fs');
const path = require('path');
return class Eleventy {
constructor(input, output) {
this.input = input;
this.output = output;
}
setConfig() {}
async write() {
const walk = d => {
const entries = fs.readdirSync(d, { withFileTypes: true });
let files = [];
for (const e of entries) {
const p = path.join(d, e.name);
if (e.isDirectory()) files = files.concat(walk(p));
else if (p.endsWith('.md')) files.push(p);
}
return files;
};
for (const file of walk(this.input)) {
const rel = path.relative(this.input, file).replace(/\.md$/, '.html');
const dest = path.join(this.output, rel);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(
dest,
`<!DOCTYPE html><html><head><link rel="stylesheet" href="/assets/theme.css"></head><body><header><button id="sidebar-toggle" class="sidebar-toggle">☰</button></header><div class="container"><aside class="sidebar"></aside><main></main></div><script src="/assets/theme.js"></script></body></html>`
);
}
}
};
});
const fs = require('fs');
const path = require('path');
const http = require('http');
const os = require('os');
const puppeteer = require('puppeteer');
const { generate } = require('../src/generator');
jest.setTimeout(30000);
let server;
let browser;
let port;
let tmp;
beforeAll(async () => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'df-responsive-'));
const contentDir = path.join(tmp, 'content');
const outputDir = path.join(tmp, '_site');
fs.mkdirSync(contentDir, { recursive: true });
fs.writeFileSync(path.join(contentDir, 'index.md'), '# Home\n');
await generate({ contentDir, outputDir });
fs.cpSync(path.join(__dirname, '../assets'), path.join(outputDir, 'assets'), { recursive: true });
server = http.createServer((req, res) => {
let filePath = path.join(outputDir, req.url === '/' ? 'index.html' : req.url);
if (req.url.startsWith('/assets')) {
filePath = path.join(outputDir, req.url);
}
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
res.end('Not found');
return;
}
const ext = path.extname(filePath).slice(1);
const type = { html: 'text/html', js: 'text/javascript', css: 'text/css' }[ext] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': type });
res.end(data);
});
});
await new Promise(resolve => {
server.listen(0, () => {
port = server.address().port;
resolve();
});
});
browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] });
});
afterAll(async () => {
if (browser) await browser.close();
if (server) server.close();
fs.rmSync(tmp, { recursive: true, force: true });
});
test('sidebar opens on small screens', async () => {
const page = await browser.newPage();
await page.setViewport({ width: 500, height: 800 });
await page.goto(`http://localhost:${port}/`);
await page.waitForSelector('#sidebar-toggle');
await page.click('#sidebar-toggle');
await new Promise(r => setTimeout(r, 300));
const bodyClass = await page.evaluate(() => document.body.classList.contains('sidebar-open'));
const sidebarLeft = await page.evaluate(() => getComputedStyle(document.querySelector('.sidebar')).left);
expect(bodyClass).toBe(true);
expect(sidebarLeft).toBe('0px');
});
test('clicking outside closes sidebar on small screens', async () => {
const page = await browser.newPage();
await page.setViewport({ width: 500, height: 800 });
await page.goto(`http://localhost:${port}/`);
await page.waitForSelector('#sidebar-toggle');
await page.click('#sidebar-toggle');
await new Promise(r => setTimeout(r, 300));
await page.click('main');
await new Promise(r => setTimeout(r, 300));
const bodyClass = await page.evaluate(() => document.body.classList.contains('sidebar-open'));
expect(bodyClass).toBe(false);
});
test('sidebar toggles on large screens', async () => {
const page = await browser.newPage();
await page.setViewport({ width: 1024, height: 800 });
await page.goto(`http://localhost:${port}/`);
await page.waitForSelector('#sidebar-toggle');
await new Promise(r => setTimeout(r, 300));
let sidebarWidth = await page.evaluate(() => getComputedStyle(document.querySelector('.sidebar')).width);
expect(sidebarWidth).toBe('240px');
await page.click('#sidebar-toggle');
await new Promise(r => setTimeout(r, 300));
sidebarWidth = await page.evaluate(() => getComputedStyle(document.querySelector('.sidebar')).width);
expect(sidebarWidth).toBe('0px');
});

3475
docs/assets/lunr.js Normal file

File diff suppressed because it is too large Load Diff

160
docs/assets/theme.css Normal file
View File

@@ -0,0 +1,160 @@
:root {
--bg-color: #ffffff;
--text-color: #333333;
--sidebar-bg: #f3f3f3;
--sidebar-width: 240px;
}
[data-theme="dark"] {
--bg-color: #222222;
--text-color: #eeeeee;
--sidebar-bg: #333333;
}
body {
margin: 0;
background: var(--bg-color);
color: var(--text-color);
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.header {
display: flex;
align-items: center;
padding: 0.5rem 1rem;
background: var(--sidebar-bg);
position: sticky;
top: 0;
z-index: 1100;
}
.search-input {
margin-left: auto;
padding: 0.25rem;
}
.search-results {
display: none;
position: absolute;
right: 1rem;
top: 100%;
background: var(--bg-color);
border: 1px solid #ccc;
width: 250px;
max-height: 200px;
overflow-y: auto;
z-index: 100;
}
.search-results a {
display: block;
padding: 0.25rem;
color: var(--text-color);
text-decoration: none;
}
.search-results a:hover {
background: var(--sidebar-bg);
}
.search-results .no-results {
padding: 0.25rem;
}
.logo { text-decoration: none; color: var(--text-color); font-weight: bold; }
.sidebar-toggle,
.theme-toggle { background: none; border: none; font-size: 1.2rem; margin-right: 1rem; cursor: pointer; }
.container { display: flex; flex: 1; }
.sidebar {
width: var(--sidebar-width);
background: var(--sidebar-bg);
padding: 1rem;
box-sizing: border-box;
}
.sidebar ul { list-style: none; padding: 0; margin: 0; }
.sidebar li { margin: 0.25rem 0; }
.sidebar a { text-decoration: none; color: var(--text-color); display: block; padding: 0.25rem 0; }
.sidebar nav { font-size: 0.9rem; }
.nav-link:hover { text-decoration: underline; }
.nav-link.active { font-weight: bold; }
.nav-section summary {
list-style: none;
cursor: pointer;
position: relative;
display: flex;
align-items: center;
}
.nav-section summary::-webkit-details-marker { display: none; }
.nav-section summary::before {
content: '▸';
display: inline-block;
margin-right: 0.25rem;
transition: transform 0.2s ease;
}
.nav-section[open] > summary::before { transform: rotate(90deg); }
.nav-level { padding-left: 1rem; margin-left: 0.5rem; border-left: 2px solid #ccc; }
.sidebar ul ul { padding-left: 1rem; margin-left: 0.5rem; border-left: 2px solid #ccc; }
main {
flex: 1;
padding: 2rem;
}
.breadcrumbs a { color: var(--text-color); text-decoration: none; }
.footer {
text-align: center;
padding: 1rem;
background: var(--sidebar-bg);
position: relative;
}
.footer-links {
margin-bottom: 0.5rem;
}
.footer-links a {
margin: 0 0.5rem;
text-decoration: none;
color: var(--text-color);
}
.footer-permanent-links {
position: absolute;
right: 0.5rem;
bottom: 0.25rem;
font-size: 0.8rem;
opacity: 0.7;
}
.footer-permanent-links a {
margin-left: 0.5rem;
text-decoration: none;
color: var(--text-color);
}
.sidebar-overlay {
display: none;
}
@media (max-width: 768px) {
body.sidebar-open .sidebar-overlay {
display: block;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 999;
}
}
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: -100%;
top: 0;
height: 100%;
overflow-y: auto;
transition: none;
z-index: 1000;
}
body.sidebar-open .sidebar { left: 0; }
}
@media (min-width: 769px) {
.sidebar {
transition: width 0.2s ease;
}
body:not(.sidebar-open) .sidebar {
width: 0;
padding: 0;
overflow: hidden;
}
}

107
docs/assets/theme.js Normal file
View File

@@ -0,0 +1,107 @@
document.addEventListener('DOMContentLoaded', () => {
const sidebarToggle = document.getElementById('sidebar-toggle');
const themeToggle = document.getElementById('theme-toggle');
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
const sidebar = document.getElementById('sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay');
const root = document.documentElement;
function setTheme(theme) {
root.dataset.theme = theme;
localStorage.setItem('theme', theme);
}
const stored = localStorage.getItem('theme');
if (stored) setTheme(stored);
if (window.innerWidth > 768) {
document.body.classList.add('sidebar-open');
}
sidebarToggle?.addEventListener('click', () => {
document.body.classList.toggle('sidebar-open');
});
sidebarOverlay?.addEventListener('click', () => {
document.body.classList.remove('sidebar-open');
});
themeToggle?.addEventListener('click', () => {
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
setTheme(next);
});
// search
let lunrIndex;
let docs = [];
async function loadIndex() {
if (lunrIndex) return;
try {
const res = await fetch('/search-index.json');
const data = await res.json();
lunrIndex = lunr.Index.load(data.index);
docs = data.docs;
} catch (e) {
console.error('Search index failed to load', e);
}
}
function highlight(text, q) {
const re = new RegExp('(' + q.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&') + ')', 'gi');
return text.replace(re, '<mark>$1</mark>');
}
searchInput?.addEventListener('input', async e => {
const q = e.target.value.trim();
await loadIndex();
if (!lunrIndex || !q) {
searchResults.style.display = 'none';
searchResults.innerHTML = '';
return;
}
const matches = lunrIndex.search(q);
searchResults.innerHTML = '';
if (!matches.length) {
searchResults.innerHTML = '<div class="no-results">No matches found</div>';
searchResults.style.display = 'block';
return;
}
matches.forEach(m => {
const doc = docs.find(d => d.id === m.ref);
if (!doc) return;
const a = document.createElement('a');
a.href = doc.url;
const snippet = doc.body ? doc.body.slice(0, 160) + (doc.body.length > 160 ? '...' : '') : '';
a.innerHTML = '<strong>' + highlight(doc.title, q) + '</strong><br><small>' + highlight(snippet, q) + '</small>';
searchResults.appendChild(a);
});
searchResults.style.display = 'block';
});
document.addEventListener('click', e => {
if (!searchResults.contains(e.target) && e.target !== searchInput) {
searchResults.style.display = 'none';
}
if (
window.innerWidth <= 768 &&
document.body.classList.contains('sidebar-open') &&
sidebar &&
!sidebar.contains(e.target) &&
e.target !== sidebarToggle
) {
document.body.classList.remove('sidebar-open');
}
});
// breadcrumbs
const bc = document.getElementById('breadcrumbs');
if (bc) {
const parts = location.pathname.split('/').filter(Boolean);
let path = '';
bc.innerHTML = '<a href="/">Home</a>';
parts.forEach((p) => {
path += '/' + p;
bc.innerHTML += ' / <a href="' + path + '">' + p.replace(/-/g, ' ') + '</a>';
});
}
});

45
docs/bin/create-archivox.js Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
function copyDir(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDir(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
function main() {
const args = process.argv.slice(2);
const install = args.includes('--install');
const targetArg = args.find(a => !a.startsWith('-')) || '.';
const targetDir = path.resolve(process.cwd(), targetArg);
const templateDir = path.join(__dirname, '..', 'starter');
copyDir(templateDir, targetDir);
const pkgPath = path.join(targetDir, 'package.json');
if (fs.existsSync(pkgPath)) {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
const version = require('../package.json').version;
if (pkg.dependencies && pkg.dependencies.archivox)
pkg.dependencies.archivox = `^${version}`;
pkg.name = path.basename(targetDir);
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
}
if (install) {
execSync('npm install', { cwd: targetDir, stdio: 'inherit' });
}
console.log(`Archivox starter created at ${targetDir}`);
}
main();

15
docs/build-docs.js Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env node
const path = require('path');
const { generate } = require('./src/generator');
(async () => {
try {
const contentDir = path.join(__dirname, 'docs', 'content');
const configPath = path.join(__dirname, 'docs', 'config.yaml');
const outputDir = path.join(__dirname, '_site');
await generate({ contentDir, outputDir, configPath });
} catch (err) {
console.error(err);
process.exit(1);
}
})();

12
docs/docs/config.yaml Normal file
View File

@@ -0,0 +1,12 @@
site:
title: "SeedPass Docs"
description: "One seed to rule them all."
navigation:
search: true
footer:
links:
- text: "SeedPass"
url: "https://seedpass.me/"

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` |
@@ -70,10 +70,11 @@ Manage the entire vault for a profile.
| Action | Command | Examples |
| :--- | :--- | :--- |
| Export the vault | `vault export` | `seedpass vault export --file backup.json` |
| Import a vault | `vault import` | `seedpass vault import --file backup.json` |
| Import a vault | `vault import` | `seedpass vault import --file backup.json` *(also syncs with Nostr)* |
| Change the master password | `vault change-password` | `seedpass vault change-password` |
| Lock the vault | `vault lock` | `seedpass vault lock` |
| Show profile statistics | `vault stats` | `seedpass vault stats` |
| Reveal or back up the parent seed | `vault reveal-parent-seed` | `seedpass vault reveal-parent-seed --file backup.enc` |
### Nostr Commands
@@ -90,8 +91,9 @@ Manage profilespecific settings.
| Action | Command | Examples |
| :--- | :--- | :--- |
| Get a setting value | `config get` | `seedpass config get inactivity_timeout` |
| Set a setting value | `config set` | `seedpass config set inactivity_timeout 300` |
| Get a setting value | `config get` | `seedpass config get kdf_iterations` |
| Set a setting value | `config set` | `seedpass config set backup_interval 3600` |
| Toggle offline mode | `config toggle-offline` | `seedpass config toggle-offline` |
### Fingerprint Commands
@@ -110,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.
@@ -130,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.
@@ -154,13 +160,22 @@ $ 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.
- **`seedpass vault import`** Import a vault from an encrypted JSON file.
- **`seedpass vault import`** Import a vault from an encrypted JSON file and automatically sync via Nostr.
- **`seedpass vault change-password`** Change the master password used for encryption.
- **`seedpass vault lock`** Clear sensitive data from memory and require reauthentication.
- **`seedpass vault stats`** Display statistics about the active seed profile.
- **`seedpass vault reveal-parent-seed`** Print the parent seed or write an encrypted backup with `--file`.
### `nostr` Commands
@@ -169,9 +184,10 @@ Code: 123456
### `config` Commands
- **`seedpass config get <key>`** Retrieve a configuration value such as `inactivity_timeout`, `secret_mode`, or `auto_sync`.
- **`seedpass config set <key> <value>`** Update a configuration option. Example: `seedpass config set inactivity_timeout 300`.
- **`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`, `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.
### `fingerprint` Commands
@@ -206,5 +222,6 @@ 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 `inactivity_timeout` or `secret_mode` through the `config` commands.
- Adjust configuration values like `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `nostr_max_retries`, `nostr_retry_delay`, or `quick_unlock` through the `config` commands.
- Customize 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,6 +2,9 @@
This guide covers how to start the SeedPass API, authenticate requests, and interact with the available endpoints.
**Note:** All UI layers, including the CLI, BeeWare GUI, and future adapters, consume this REST API through service classes in `seedpass.core`. See [docs/gui_adapter.md](docs/gui_adapter.md) for more details on the GUI integration.
## Starting the API
Run `seedpass api start` from your terminal. The command prints a onetime token used for authentication:
@@ -31,13 +34,20 @@ Keep this token secret. Every request must include it in the `Authorization` hea
- `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/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/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.
- `DELETE /api/v1/relays/{idx}` Remove the relay at the given index (1based).
- `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.
@@ -96,6 +106,22 @@ curl -X PUT http://127.0.0.1:8000/api/v1/config/inactivity_timeout \
-d '{"value": 300}'
```
To raise the PBKDF2 work factor or change how often backups are written:
```bash
curl -X PUT http://127.0.0.1:8000/api/v1/config/kdf_iterations \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"value": 200000}'
curl -X PUT http://127.0.0.1:8000/api/v1/config/backup_interval \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"value": 3600}'
```
Using fewer iterations or a long interval reduces security, so adjust these values carefully.
### Toggling Secret Mode
Send both `enabled` and `delay` values to `/api/v1/secret-mode`:
@@ -115,7 +141,119 @@ Change the active seed profile via `POST /api/v1/fingerprint/select`:
curl -X POST http://127.0.0.1:8000/api/v1/fingerprint/select \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"fingerprint": "abc123"}'
-d '{"fingerprint": "abc123"}'
```
### Exporting the Vault
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 \
-H "Authorization: Bearer <token>" \
-o backup.json
```
### Importing a Vault
Restore a backup with `POST /api/v1/vault/import`. Use `-F` to upload a file:
```bash
curl -X POST http://127.0.0.1:8000/api/v1/vault/import \
-H "Authorization: Bearer <token>" \
-F file=@backup.json
```
### Locking the Vault
Clear sensitive data from memory using `/api/v1/vault/lock`:
```bash
curl -X POST http://127.0.0.1:8000/api/v1/vault/lock \
-H "Authorization: Bearer <token>"
```
### Backing Up the Parent Seed
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 "Content-Type: application/json" \
-d '{"path": "seed_backup.enc"}'
```
### Retrieving Vault Statistics
Get profile stats such as entry counts with `GET /api/v1/stats`:
```bash
curl -H "Authorization: Bearer <token>" \
http://127.0.0.1:8000/api/v1/stats
```
### Checking Notifications
Get queued messages with `GET /api/v1/notifications`:
```bash
curl -H "Authorization: Bearer <token>" \
http://127.0.0.1:8000/api/v1/notifications
```
The TUI displays these alerts in a persistent notification box for 10 seconds,
but the endpoint returns all queued messages even if they have already
disappeared from the screen.
### Changing the Master Password
Update the vault password via `POST /api/v1/change-password`:
```bash
curl -X POST http://127.0.0.1:8000/api/v1/change-password \
-H "Authorization: Bearer <token>"
```
### Verifying the Script Checksum
Check that the running script matches the stored checksum:
```bash
curl -X POST http://127.0.0.1:8000/api/v1/checksum/verify \
-H "Authorization: Bearer <token>"
```
### Updating the Script Checksum
Regenerate the stored checksum using `/api/v1/checksum/update`:
```bash
curl -X POST http://127.0.0.1:8000/api/v1/checksum/update \
-H "Authorization: Bearer <token>"
```
### Managing Relays
List, add, or remove Nostr relays:
```bash
# list
curl -H "Authorization: Bearer <token>" http://127.0.0.1:8000/api/v1/relays
# add
curl -X POST http://127.0.0.1:8000/api/v1/relays \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"url": "wss://relay.example.com"}'
# remove first relay
curl -X DELETE http://127.0.0.1:8000/api/v1/relays/1 \
-H "Authorization: Bearer <token>"
# reset to defaults
curl -X POST http://127.0.0.1:8000/api/v1/relays/reset \
-H "Authorization: Bearer <token>"
```
### Enabling CORS

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

@@ -0,0 +1,47 @@
# Index Migrations
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.
## How migrations work
When the vault loads the index, `Vault.load_index()` checks the version and
applies migrations defined in `password_manager/migrations.py`. The
`apply_migrations()` function iterates through registered migrations until the
file reaches `LATEST_VERSION`.
If an old file lacks `schema_version`, it is treated as version 0 and upgraded
to the latest format. Attempting to load an index from a future version will
raise an error.
## Upgrading an index
1. The JSON is decrypted and parsed.
2. `apply_migrations()` applies any necessary steps, such as injecting the
`schema_version` field on first upgrade.
3. After migration, the updated index is saved back to disk.
This process happens automatically; users only need to open their vault to
upgrade older indices.
### Legacy Fernet migration
Older versions stored the vault index in a file named
`seedpass_passwords_db.json.enc` encrypted with Fernet. When opening such a
vault, SeedPass now automatically decrypts the legacy file, reencrypts it using
AESGCM, and saves it under the new name `seedpass_entries_db.json.enc`.
The original Fernet file is preserved as
`seedpass_entries_db.json.enc.fernet` and the legacy checksum file, if present,
is renamed to `seedpass_entries_db_checksum.txt.fernet`.
No additional command is required simply open your existing vault and the
conversion happens transparently.
### Parent seed backup migration
If your vault contains a `parent_seed.enc` file that was encrypted with Fernet,
SeedPass performs a similar upgrade. Upon loading the vault, the application
decrypts the old file, reencrypts it with AESGCM, and writes the result back to
`parent_seed.enc`. The legacy Fernet file is preserved as
`parent_seed.enc.fernet` so you can revert if needed. No manual steps are
required simply unlock your vault and the conversion runs automatically.

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

582
docs/docs/content/index.md Normal file
View File

@@ -0,0 +1,582 @@
# SeedPass
**SeedPass** is a secure password generator and manager built on **Bitcoin's BIP-85 standard**. It uses deterministic key derivation to generate **passwords that are never stored**, but can be easily regenerated when needed. By integrating with the **Nostr network**, SeedPass compresses your encrypted vault and splits it into 50KB chunks. Each chunk is published as a parameterised replaceable event (`kind 30071`), with a manifest (`kind 30070`) describing the snapshot and deltas (`kind 30072`) capturing changes between snapshots. This allows secure password recovery across devices without exposing your data.
[Tip Jar](https://nostrtipjar.netlify.app/?n=npub16y70nhp56rwzljmr8jhrrzalsx5x495l4whlf8n8zsxww204k8eqrvamnp)
---
**⚠️ Disclaimer**
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.
---
### 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
- [Features](#features)
- [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)
- [Usage](#usage)
- [Running the Application](#running-the-application)
- [Managing Multiple Seeds](#managing-multiple-seeds)
- [Additional Entry Types](#additional-entry-types)
- [Security Considerations](#security-considerations)
- [Contributing](#contributing)
- [License](#license)
- [Contact](#contact)
## Features
- **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.
- **Chunked Snapshots:** Encrypted vaults are compressed and split into 50KB 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.
- **Nested Managed Account Seeds:** SeedPass can derive nested managed account seeds.
- **Interactive TUI:** Navigate through menus to add, retrieve, and modify entries as well as configure Nostr settings.
- **SeedPass 2FA:** Generate TOTP codes with a real-time countdown progress bar.
- **2FA Secret Issuance & Import:** Derive new TOTP secrets from your seed or import existing `otpauth://` URIs.
- **Export 2FA Codes:** Save all stored TOTP entries to an encrypted JSON file for use with other apps.
- **Display TOTP Codes:** Show all active 2FA codes with a countdown timer.
- **Optional External Backup Location:** Configure a second directory where backups are automatically copied.
- **AutoLock on Inactivity:** Vault locks after a configurable timeout for additional security.
- **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.
- **Vault Statistics:** View counts for entries and other profile metrics.
- **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.
## 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.
*Windows only:* Install the [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and select the **C++ build tools** workload.
## Installation
### 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
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)"
```
*Install the beta branch:*
```bash
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)" _ -b beta
```
**Windows (PowerShell):**
```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))
```
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.
### Uninstall
Run the matching uninstaller if you need to remove a previous installation or clean up an old `seedpass` command:
**Linux and macOS:**
```bash
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/uninstall.sh)"
```
If the script warns that it couldn't remove an executable, delete that file manually.
**Windows (PowerShell):**
```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/uninstall.ps1'); & ([scriptblock]::create($scriptContent))
```
*Install the beta branch:*
```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)) -Branch beta
```
### Manual Setup
Follow these steps to set up SeedPass on your local machine.
### 1. Clone the Repository
First, clone the SeedPass repository from GitHub:
```bash
git clone https://github.com/PR0M3TH3AN/SeedPass.git
```
Navigate to the project directory:
```bash
cd SeedPass
```
### 2. Create a Virtual Environment
It's recommended to use a virtual environment to manage your project's dependencies. Create a virtual environment named `venv`:
```bash
python3 -m venv venv
```
### 3. Activate the Virtual Environment
Activate the virtual environment using the appropriate command for your operating system.
- **On Linux and macOS:**
```bash
source venv/bin/activate
```
- **On Windows:**
```bash
venv\Scripts\activate
```
Once activated, your terminal prompt should be prefixed with `(venv)` indicating that the virtual environment is active.
### 4. Install Dependencies
Install the required Python packages and build dependencies using `pip`.
When upgrading pip, use `python -m pip` inside the virtual environment so that pip can update itself cleanly:
```bash
python -m pip install --upgrade pip
python -m pip install -r src/requirements.txt
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:
```bash
sudo apt-get install xclip
```
## Quick Start
After installing dependencies, activate your virtual environment and install
the package so the `seedpass` command is available, then launch SeedPass and
create a backup:
```bash
# Start the application
seedpass
# Export your index
seedpass vault export --file "~/seedpass_backup.json"
# Later you can restore it
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
# Sort or filter the list view
seedpass list --sort label
seedpass list --filter totp
# Use the **Settings** menu to configure an extra backup directory
# on an external drive.
```
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).
### 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.
```json
{
"schema_version": 2,
"entries": {
"0": {
"label": "example.com",
"length": 8,
"type": "password",
"notes": ""
}
}
}
```
## Usage
After successfully installing the dependencies, launch the interactive TUI with:
```bash
seedpass
```
You can also run directly from the repository using:
```bash
python src/main.py
```
You can explore other CLI commands using:
```bash
seedpass --help
```
For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). The REST API is described in [docs/api_reference.md](docs/api_reference.md).
### Running the Application
1. **Start the Application:**
```bash
seedpass
```
*(or `python src/main.py` if running directly from the repository)*
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.
- **Enter Your Password:** This password is crucial as it is used to encrypt and decrypt your parent seed and seed index data.
- **Select an Option:** Navigate through the menu by entering the number corresponding to your desired action.
Example menu:
```bash
Select an option:
1. Add Entry
2. Retrieve Entry
3. Search Entries
4. List Entries
5. Modify an Existing Entry
6. 2FA Codes
7. Settings
Enter your choice (1-7) or press Enter to exit:
```
When choosing **Add Entry**, you can now select from:
- **Password**
- **2FA (TOTP)**
- **SSH Key**
- **Seed Phrase**
- **Nostr Key Pair**
- **PGP Key**
- **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)**.
2. Pick **Make 2FA** to derive a new secret from your seed or **Import 2FA** to paste an existing `otpauth://` URI or secret.
3. Provide a label for the account (for example, `GitHub`).
4. SeedPass automatically chooses the next available derivation index when deriving.
5. Optionally specify the TOTP period and digit count.
6. SeedPass displays the URI and secret, along with a QR code you can scan to import it into your authenticator app.
### Modifying a 2FA Entry
1. From the main menu choose **Modify an Existing Entry** and enter the index of the 2FA code you want to edit.
2. SeedPass will show the current label, period, digit count, and archived status.
3. Enter new values or press **Enter** to keep the existing settings.
4. When retrieving a 2FA entry you can press **E** to edit the label, period or digit count, or **A** to archive/unarchive it.
5. The updated entry is saved back to your encrypted vault.
6. Archived entries are hidden from lists but can be viewed or restored from the **List Archived** menu.
7. When editing an archived entry you'll be prompted to restore it after saving your changes.
### 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.
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.
### Additional Entry Types
SeedPass supports storing more than just passwords and 2FA secrets. You can also create entries for:
- **SSH Key** deterministically derive an Ed25519 key pair for servers or git hosting platforms.
- **Seed Phrase** store only the BIP-85 index and word count. The mnemonic is regenerated on demand.
- **PGP Key** derive an OpenPGP key pair from your master seed.
- **Nostr Key Pair** store the index used to derive an `npub`/`nsec` pair for Nostr clients.
When you retrieve one of these entries, SeedPass can display QR codes for the
keys. The `npub` is wrapped in the `nostr:` URI scheme so any client can scan
it, while the `nsec` QR is shown only after a security warning.
- **Key/Value** store a simple key and value for miscellaneous secrets or configuration data.
- **Managed Account** derive a child seed under the current profile. Loading a managed account switches to a nested profile and the header shows `<parent_fp> > Managed Account > <child_fp>`. Press Enter on the main menu to return to the parent profile.
The table below summarizes the extra fields stored for each entry type. Every
entry includes a `label`, while only password entries track a `url`.
| Entry Type | Extra Fields |
|---------------|---------------------------------------------------------------------------------------------------------------------------------------|
| Password | `username`, `url`, `length`, `archived`, optional `notes`, optional `custom_fields` (may include hidden fields), optional `tags` |
| 2FA (TOTP) | `index` or `secret`, `period`, `digits`, `archived`, optional `notes`, optional `tags` |
| SSH Key | `index`, `archived`, optional `notes`, optional `tags` |
| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes`, optional `tags` |
| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes`, optional `tags` |
| Nostr Key Pair| `index`, `archived`, optional `notes`, 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
SeedPass allows you to manage multiple seed profiles (previously referred to as "fingerprints"). Each seed profile has its own parent seed and associated data, enabling you to compartmentalize your passwords.
- **Add a New Seed Profile:**
- From the main menu, select **Settings** then **Profiles** and choose "Add a New Seed Profile".
- 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:**
- From the **Profiles** menu, select "Switch Seed Profile".
- You'll see a list of available seed profiles.
- Enter the number corresponding to the seed profile you wish to switch to.
- Enter the master password associated with that seed profile.
- **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.
### 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:
```
wss://relay.snort.social
wss://nostr.oxtr.dev
wss://relay.primal.net
```
You can manage your relays and sync with Nostr from the **Settings** menu:
1. From the main menu choose `6` (**Settings**).
2. Select `2` (**Nostr**) to open the Nostr submenu.
3. Choose `1` to back up your encrypted index to Nostr.
4. Select `2` to restore the index from Nostr.
5. Choose `3` to view your current relays.
6. Select `4` to add a new relay URL.
7. Choose `5` to remove a relay by number.
8. Select `6` to reset to the default relay list.
9. Choose `7` to display your Nostr public key.
10. Select `8` to return to the Settings menu.
Back in the Settings menu you can:
* Select `3` to change your master password.
* Choose `4` to verify the script checksum.
* Select `5` to generate a new script checksum.
* Choose `6` to back up the parent seed.
* Select `7` to export the database to an encrypted file.
* Choose `8` to import a database from a backup file. This also performs a Nostr sync automatically.
* Select `9` to export all 2FA codes.
* Choose `10` to set an additional backup location. A backup is created
immediately after the directory is configured.
* Select `11` to change the inactivity timeout.
* Choose `12` to lock the vault and require re-entry of your password.
* Select `13` to view seed profile stats. The summary lists counts for
passwords, TOTP codes, SSH keys, seed phrases, and PGP keys. It also shows
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.
* 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:
```bash
pip install -r src/requirements.txt
pytest -vv
```
`test_fuzz_key_derivation.py` uses Hypothesis to generate random passwords,
seeds and configuration data. It performs round-trip encryption tests with the
`EncryptionManager` to catch edge cases automatically. These fuzz tests run in
CI alongside the rest of the suite.
### Exploring Nostr Index Size Limits
`test_nostr_index_size.py` demonstrates how SeedPass rotates snapshots after too many delta events.
Each chunk is limited to 50KB, so the test gradually grows the vault to observe
when a new snapshot is triggered. Use the `NOSTR_TEST_DELAY` environment
variable to control the delay between publishes when experimenting with large vaults.
```bash
pytest -vv -s -n 0 src/tests/test_nostr_index_size.py --desktop --max-entries=1000
```
### Generating a Test Profile
Use the helper script below to populate a profile with sample entries for testing:
```bash
python scripts/generate_test_profile.py --profile demo_profile --count 100
```
The script determines the fingerprint from the generated seed and stores the
vault under `~/.seedpass/tests/<fingerprint>`. SeedPass only discovers profiles
inside `~/.seedpass/`, so copy the fingerprint directory out of the `tests`
subfolder (or adjust `APP_DIR` in `constants.py`) if you want to use the
generated seed 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. Synchronization also runs
in the background after unlocking or when switching profiles.
### Automatically Updating the Script Checksum
SeedPass stores a SHA-256 checksum for the main program in `~/.seedpass/seedpass_script_checksum.txt`.
To keep this value in sync with the source code, install the prepush git hook:
```bash
pre-commit install -t pre-push
```
After running this command, every `git push` will execute `scripts/update_checksum.py`,
updating the checksum file automatically.
If the checksum file is missing, generate it manually:
```bash
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
pytest --cov=src src/tests
python -m mutmut run --paths-to-mutate src --tests-dir src/tests --runner "python -m pytest -q" --use-coverage --no-progress
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.
## Security Considerations
**Important:** The password you use to encrypt your parent seed is also required to decrypt the seed index data retrieved from Nostr. **It is imperative to remember this password** and be sure to use it with the same seed, as losing it means you won't be able to access your stored index. Secure your 12-word seed **and** your master password.
- **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.
- **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.
- **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
Contributions are welcome! If you have suggestions for improvements, bug fixes, or new features, please follow these steps:
1. **Fork the Repository:** Click the "Fork" button on the top right of the repository page.
2. **Create a Branch:** Create a new branch for your feature or bugfix.
```bash
git checkout -b feature/YourFeatureName
```
3. **Commit Your Changes:** Make your changes and commit them with clear messages.
```bash
git commit -m "Add feature X"
```
4. **Push to GitHub:** Push your changes to your forked repository.
```bash
git push origin feature/YourFeatureName
```
5. **Create a Pull Request:** Navigate to the original repository and create a pull request describing your changes.
## License
This project is licensed under the [MIT License](LICENSE). See the [LICENSE](LICENSE) file for details.
## Contact
For any questions, suggestions, or support, please open an issue on the [GitHub repository](https://github.com/PR0M3TH3AN/SeedPass/issues) or contact the maintainer directly on [Nostr](https://primal.net/p/npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx).
---
*Stay secure and keep your passwords safe with SeedPass!*
---

11
docs/docs/package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "docs",
"private": true,
"scripts": {
"dev": "eleventy --serve",
"build": "node node_modules/archivox/src/generator/index.js"
},
"dependencies": {
"archivox": "^1.0.0"
}
}

View File

@@ -1,25 +0,0 @@
# Index Migrations
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.
## How migrations work
When the vault loads the index, `Vault.load_index()` checks the version and
applies migrations defined in `password_manager/migrations.py`. The
`apply_migrations()` function iterates through registered migrations until the
file reaches `LATEST_VERSION`.
If an old file lacks `schema_version`, it is treated as version 0 and upgraded
to the latest format. Attempting to load an index from a future version will
raise an error.
## Upgrading an index
1. The JSON is decrypted and parsed.
2. `apply_migrations()` applies any necessary steps, such as injecting the
`schema_version` field on first upgrade.
3. After migration, the updated index is saved back to disk.
This process happens automatically; users only need to open their vault to
upgrade older indices.

3
docs/netlify.toml Normal file
View File

@@ -0,0 +1,3 @@
[build]
command = "node build-docs.js"
publish = "_site"

6357
docs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
docs/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "archivox",
"version": "1.0.0",
"description": "Archivox static site generator",
"scripts": {
"dev": "eleventy --serve",
"build": "node src/generator/index.js",
"test": "jest"
},
"dependencies": {
"@11ty/eleventy": "^2.0.1",
"gray-matter": "^4.0.3",
"marked": "^11.1.1",
"lunr": "^2.3.9",
"js-yaml": "^4.1.0"
},
"devDependencies": {
"jest": "^29.6.1",
"puppeteer": "^24.12.1"
},
"license": "MIT",
"bin": {
"create-archivox": "./bin/create-archivox.js"
}
}

View File

@@ -0,0 +1,7 @@
module.exports = {
onPageRendered: async ({ html, file }) => {
// Example: inject analytics script into each page
const snippet = '\n<script>console.log("Page viewed: ' + file + '")</script>';
return { html: html.replace('</body>', `${snippet}</body>`) };
}
};

View File

@@ -0,0 +1,70 @@
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
function deepMerge(target, source) {
for (const key of Object.keys(source)) {
if (
source[key] &&
typeof source[key] === 'object' &&
!Array.isArray(source[key])
) {
target[key] = deepMerge(target[key] || {}, source[key]);
} else if (source[key] !== undefined) {
target[key] = source[key];
}
}
return target;
}
function loadConfig(configPath = path.join(process.cwd(), 'config.yaml')) {
let raw = {};
if (fs.existsSync(configPath)) {
try {
raw = yaml.load(fs.readFileSync(configPath, 'utf8')) || {};
} catch (e) {
console.error(`Failed to parse ${configPath}: ${e.message}`);
process.exit(1);
}
}
const defaults = {
site: {
title: 'Archivox',
description: '',
logo: '',
favicon: ''
},
navigation: {
search: true
},
footer: {},
theme: {
name: 'minimal',
darkMode: false
},
features: {},
pluginsDir: 'plugins',
plugins: []
};
const config = deepMerge(defaults, raw);
const errors = [];
if (
!config.site ||
typeof config.site.title !== 'string' ||
!config.site.title.trim()
) {
errors.push('site.title is required in config.yaml');
}
if (errors.length) {
errors.forEach(err => console.error(`Config error: ${err}`));
process.exit(1);
}
return config;
}
module.exports = loadConfig;

View File

@@ -0,0 +1,24 @@
const path = require('path');
const fs = require('fs');
function loadPlugins(config) {
const dir = path.resolve(process.cwd(), config.pluginsDir || 'plugins');
const names = Array.isArray(config.plugins) ? config.plugins : [];
const plugins = [];
for (const name of names) {
const file = path.join(dir, name.endsWith('.js') ? name : `${name}.js`);
if (fs.existsSync(file)) {
try {
const mod = require(file);
plugins.push(mod);
} catch (e) {
console.error(`Failed to load plugin ${name}:`, e);
}
} else {
console.warn(`Plugin not found: ${file}`);
}
}
return plugins;
}
module.exports = loadPlugins;

235
docs/src/generator/index.js Normal file
View File

@@ -0,0 +1,235 @@
// Generator entry point for Archivox
const fs = require('fs');
const path = require('path');
const matter = require('gray-matter');
const lunr = require('lunr');
const marked = require('marked');
const { lexer } = marked;
const loadConfig = require('../config/loadConfig');
const loadPlugins = require('../config/loadPlugins');
function formatName(name) {
return name
.replace(/^\d+[-_]?/, '')
.replace(/\.md$/, '');
}
async function readDirRecursive(dir) {
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const res = path.resolve(dir, entry.name);
if (entry.isDirectory()) {
files.push(...await readDirRecursive(res));
} else {
files.push(res);
}
}
return files;
}
function buildNav(pages) {
const tree = {};
for (const page of pages) {
const rel = page.file.replace(/\\/g, '/');
if (rel === 'index.md') {
if (!tree.children) tree.children = [];
tree.children.push({
name: 'index.md',
children: [],
page: page.data,
path: `/${rel.replace(/\.md$/, '.html')}`,
order: page.data.order || 0
});
continue;
}
const parts = rel.split('/');
let node = tree;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const isLast = i === parts.length - 1;
const isIndex = isLast && part === 'index.md';
if (isIndex) {
node.page = page.data;
node.path = `/${rel.replace(/\.md$/, '.html')}`;
node.order = page.data.order || 0;
break;
}
if (!node.children) node.children = [];
let child = node.children.find(c => c.name === part);
if (!child) {
child = { name: part, children: [] };
node.children.push(child);
}
node = child;
if (isLast) {
node.page = page.data;
node.path = `/${rel.replace(/\.md$/, '.html')}`;
node.order = page.data.order || 0;
}
}
}
function finalize(node, isRoot = false) {
if (node.page && node.page.title) {
node.displayName = node.page.title;
} else if (node.name) {
node.displayName = formatName(node.name);
}
if (node.children) {
node.children.forEach(c => finalize(c));
node.children.sort((a, b) => {
const orderDiff = (a.order || 0) - (b.order || 0);
if (orderDiff !== 0) return orderDiff;
return (a.displayName || '').localeCompare(b.displayName || '');
});
node.isSection = node.children.length > 0;
} else {
node.isSection = false;
}
if (isRoot && node.children) {
const idx = node.children.findIndex(c => c.name === 'index.md');
if (idx > 0) {
const [first] = node.children.splice(idx, 1);
node.children.unshift(first);
}
}
}
finalize(tree, true);
return tree.children || [];
}
async function generate({ contentDir = 'content', outputDir = '_site', configPath } = {}) {
const config = loadConfig(configPath);
const plugins = loadPlugins(config);
async function runHook(name, data) {
for (const plugin of plugins) {
if (typeof plugin[name] === 'function') {
const res = await plugin[name](data);
if (res !== undefined) data = res;
}
}
return data;
}
if (!fs.existsSync(contentDir)) {
console.error(`Content directory not found: ${contentDir}`);
return;
}
const files = await readDirRecursive(contentDir);
const pages = [];
const assets = [];
const searchDocs = [];
for (const file of files) {
const rel = path.relative(contentDir, file);
if (file.endsWith('.md')) {
const srcStat = await fs.promises.stat(file);
const outPath = path.join(outputDir, rel.replace(/\.md$/, '.html'));
if (fs.existsSync(outPath)) {
const outStat = await fs.promises.stat(outPath);
if (srcStat.mtimeMs <= outStat.mtimeMs) {
continue; // skip unchanged
}
}
let raw = await fs.promises.readFile(file, 'utf8');
const mdObj = await runHook('onParseMarkdown', { file: rel, content: raw });
if (mdObj && mdObj.content) raw = mdObj.content;
const parsed = matter(raw);
const tokens = lexer(parsed.content || '');
const firstHeading = tokens.find(t => t.type === 'heading');
const title = parsed.data.title || (firstHeading ? firstHeading.text : path.basename(rel, '.md'));
const headings = tokens.filter(t => t.type === 'heading').map(t => t.text).join(' ');
const htmlBody = require('marked').parse(parsed.content || '');
const bodyText = htmlBody.replace(/<[^>]+>/g, ' ');
pages.push({ file: rel, data: { ...parsed.data, title } });
searchDocs.push({ id: rel.replace(/\.md$/, '.html'), url: '/' + rel.replace(/\.md$/, '.html'), title, headings, body: bodyText });
} else {
assets.push(rel);
}
}
const nav = buildNav(pages);
await fs.promises.mkdir(outputDir, { recursive: true });
await fs.promises.writeFile(path.join(outputDir, 'navigation.json'), JSON.stringify(nav, null, 2));
await fs.promises.writeFile(path.join(outputDir, 'config.json'), JSON.stringify(config, null, 2));
const searchIndex = lunr(function() {
this.ref('id');
this.field('title');
this.field('headings');
this.field('body');
searchDocs.forEach(d => this.add(d));
});
await fs.promises.writeFile(
path.join(outputDir, 'search-index.json'),
JSON.stringify({ index: searchIndex.toJSON(), docs: searchDocs }, null, 2)
);
const nunjucks = require('nunjucks');
const env = new nunjucks.Environment(
new nunjucks.FileSystemLoader('templates')
);
env.addGlobal('navigation', nav);
env.addGlobal('config', config);
for (const page of pages) {
const outPath = path.join(outputDir, page.file.replace(/\.md$/, '.html'));
await fs.promises.mkdir(path.dirname(outPath), { recursive: true });
const srcPath = path.join(contentDir, page.file);
const raw = await fs.promises.readFile(srcPath, 'utf8');
const { content, data } = matter(raw);
const body = require('marked').parse(content);
const pageContext = {
title: data.title || page.data.title,
content: body,
page: { url: '/' + page.file.replace(/\.md$/, '.html') }
};
let html = env.render('layout.njk', pageContext);
const result = await runHook('onPageRendered', { file: page.file, html });
if (result && result.html) html = result.html;
await fs.promises.writeFile(outPath, html);
}
for (const asset of assets) {
const srcPath = path.join(contentDir, asset);
const destPath = path.join(outputDir, asset);
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
try {
const sharp = require('sharp');
if (/(png|jpg|jpeg)/i.test(path.extname(asset))) {
await sharp(srcPath).toFile(destPath);
continue;
}
} catch (e) {
// sharp not installed, fallback
}
await fs.promises.copyFile(srcPath, destPath);
}
// Copy the main assets directory (theme, js, etc.)
// Always resolve assets relative to the Archivox package so it works
// regardless of the current working directory or config location.
const mainAssetsSrc = path.resolve(__dirname, '../../assets');
const mainAssetsDest = path.join(outputDir, 'assets');
if (fs.existsSync(mainAssetsSrc)) {
console.log(`Copying main assets from ${mainAssetsSrc} to ${mainAssetsDest}`);
// Use fs.promises.cp for modern Node.js, it's like `cp -R`
await fs.promises.cp(mainAssetsSrc, mainAssetsDest, { recursive: true });
}
}
module.exports = { generate, buildNav };
if (require.main === module) {
generate().catch(err => {
console.error(err);
process.exit(1);
});
}

6
docs/starter/config.yaml Normal file
View File

@@ -0,0 +1,6 @@
site:
title: "Archivox Docs"
description: "Simple static docs."
navigation:
search: true

View File

@@ -0,0 +1,3 @@
# Install
Run `npm install` then `npm run build` to generate your site.

View File

@@ -0,0 +1,3 @@
# Getting Started
This section helps you begin with Archivox.

View File

@@ -0,0 +1,3 @@
# Welcome to Archivox
This is your new documentation site. Start editing files in the `content/` folder.

11
docs/starter/package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "my-archivox-site",
"private": true,
"scripts": {
"dev": "eleventy --serve",
"build": "node node_modules/archivox/src/generator/index.js"
},
"dependencies": {
"archivox": "*"
}
}

23
docs/templates/layout.njk vendored Normal file
View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en" data-theme="{% if config.theme.darkMode %}dark{% else %}light{% endif %}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ title | default(config.site.title) }}</title>
<link rel="stylesheet" href="/assets/theme.css" />
</head>
<body>
{% include "partials/header.njk" %}
<div id="sidebar-overlay" class="sidebar-overlay"></div>
<div class="container">
{% include "partials/sidebar.njk" %}
<main id="content">
<nav id="breadcrumbs" class="breadcrumbs"></nav>
{{ content | safe }}
</main>
</div>
{% include "partials/footer.njk" %}
<script src="/assets/lunr.js"></script>
<script src="/assets/theme.js"></script>
</body>
</html>

14
docs/templates/partials/footer.njk vendored Normal file
View File

@@ -0,0 +1,14 @@
<footer class="footer">
{% if config.footer.links %}
<nav class="footer-links">
{% for link in config.footer.links %}
<a href="{{ link.url }}">{{ link.text }}</a>
{% endfor %}
</nav>
{% endif %}
<p>&copy; {{ config.site.title }}</p>
<div class="footer-permanent-links">
<a href="https://github.com/PR0M3TH3AN/Archivox">GitHub</a>
<a href="https://nostrtipjar.netlify.app/?n=npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx">Tip Jar</a>
</div>
</footer>

7
docs/templates/partials/header.njk vendored Normal file
View File

@@ -0,0 +1,7 @@
<header class="header">
<button id="sidebar-toggle" class="sidebar-toggle" aria-label="Toggle navigation">☰</button>
<a href="/" class="logo">{{ config.site.title }}</a>
<input id="search-input" class="search-input" type="search" placeholder="Search..." aria-label="Search" />
<button id="theme-toggle" class="theme-toggle" aria-label="Toggle dark mode">🌓</button>
<div id="search-results" class="search-results"></div>
</header>

29
docs/templates/partials/sidebar.njk vendored Normal file
View File

@@ -0,0 +1,29 @@
{% macro renderNav(items, pageUrl) %}
<ul>
{% for item in items %}
<li>
{% if item.children and item.children.length %}
{% set sectionPath = item.path | replace('index.html', '') %}
<details class="nav-section" {% if pageUrl.startsWith(sectionPath) %}open{% endif %}>
<summary>
<a href="{{ item.path }}" class="nav-link{% if item.path === pageUrl %} active{% endif %}">
{{ item.displayName or item.page.title }}
</a>
</summary>
{{ renderNav(item.children, pageUrl) }}
</details>
{% else %}
<a href="{{ item.path }}" class="nav-link{% if item.path === pageUrl %} active{% endif %}">
{{ item.displayName or item.page.title }}
</a>
{% endif %}
</li>
{% endfor %}
</ul>
{% endmacro %}
<aside class="sidebar" id="sidebar">
<nav>
{{ renderNav(navigation, page.url) }}
</nav>
</aside>

View File

@@ -1,31 +0,0 @@
from pathlib import Path
from cryptography.fernet import Fernet
from password_manager.encryption import EncryptionManager
from password_manager.vault import Vault
from password_manager.entry_management import EntryManager
from password_manager.backup import BackupManager
from constants import initialize_app
def main() -> None:
"""Demonstrate basic EntryManager usage."""
initialize_app()
key = Fernet.generate_key()
enc = EncryptionManager(key, Path("."))
vault = Vault(enc, Path("."))
backup_mgr = BackupManager(Path("."))
manager = EntryManager(vault, backup_mgr)
index = manager.add_entry(
"Example Website",
16,
username="user123",
url="https://example.com",
)
print(manager.retrieve_entry(index))
manager.list_all_entries()
if __name__ == "__main__":
main()

View File

@@ -1,15 +0,0 @@
from password_manager.manager import PasswordManager
from nostr.client import NostrClient
from constants import initialize_app
def main() -> None:
"""Show how to initialise PasswordManager with Nostr support."""
initialize_app()
manager = PasswordManager()
manager.nostr_client = NostrClient(encryption_manager=manager.encryption_manager)
# Sample actions could be called on ``manager`` here.
if __name__ == "__main__":
main()

View File

@@ -40,6 +40,8 @@
</li>
<li role="none"><a href="#disclaimer" role="menuitem">Disclaimer</a>
</li>
<li role="none"><a href="https://docs.seedpass.me/" role="menuitem">Docs</a>
</li>
</ul>
</div>
</nav>
@@ -82,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

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

@@ -2,10 +2,50 @@
name = "seedpass"
version = "0.1.0"
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"
[project.scripts]
seedpass = "seedpass.cli:app"
seedpass-gui = "seedpass_gui.app:main"
[tool.mypy]
python_version = "3.11"
strict = true
mypy_path = "src"
[tool.briefcase.app.seedpass-gui]
formal-name = "SeedPass"
description = "Deterministic password manager with a BeeWare GUI"
sources = ["src"]
requires = [
"toga-core>=0.5.2",
"colorama>=0.4.6",
"termcolor>=1.1.0",
"cryptography>=40.0.2",
"bip-utils>=2.5.0",
"bech32==1.2.0",
"coincurve>=18.0.0",
"mnemonic",
"aiohttp>=3.12.14",
"bcrypt",
"portalocker>=2.8",
"nostr-sdk>=0.43",
"websocket-client==1.7.0",
"websockets>=15.0.0",
"tomli",
"pgpy==0.6.0",
"pyotp>=2.8.0",
"pyperclip",
"qrcode>=8.2",
"typer>=0.12.3",
"fastapi>=0.116.0",
"uvicorn>=0.35.0",
"httpx>=0.28.1",
"requests>=2.32",
"python-multipart",
"orjson",
"argon2-cffi",
]
icon = "logo/png/SeedPass-Logo-24.png"

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.**

View File

@@ -1,7 +1,8 @@
aiohappyeyeballs==2.6.1
aiohttp==3.12.13
aiosignal==1.3.2
aiohttp==3.12.14
aiosignal==1.4.0
attrs==25.3.0
argon2-cffi==23.1.0
base58==2.1.1
bcrypt==4.3.0
bech32==1.2.0
@@ -19,7 +20,7 @@ cryptography==45.0.4
ecdsa==0.19.1
ed25519-blake2b==1.4.1
execnet==2.1.1
fastapi==0.116.0
fastapi==0.116.1
frozenlist==1.7.0
glob2==0.7
hypothesis==6.135.20
@@ -31,7 +32,8 @@ mnemonic==0.21
monero==1.1.1
multidict==6.6.3
mutmut==2.4.4
nostr-sdk==0.42.1
nostr-sdk==0.43.0
orjson==3.10.18
packaging==25.0
parso==0.8.4
pgpy==0.6.0
@@ -59,6 +61,7 @@ toml==0.10.2
tomli==2.2.1
urllib3==2.5.0
uvicorn==0.35.0
starlette==0.47.2
httpx==0.28.1
varint==1.0.2
websocket-client==1.7.0

View File

@@ -1,10 +1,17 @@
#!/usr/bin/env python3
"""Generate a SeedPass test profile with realistic entries.
This script populates a profile directory with a variety of entry types.
This script populates a profile directory with a variety of entry types,
including key/value pairs and managed accounts.
If the profile does not exist, a new BIP-39 seed phrase is generated and
stored encrypted. A clear text copy is written to ``seed_phrase.txt`` so
it can be reused across devices.
Profiles are saved under ``~/.seedpass/tests/`` by default. SeedPass
only detects a profile automatically when it resides directly under
``~/.seedpass/``. Copy the generated fingerprint directory from the
``tests`` subfolder to ``~/.seedpass`` (or adjust ``APP_DIR`` in
``constants.py``) to use the test seed with the main application.
"""
from __future__ import annotations
@@ -31,11 +38,11 @@ consts.SCRIPT_CHECKSUM_FILE = consts.APP_DIR / "seedpass_script_checksum.txt"
from constants import APP_DIR, initialize_app
from utils.key_derivation import derive_key_from_password, derive_index_key
from password_manager.encryption import EncryptionManager
from password_manager.vault import Vault
from password_manager.config_manager import ConfigManager
from password_manager.backup import BackupManager
from password_manager.entry_management import EntryManager
from seedpass.core.encryption import EncryptionManager
from seedpass.core.vault import Vault
from seedpass.core.config_manager import ConfigManager
from seedpass.core.backup import BackupManager
from seedpass.core.entry_management import EntryManager
from nostr.client import NostrClient
from utils.fingerprint import generate_fingerprint
from utils.fingerprint_manager import FingerprintManager
@@ -46,7 +53,9 @@ import gzip
DEFAULT_PASSWORD = "testpassword"
def initialize_profile(profile_name: str) -> tuple[str, EntryManager, Path, str]:
def initialize_profile(
profile_name: str,
) -> tuple[str, EntryManager, Path, str, ConfigManager]:
"""Create or load a profile and return the seed phrase, manager, directory and fingerprint."""
initialize_app()
seed_txt = APP_DIR / f"{profile_name}_seed.txt"
@@ -96,9 +105,11 @@ def initialize_profile(profile_name: str) -> tuple[str, EntryManager, Path, str]
# Store the default password hash so the profile can be opened
hashed = bcrypt.hashpw(DEFAULT_PASSWORD.encode(), bcrypt.gensalt()).decode()
cfg_mgr.set_password_hash(hashed)
# Ensure stored iterations match the PBKDF2 work factor used above
cfg_mgr.set_kdf_iterations(100_000)
backup_mgr = BackupManager(profile_dir, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
return seed_phrase, entry_mgr, profile_dir, fingerprint
return seed_phrase, entry_mgr, profile_dir, fingerprint, cfg_mgr
def random_secret(length: int = 16) -> str:
@@ -111,7 +122,7 @@ def populate(entry_mgr: EntryManager, seed: str, count: int) -> None:
start_index = entry_mgr.get_next_index()
for i in range(count):
idx = start_index + i
kind = idx % 7
kind = idx % 9
if kind == 0:
entry_mgr.add_entry(
label=f"site-{idx}.example.com",
@@ -133,18 +144,33 @@ def populate(entry_mgr: EntryManager, seed: str, count: int) -> None:
)
elif kind == 5:
entry_mgr.add_nostr_key(f"nostr-{idx}", notes=f"Nostr key {idx}")
else:
elif kind == 6:
entry_mgr.add_pgp_key(
f"pgp-{idx}",
seed,
user_id=f"user{idx}@example.com",
notes=f"PGP key {idx}",
)
elif kind == 7:
entry_mgr.add_key_value(
f"kv-{idx}",
random_secret(20),
notes=f"Key/Value {idx}",
)
else:
entry_mgr.add_managed_account(
f"acct-{idx}",
seed,
notes=f"Managed account {idx}",
)
def main() -> None:
parser = argparse.ArgumentParser(
description="Create or extend a SeedPass test profile"
description=(
"Create or extend a SeedPass test profile (default PBKDF2 iterations:"
" 100,000)"
)
)
parser.add_argument(
"--profile",
@@ -159,7 +185,7 @@ def main() -> None:
)
args = parser.parse_args()
seed, entry_mgr, dir_path, fingerprint = initialize_profile(args.profile)
seed, entry_mgr, dir_path, fingerprint, cfg_mgr = initialize_profile(args.profile)
print(f"Using profile directory: {dir_path}")
print(f"Parent seed: {seed}")
if fingerprint:
@@ -173,6 +199,7 @@ def main() -> None:
entry_mgr.vault.encryption_manager,
fingerprint or dir_path.name,
parent_seed=seed,
config_manager=cfg_mgr,
)
asyncio.run(client.publish_snapshot(encrypted))
print("[+] Data synchronized to Nostr.")

View File

@@ -255,6 +255,15 @@ if ($LASTEXITCODE -ne 0) {
Write-Error "Dependency installation failed."
}
& "$VenvDir\Scripts\python.exe" -m pip install -e .
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to install SeedPass package"
}
Write-Info "Installing BeeWare GUI backend..."
& "$VenvDir\Scripts\python.exe" -m pip install toga-winforms
if ($LASTEXITCODE -ne 0) { Write-Warning "Failed to install GUI backend" }
# 5. Create launcher script
Write-Info "Creating launcher script..."
if (-not (Test-Path $LauncherDir)) { New-Item -ItemType Directory -Path $LauncherDir | Out-Null }
@@ -263,11 +272,29 @@ $LauncherContent = @"
@echo off
setlocal
call "%~dp0..\venv\Scripts\activate.bat"
python "%~dp0..\src\main.py" %*
"%~dp0..\venv\Scripts\python.exe" -m seedpass.cli %*
endlocal
"@
Set-Content -Path $LauncherPath -Value $LauncherContent -Force
$existingSeedpass = Get-Command seedpass -ErrorAction SilentlyContinue
if ($existingSeedpass -and $existingSeedpass.Source -ne $LauncherPath) {
Write-Warning "Another 'seedpass' command was found at $($existingSeedpass.Source)."
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")
@@ -281,4 +308,5 @@ if (($UserPath -split ';') -notcontains $LauncherDir) {
}
Write-Success "Installation/update complete!"
Write-Info "To run the application, please open a NEW terminal window and type: seedpass"
Write-Info "To launch the interactive TUI, open a NEW terminal window and run: seedpass"
Write-Info "'seedpass' resolves to: $(Get-Command seedpass | Select-Object -ExpandProperty Source)"

View File

@@ -21,6 +21,32 @@ print_info() { echo -e "\033[1;34m[INFO]\033[0m $1"; }
print_success() { echo -e "\033[1;32m[SUCCESS]\033[0m $1"; }
print_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 python3-dev libffi-dev libssl-dev xclip
elif command -v yum &>/dev/null; then
sudo yum install -y @'Development Tools' cairo cairo-devel \
gobject-introspection-devel gtk3-devel python3-devel \
libffi-devel openssl-devel xclip
elif command -v dnf &>/dev/null; then
sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y \
cairo cairo-devel gobject-introspection-devel gtk3-devel \
python3-devel libffi-devel openssl-devel xclip
elif command -v pacman &>/dev/null; then
sudo pacman -Syu --noconfirm base-devel pkgconf cairo \
gobject-introspection gtk3 python xclip
elif command -v brew &>/dev/null; then
brew install pkg-config cairo gobject-introspection gtk+3
else
print_warning "Unsupported package manager. Please install Gtk/GObject dependencies manually."
fi
}
usage() {
echo "Usage: $0 [-b | --branch <branch_name>] [-h | --help]"
echo " -b, --branch Specify the git branch to install (default: main)"
@@ -84,15 +110,12 @@ 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
print_info "Checking for Gtk development libraries..."
if ! python3 -c "import gi" &>/dev/null; then
print_warning "Gtk introspection bindings not found. Installing dependencies..."
install_dependencies
else
print_info "Gtk bindings already available."
fi
# 4. Clone or update the repository
@@ -119,21 +142,54 @@ main() {
print_info "Installing/updating Python dependencies from src/requirements.txt..."
pip install --upgrade pip
pip install -r src/requirements.txt
pip install -e .
print_info "Installing platform-specific Toga backend..."
if [ "$OS_NAME" = "Linux" ]; then
print_info "Installing toga-gtk for Linux..."
pip install toga-gtk
elif [ "$OS_NAME" = "Darwin" ]; then
print_info "Installing toga-cocoa for macOS..."
pip install toga-cocoa
fi
deactivate
# 7. Create launcher script
print_info "Creating launcher script at '$LAUNCHER_PATH'..."
mkdir -p "$LAUNCHER_DIR"
cat > "$LAUNCHER_PATH" << EOF2
cat > "$LAUNCHER_PATH" << EOF2
#!/bin/bash
source "$VENV_DIR/bin/activate"
exec python3 "$INSTALL_DIR/src/main.py" "\$@"
exec "$VENV_DIR/bin/seedpass" "\$@"
EOF2
chmod +x "$LAUNCHER_PATH"
existing_cmd=$(command -v seedpass 2>/dev/null || true)
if [ -n "$existing_cmd" ] && [ "$existing_cmd" != "$LAUNCHER_PATH" ]; then
print_warning "Another 'seedpass' command was found at $existing_cmd."
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 run the application by typing: seedpass"
print_info "You can now launch the interactive TUI by typing: seedpass"
print_info "'seedpass' resolves to: $(command -v seedpass)"
if [[ ":$PATH:" != *":$LAUNCHER_DIR:"* ]]; then
print_warning "Directory '$LAUNCHER_DIR' is not in your PATH."
print_warning "Please add 'export PATH=\"$HOME/.local/bin:$PATH\"' to your shell's config file (e.g., ~/.bashrc, ~/.zshrc) and restart your terminal."

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

41
scripts/uninstall.ps1 Normal file
View File

@@ -0,0 +1,41 @@
#
# SeedPass Uninstaller for Windows
#
# Removes the SeedPass application files but preserves user data under ~/.seedpass
$AppRootDir = Join-Path $env:USERPROFILE ".seedpass"
$InstallDir = Join-Path $AppRootDir "app"
$LauncherDir = Join-Path $InstallDir "bin"
$LauncherName = "seedpass.cmd"
function Write-Info { param([string]$Message) Write-Host "[INFO] $Message" -ForegroundColor Cyan }
function Write-Success { param([string]$Message) Write-Host "[SUCCESS] $Message" -ForegroundColor Green }
function Write-Warning { param([string]$Message) Write-Host "[WARNING] $Message" -ForegroundColor Yellow }
function Write-Error { param([string]$Message) Write-Host "[ERROR] $Message" -ForegroundColor Red }
Write-Info "Removing SeedPass installation..."
if (Test-Path $InstallDir) {
Remove-Item -Recurse -Force $InstallDir
Write-Info "Deleted '$InstallDir'"
} else {
Write-Info "Installation directory not found."
}
$LauncherPath = Join-Path $LauncherDir $LauncherName
if (Test-Path $LauncherPath) {
Remove-Item -Force $LauncherPath
Write-Info "Removed launcher '$LauncherPath'"
} else {
Write-Info "Launcher not found."
}
Write-Info "Attempting to uninstall any global 'seedpass' package with pip..."
try {
pip uninstall -y seedpass | Out-Null
} catch {
try { pip3 uninstall -y seedpass | Out-Null } catch {}
}
Write-Success "SeedPass uninstalled. User data under '$AppRootDir' was left intact."

70
scripts/uninstall.sh Normal file
View File

@@ -0,0 +1,70 @@
#!/bin/bash
#
# SeedPass Uninstaller for Linux and macOS
#
# Removes the SeedPass application files but preserves user data under ~/.seedpass
set -e
APP_ROOT_DIR="$HOME/.seedpass"
INSTALL_DIR="$APP_ROOT_DIR/app"
LAUNCHER_PATH="$HOME/.local/bin/seedpass"
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"; }
# Remove any stale 'seedpass' executables that may still be on the PATH.
remove_stale_executables() {
IFS=':' read -ra DIRS <<< "$PATH"
for dir in "${DIRS[@]}"; do
candidate="$dir/seedpass"
if [ -f "$candidate" ] && [ "$candidate" != "$LAUNCHER_PATH" ]; then
print_info "Removing old executable '$candidate'..."
if rm -f "$candidate"; then
rm_status=0
else
rm_status=$?
fi
if [ $rm_status -ne 0 ] && [ -f "$candidate" ]; then
print_warning "Failed to remove $candidate try deleting it manually"
fi
fi
done
}
main() {
if [ -d "$INSTALL_DIR" ]; then
print_info "Removing installation directory '$INSTALL_DIR'..."
rm -rf "$INSTALL_DIR"
else
print_info "Installation directory not found."
fi
if [ -f "$LAUNCHER_PATH" ]; then
print_info "Removing launcher script '$LAUNCHER_PATH'..."
rm -f "$LAUNCHER_PATH"
else
print_info "Launcher script not found."
fi
remove_stale_executables
print_info "Attempting to uninstall any global 'seedpass' package with pip..."
if command -v python3 &> /dev/null; then
python3 -m pip uninstall -y seedpass >/dev/null 2>&1 || true
elif command -v pip &> /dev/null; then
pip uninstall -y seedpass >/dev/null 2>&1 || true
fi
if command -v pipx &> /dev/null; then
pipx uninstall -y seedpass >/dev/null 2>&1 || true
fi
print_success "SeedPass uninstalled."
print_warning "User data in '$APP_ROOT_DIR' was left intact."
}
main "$@"

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,8 +9,11 @@ logger = logging.getLogger(__name__)
# -----------------------------------
# Nostr Relay Connection Settings
# -----------------------------------
MAX_RETRIES = 3 # Maximum number of retries for relay connections
RETRY_DELAY = 5 # 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
# -----------------------------------
@@ -47,9 +50,15 @@ 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
# Duration in seconds that a notification remains active
NOTIFICATION_DURATION = 10
# -----------------------------------
# Additional Constants (if any)
# -----------------------------------

View File

@@ -1,10 +1,15 @@
# 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
import signal
import getpass
import time
import argparse
import asyncio
@@ -15,9 +20,9 @@ from termcolor import colored
from utils.color_scheme import color_text
import traceback
from password_manager.manager import PasswordManager
from seedpass.core.manager import PasswordManager
from nostr.client import NostrClient
from password_manager.entry_types import EntryType
from seedpass.core.entry_types import EntryType
from constants import INACTIVITY_TIMEOUT, initialize_app
from utils.password_prompt import PasswordPromptError
from utils import (
@@ -25,8 +30,9 @@ from utils import (
copy_to_clipboard,
clear_screen,
pause,
clear_and_print_fingerprint,
clear_header_with_notification,
)
import queue
from local_bip85.bip85 import Bip85Error
@@ -100,6 +106,37 @@ def confirm_action(prompt: str) -> bool:
print(colored("Please enter 'Y' or 'N'.", "red"))
def drain_notifications(pm: PasswordManager) -> str | None:
"""Return the next queued notification message if available."""
queue_obj = getattr(pm, "notifications", None)
if queue_obj is None:
return None
try:
note = queue_obj.get_nowait()
except queue.Empty:
return None
category = getattr(note, "level", "info").lower()
if category not in ("info", "warning", "error"):
category = "info"
return color_text(getattr(note, "message", ""), category)
def get_notification_text(pm: PasswordManager) -> str:
"""Return the current notification from ``pm`` as a colored string."""
note = None
if hasattr(pm, "get_current_notification"):
try:
note = pm.get_current_notification()
except Exception:
note = None
if not note:
return ""
category = getattr(note, "level", "info").lower()
if category not in ("info", "warning", "error"):
category = "info"
return color_text(getattr(note, "message", ""), category)
def handle_switch_fingerprint(password_manager: PasswordManager):
"""
Handles switching the active fingerprint.
@@ -119,7 +156,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)):
@@ -163,7 +201,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)):
@@ -207,7 +246,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)
@@ -232,12 +272,69 @@ def handle_display_npub(password_manager: PasswordManager):
print(colored(f"Error: Failed to display npub: {e}", "red"))
def _display_live_stats(
password_manager: PasswordManager, interval: float = 1.0
) -> None:
"""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()
note = get_notification_text(password_manager)
if note:
print(note)
print(colored("Press Enter to continue.", "cyan"))
pause()
if stats_mgr is not None:
stats_mgr.reset()
return
while True:
if callable(sync_fn):
try:
sync_fn()
except Exception: # pragma: no cover - sync best effort
logging.debug("Background sync failed during stats display")
clear_screen()
display_fn()
note = get_notification_text(password_manager)
if note:
print(note)
print(colored("Press Enter to continue.", "cyan"))
sys.stdout.flush()
try:
user_input = timed_input("", interval)
if user_input.strip() == "" or user_input.strip().lower() == "b":
break
except TimeoutError:
pass
except KeyboardInterrupt:
print()
break
if stats_mgr is not None:
stats_mgr.reset()
def handle_display_stats(password_manager: PasswordManager) -> None:
"""Print seed profile statistics."""
"""Print seed profile statistics with live updates."""
try:
display_fn = getattr(password_manager, "display_stats", None)
if callable(display_fn):
display_fn()
_display_live_stats(password_manager)
except Exception as e: # pragma: no cover - display best effort
logging.error(f"Failed to display stats: {e}", exc_info=True)
print(colored(f"Error: Failed to display stats: {e}", "red"))
@@ -245,31 +342,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:
@@ -289,14 +383,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"))
@@ -313,28 +408,30 @@ def handle_retrieve_from_nostr(password_manager: PasswordManager):
Handles the action of retrieving the encrypted password index from Nostr.
"""
try:
password_manager.nostr_client.fingerprint = password_manager.current_fingerprint
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:
try:
version = int(manifest.delta_since)
deltas = asyncio.run(
password_manager.nostr_client.fetch_deltas_since(version)
)
if deltas:
encrypted = deltas[-1]
except ValueError:
pass
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
)
print(colored("Encrypted index retrieved and saved successfully.", "green"))
logging.info("Encrypted index retrieved and saved successfully from Nostr.")
else:
print(colored("Failed to retrieve data from Nostr.", "red"))
logging.error("Failed to retrieve data from Nostr.")
msg = (
f"No Nostr events found for fingerprint"
f" {password_manager.current_fingerprint}."
)
print(colored(msg, "red"))
logging.error(msg)
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"))
@@ -359,10 +456,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:
@@ -493,6 +601,39 @@ def handle_set_inactivity_timeout(password_manager: PasswordManager) -> None:
print(colored(f"Error: {e}", "red"))
def handle_set_kdf_iterations(password_manager: PasswordManager) -> None:
"""Change the PBKDF2 iteration count."""
cfg_mgr = password_manager.config_manager
if cfg_mgr is None:
print(colored("Configuration manager unavailable.", "red"))
return
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"))
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"))
except Exception as e:
logging.error(f"Error saving iterations: {e}")
print(colored(f"Error: {e}", "red"))
def handle_set_additional_backup_location(pm: PasswordManager) -> None:
"""Configure an optional second backup directory."""
cfg_mgr = pm.config_manager
@@ -543,6 +684,25 @@ 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
@@ -584,6 +744,61 @@ def handle_toggle_secret_mode(pm: PasswordManager) -> None:
print(colored(f"Error: {exc}", "red"))
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
try:
enabled = cfg.get_quick_unlock()
except Exception as exc:
logging.error(f"Error loading quick unlock setting: {exc}")
print(colored(f"Error loading settings: {exc}", "red"))
return
print(colored(f"Quick Unlock is currently {'ON' if enabled else 'OFF'}", "cyan"))
choice = input("Enable Quick Unlock? (y/n, blank to keep): ").strip().lower()
if choice in ("y", "yes"):
enabled = True
elif choice in ("n", "no"):
enabled = False
try:
cfg.set_quick_unlock(enabled)
status = "enabled" if enabled else "disabled"
print(colored(f"Quick Unlock {status}.", "green"))
except Exception as exc:
logging.error(f"Error saving quick unlock: {exc}")
print(colored(f"Error: {exc}", "red"))
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
try:
enabled = cfg.get_offline_mode()
except Exception as exc:
logging.error(f"Error loading offline mode setting: {exc}")
print(colored(f"Error loading settings: {exc}", "red"))
return
print(colored(f"Offline mode is currently {'ON' if enabled else 'OFF'}", "cyan"))
choice = input("Enable offline mode? (y/n, blank to keep): ").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
status = "enabled" if enabled else "disabled"
print(colored(f"Offline mode {status}.", "green"))
except Exception as exc:
logging.error(f"Error saving offline mode: {exc}")
print(colored(f"Error: {exc}", "red"))
def handle_profiles_menu(password_manager: PasswordManager) -> None:
"""Submenu for managing seed profiles."""
while True:
@@ -592,7 +807,7 @@ def handle_profiles_menu(password_manager: PasswordManager) -> None:
"header_fingerprint_args",
(getattr(password_manager, "current_fingerprint", None), None, None),
)
clear_and_print_fingerprint(
clear_header_with_notification(
fp,
"Main Menu > Settings > Profiles",
parent_fingerprint=parent_fp,
@@ -603,6 +818,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":
@@ -614,6 +830,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:
@@ -638,7 +856,7 @@ def handle_nostr_menu(password_manager: PasswordManager) -> None:
"header_fingerprint_args",
(getattr(password_manager, "current_fingerprint", None), None, None),
)
clear_and_print_fingerprint(
clear_header_with_notification(
fp,
"Main Menu > Settings > Nostr",
parent_fingerprint=parent_fp,
@@ -682,7 +900,7 @@ def handle_settings(password_manager: PasswordManager) -> None:
"header_fingerprint_args",
(getattr(password_manager, "current_fingerprint", None), None, None),
)
clear_and_print_fingerprint(
clear_header_with_notification(
fp,
"Main Menu > Settings",
parent_fingerprint=parent_fp,
@@ -699,10 +917,13 @@ 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 inactivity timeout", "menu"))
print(color_text("12. Lock Vault", "menu"))
print(color_text("13. Stats", "menu"))
print(color_text("14. Toggle Secret Mode", "menu"))
print(color_text("11. Set KDF iterations", "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("17. Toggle Quick Unlock", "menu"))
choice = input("Select an option or press Enter to go back: ").strip()
if choice == "1":
handle_profiles_menu(password_manager)
@@ -735,19 +956,29 @@ def handle_settings(password_manager: PasswordManager) -> None:
handle_set_additional_backup_location(password_manager)
pause()
elif choice == "11":
handle_set_inactivity_timeout(password_manager)
handle_set_kdf_iterations(password_manager)
pause()
elif choice == "12":
handle_set_inactivity_timeout(password_manager)
pause()
elif choice == "13":
password_manager.lock_vault()
print(colored("Vault locked. Please re-enter your password.", "yellow"))
password_manager.unlock_vault()
pause()
elif choice == "13":
handle_display_stats(password_manager)
password_manager.start_background_sync()
getattr(password_manager, "start_background_relay_check", lambda: None)()
pause()
elif choice == "14":
handle_display_stats(password_manager)
elif choice == "15":
handle_toggle_secret_mode(password_manager)
pause()
elif choice == "16":
handle_toggle_offline_mode(password_manager)
pause()
elif choice == "17":
handle_toggle_quick_unlock(password_manager)
pause()
elif not choice:
break
else:
@@ -773,17 +1004,17 @@ def display_menu(
7. Settings
8. List Archived
"""
display_fn = getattr(password_manager, "display_stats", None)
if callable(display_fn):
display_fn()
pause()
password_manager.start_background_sync()
getattr(password_manager, "start_background_relay_check", lambda: None)()
_display_live_stats(password_manager)
while True:
fp, parent_fp, child_fp = getattr(
password_manager,
"header_fingerprint_args",
(getattr(password_manager, "current_fingerprint", None), None, None),
)
clear_and_print_fingerprint(
clear_header_with_notification(
password_manager,
fp,
"Main Menu",
parent_fingerprint=parent_fp,
@@ -793,6 +1024,8 @@ def display_menu(
print(colored("Session timed out. Vault locked.", "yellow"))
password_manager.lock_vault()
password_manager.unlock_vault()
password_manager.start_background_sync()
getattr(password_manager, "start_background_relay_check", lambda: None)()
continue
# Periodically push updates to Nostr
if (
@@ -815,6 +1048,8 @@ def display_menu(
print(colored("Session timed out. Vault locked.", "yellow"))
password_manager.lock_vault()
password_manager.unlock_vault()
password_manager.start_background_sync()
getattr(password_manager, "start_background_relay_check", lambda: None)()
continue
password_manager.update_activity()
if not choice:
@@ -823,7 +1058,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:
@@ -836,7 +1072,7 @@ def display_menu(
None,
),
)
clear_and_print_fingerprint(
clear_header_with_notification(
fp,
"Main Menu > Add Entry",
parent_fingerprint=parent_fp,
@@ -891,7 +1127,7 @@ def display_menu(
"header_fingerprint_args",
(getattr(password_manager, "current_fingerprint", None), None, None),
)
clear_and_print_fingerprint(
clear_header_with_notification(
fp,
"Main Menu",
parent_fingerprint=parent_fp,
@@ -919,8 +1155,16 @@ def display_menu(
print(colored("Invalid choice. Please select a valid option.", "red"))
def main(argv: list[str] | None = None) -> int:
"""Entry point for the SeedPass CLI."""
def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> int:
"""Entry point for the SeedPass CLI.
Parameters
----------
argv:
Command line arguments.
fingerprint:
Optional seed profile fingerprint to select automatically.
"""
configure_logging()
initialize_app()
logger = logging.getLogger(__name__)
@@ -928,6 +1172,7 @@ def main(argv: list[str] | None = None) -> int:
load_global_config()
parser = argparse.ArgumentParser()
parser.add_argument("--fingerprint")
sub = parser.add_subparsers(dest="command")
exp = sub.add_parser("export")
@@ -948,7 +1193,7 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
try:
password_manager = PasswordManager()
password_manager = PasswordManager(fingerprint=args.fingerprint or fingerprint)
logger.info("PasswordManager initialized successfully.")
except (PasswordPromptError, Bip85Error) as e:
logger.error(f"Failed to initialize PasswordManager: {e}", exc_info=True)
@@ -1017,7 +1262,8 @@ def main(argv: list[str] | None = None) -> int:
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}")
@@ -1035,7 +1281,8 @@ def main(argv: list[str] | None = None) -> int:
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}")
@@ -1045,7 +1292,8 @@ def main(argv: list[str] | None = None) -> int:
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}")
@@ -1055,7 +1303,8 @@ def main(argv: list[str] | None = None) -> int:
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
@@ -23,4 +24,4 @@ class Manifest:
ver: int
algo: str
chunks: List[ChunkMeta]
delta_since: Optional[str] = None
delta_since: Optional[int] = None

View File

@@ -4,10 +4,11 @@ import base64
import json
import logging
import time
from typing import List, Optional, Tuple
from typing import List, Optional, Tuple, TYPE_CHECKING
import hashlib
import asyncio
import gzip
import threading
import websockets
# Imports from the nostr-sdk library
@@ -20,15 +21,20 @@ from nostr_sdk import (
Kind,
KindStandard,
Tag,
RelayUrl,
)
from datetime import timedelta
from nostr_sdk import EventId, Timestamp
from .key_manager import KeyManager as SeedPassKeyManager
from .backup_models import Manifest, ChunkMeta, KIND_MANIFEST, KIND_SNAPSHOT_CHUNK
from password_manager.encryption import EncryptionManager
from seedpass.core.encryption import EncryptionManager
from constants import MAX_RETRIES, RETRY_DELAY
from utils.file_lock import exclusive_lock
if TYPE_CHECKING: # pragma: no cover - imported for type hints
from seedpass.core.config_manager import ConfigManager
# Backwards compatibility for tests that patch these symbols
KeyManager = SeedPassKeyManager
ClientBuilder = Client
@@ -42,6 +48,9 @@ DEFAULT_RELAYS = [
"wss://relay.primal.net",
]
# Identifier prefix for replaceable manifest events
MANIFEST_ID_PREFIX = "seedpass-manifest-"
def prepare_snapshot(
encrypted_bytes: bytes, limit: int
@@ -74,6 +83,7 @@ def prepare_snapshot(
id=f"seedpass-chunk-{i:04d}",
size=len(chunk),
hash=hashlib.sha256(chunk).hexdigest(),
event_id=None,
)
)
@@ -90,10 +100,14 @@ class NostrClient:
fingerprint: str,
relays: Optional[List[str]] = None,
parent_seed: Optional[str] = None,
offline_mode: bool = False,
config_manager: Optional["ConfigManager"] = None,
) -> None:
self.encryption_manager = encryption_manager
self.fingerprint = fingerprint
self.fingerprint_dir = self.encryption_manager.fingerprint_dir
self.config_manager = config_manager
self.verbose_timing = False
if parent_seed is None:
parent_seed = self.encryption_manager.decrypt_parent_seed()
@@ -110,33 +124,76 @@ class NostrClient:
except Exception:
self.keys = Keys.generate()
self.relays = relays if relays else DEFAULT_RELAYS
self.offline_mode = offline_mode
if relays is None:
self.relays = [] if offline_mode else DEFAULT_RELAYS
else:
self.relays = relays
if self.config_manager is not None:
try:
self.verbose_timing = self.config_manager.get_verbose_timing()
except Exception:
self.verbose_timing = False
# store the last error encountered during network operations
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] = []
# Configure and initialize the nostr-sdk Client
signer = NostrSigner.keys(self.keys)
self.client = Client(signer)
self.initialize_client_pool()
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
formatted = []
for relay in self.relays:
if isinstance(relay, str):
try:
formatted.append(RelayUrl.parse(relay))
except Exception:
logger.error("Invalid relay URL: %s", relay)
else:
formatted.append(relay)
if hasattr(self.client, "add_relays"):
await self.client.add_relays(self.relays)
await self.client.add_relays(formatted)
else:
for relay in self.relays:
for relay in formatted:
await self.client.add_relay(relay)
await self.client.connect()
logger.info(f"NostrClient connected to relays: {self.relays}")
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."""
@@ -170,6 +227,8 @@ class NostrClient:
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(
@@ -190,6 +249,9 @@ class NostrClient:
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")
@@ -221,9 +283,15 @@ class NostrClient:
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:
@@ -232,15 +300,35 @@ class NostrClient:
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 = 0, delay: float = 2.0
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))
self.connect()
self.last_error = None
attempt = 0
while True:
for attempt in range(retries):
try:
result = asyncio.run(self._retrieve_json_from_nostr())
if result is not None:
@@ -248,13 +336,15 @@ class NostrClient:
except Exception as e:
self.last_error = str(e)
logger.error("Failed to retrieve events from Nostr: %s", e)
if attempt >= retries:
break
attempt += 1
time.sleep(delay)
if attempt < retries - 1:
sleep_time = delay * (2**attempt)
time.sleep(sleep_time)
return None
async def _retrieve_json_from_nostr(self) -> Optional[bytes]:
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)
@@ -288,6 +378,11 @@ class NostrClient:
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.ensure_manifest_is_current()
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")
@@ -295,7 +390,13 @@ class NostrClient:
[Tag.identifier(meta.id)]
)
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
await self.client.send_event(event)
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
manifest_json = json.dumps(
{
@@ -306,57 +407,184 @@ class NostrClient:
}
)
manifest_identifier = f"{MANIFEST_ID_PREFIX}{self.fingerprint}"
manifest_event = (
EventBuilder(Kind(KIND_MANIFEST), manifest_json)
.tags([Tag.identifier(manifest_identifier)])
.build(self.keys.public_key())
.sign_with_keys(self.keys)
)
result = await self.client.send_event(manifest_event)
manifest_id = result.id.to_hex() if hasattr(result, "id") else str(result)
self.current_manifest = manifest
self._delta_events = []
return manifest, manifest_id
await self.client.send_event(manifest_event)
with self._state_lock:
self.current_manifest = manifest
self.current_manifest_id = manifest_identifier
# Record when this snapshot was published for future delta events
self.current_manifest.delta_since = int(time.time())
self._delta_events = []
if getattr(self, "verbose_timing", False):
duration = time.perf_counter() - start
logger.info("publish_snapshot completed in %.2f seconds", duration)
return manifest, manifest_identifier
async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None:
"""Retrieve the latest manifest and all snapshot chunks."""
async def _fetch_chunks_with_retry(
self, manifest_event
) -> tuple[Manifest, list[bytes]] | None:
"""Retrieve all chunks referenced by ``manifest_event`` with retries."""
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:
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
),
)
except Exception:
return None
manifest_raw = events[0].content()
data = json.loads(manifest_raw)
manifest = Manifest(
ver=data["ver"],
algo=data["algo"],
chunks=[ChunkMeta(**c) for c in data["chunks"]],
delta_since=data.get("delta_since"),
)
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:
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}")
chunk_bytes: bytes | None = None
for attempt in range(max_retries):
cf = Filter().author(pubkey).kind(Kind(KIND_SNAPSHOT_CHUNK))
if meta.event_id:
cf = cf.id(EventId.parse(meta.event_id))
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)
self.current_manifest = manifest
ident = None
try:
tags_obj = manifest_event.tags()
ident = tags_obj.identifier()
except Exception:
tags = getattr(manifest_event, "tags", None)
if callable(tags):
tags = tags()
if tags:
tag = tags[0]
if hasattr(tag, "as_vec"):
vec = tag.as_vec()
if vec and len(vec) >= 2:
ident = vec[1]
elif isinstance(tag, (list, tuple)) and len(tag) >= 2:
ident = tag[1]
elif isinstance(tag, str):
ident = tag
with self._state_lock:
self.current_manifest = manifest
self.current_manifest_id = ident
return manifest, chunks
async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None:
"""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
pubkey = self.keys.public_key()
ident = f"{MANIFEST_ID_PREFIX}{self.fingerprint}"
f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).identifier(ident).limit(1)
timeout = timedelta(seconds=10)
try:
events = (await self.client.fetch_events(f, timeout)).to_vec()
except Exception 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,
)
if self.last_error is None:
self.last_error = "Snapshot not found on relays"
return None
async def ensure_manifest_is_current(self) -> None:
"""Verify the local manifest is up to date before publishing."""
if self.offline_mode or not self.relays:
return
await self._connect_async()
pubkey = self.keys.public_key()
ident = f"{MANIFEST_ID_PREFIX}{self.fingerprint}"
f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).identifier(ident).limit(1)
timeout = timedelta(seconds=10)
try:
events = (await self.client.fetch_events(f, timeout)).to_vec()
except Exception:
return
if not events:
return
try:
data = json.loads(events[0].content())
remote = data.get("delta_since")
if remote is not None:
remote = int(remote)
except Exception:
return
with self._state_lock:
local = self.current_manifest.delta_since if self.current_manifest else None
if remote is not None and (local is None or remote > local):
self.last_error = "Manifest out of date"
raise RuntimeError("Manifest out of date")
async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str:
"""Publish a delta event referencing a manifest."""
if self.offline_mode or not self.relays:
return ""
await self.ensure_manifest_is_current()
await self._connect_async()
content = base64.b64encode(delta_bytes).decode("utf-8")
tag = Tag.event(EventId.parse(manifest_id))
@@ -364,13 +592,41 @@ class NostrClient:
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)
if self.current_manifest is not None:
self.current_manifest.delta_since = delta_id
self._delta_events.append(delta_id)
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,
}
)
manifest_event = (
EventBuilder(Kind(KIND_MANIFEST), manifest_json)
.tags([Tag.identifier(self.current_manifest_id)])
.build(self.keys.public_key())
.sign_with_keys(self.keys)
)
self._delta_events.append(delta_id)
if manifest_event is not None:
await self.client.send_event(manifest_event)
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 = (
@@ -381,12 +637,16 @@ class NostrClient:
)
timeout = timedelta(seconds=10)
events = (await self.client.fetch_events(f, timeout)).to_vec()
events.sort(
key=lambda ev: getattr(ev, "created_at", getattr(ev, "timestamp", 0))
)
deltas: list[bytes] = []
for ev in events:
deltas.append(base64.b64decode(ev.content().encode("utf-8")))
if self.current_manifest is not None:
snap_size = sum(c.size for c in self.current_manifest.chunks)
manifest = self.get_current_manifest()
if manifest is not None:
snap_size = sum(c.size for c in manifest.chunks)
if (
len(deltas) >= self.delta_threshold
or sum(len(d) for d in deltas) > snap_size
@@ -405,10 +665,26 @@ class NostrClient:
await self.client.send_event(exp_event)
return deltas
def get_current_manifest(self) -> Manifest | None:
"""Thread-safe access to ``current_manifest``."""
with self._state_lock:
return self.current_manifest
def get_current_manifest_id(self) -> str | None:
"""Thread-safe access to ``current_manifest_id``."""
with self._state_lock:
return self.current_manifest_id
def get_delta_events(self) -> list[str]:
"""Thread-safe snapshot of pending delta event IDs."""
with self._state_lock:
return list(self._delta_events)
def close_client_pool(self) -> None:
"""Disconnects the client from all relays."""
try:
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

@@ -1,174 +0,0 @@
"""Config management for SeedPass profiles."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import List, Optional
import getpass
import bcrypt
from password_manager.vault import Vault
from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS
from constants import INACTIVITY_TIMEOUT
logger = logging.getLogger(__name__)
class ConfigManager:
"""Manage per-profile configuration encrypted on disk."""
CONFIG_FILENAME = "seedpass_config.json.enc"
def __init__(self, vault: Vault, fingerprint_dir: Path):
self.vault = vault
self.fingerprint_dir = fingerprint_dir
self.config_path = self.fingerprint_dir / self.CONFIG_FILENAME
def load_config(self, require_pin: bool = True) -> dict:
"""Load the configuration file and optionally verify a stored PIN.
Parameters
----------
require_pin: bool, default True
If True and a PIN is configured, prompt the user to enter it and
verify against the stored hash.
"""
if not self.config_path.exists():
logger.info("Config file not found; returning defaults")
return {
"relays": list(DEFAULT_NOSTR_RELAYS),
"pin_hash": "",
"password_hash": "",
"inactivity_timeout": INACTIVITY_TIMEOUT,
"additional_backup_path": "",
"secret_mode_enabled": False,
"clipboard_clear_delay": 45,
}
try:
data = self.vault.load_config()
if not isinstance(data, dict):
raise ValueError("Config data must be a dictionary")
# Ensure defaults for missing keys
data.setdefault("relays", list(DEFAULT_NOSTR_RELAYS))
data.setdefault("pin_hash", "")
data.setdefault("password_hash", "")
data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT)
data.setdefault("additional_backup_path", "")
data.setdefault("secret_mode_enabled", False)
data.setdefault("clipboard_clear_delay", 45)
# Migrate legacy hashed_password.enc if present and password_hash is missing
legacy_file = self.fingerprint_dir / "hashed_password.enc"
if not data.get("password_hash") and legacy_file.exists():
with open(legacy_file, "rb") as f:
data["password_hash"] = f.read().decode()
self.save_config(data)
if require_pin and data.get("pin_hash"):
for _ in range(3):
pin = getpass.getpass("Enter settings PIN: ").strip()
if bcrypt.checkpw(pin.encode(), data["pin_hash"].encode()):
break
print("Invalid PIN")
else:
raise ValueError("PIN verification failed")
return data
except Exception as exc:
logger.error(f"Failed to load config: {exc}")
raise
def save_config(self, config: dict) -> None:
"""Encrypt and save configuration."""
try:
self.vault.save_config(config)
except Exception as exc:
logger.error(f"Failed to save config: {exc}")
raise
def set_relays(self, relays: List[str], require_pin: bool = True) -> None:
"""Update relay list and save."""
if not relays:
raise ValueError("At least one Nostr relay must be configured")
config = self.load_config(require_pin=require_pin)
config["relays"] = relays
self.save_config(config)
def set_pin(self, pin: str) -> None:
"""Hash and store the provided PIN."""
pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
config = self.load_config(require_pin=False)
config["pin_hash"] = pin_hash
self.save_config(config)
def verify_pin(self, pin: str) -> bool:
"""Check a provided PIN against the stored hash without prompting."""
config = self.load_config(require_pin=False)
stored = config.get("pin_hash", "").encode()
if not stored:
return False
return bcrypt.checkpw(pin.encode(), stored)
def change_pin(self, old_pin: str, new_pin: str) -> bool:
"""Update the stored PIN if the old PIN is correct."""
if self.verify_pin(old_pin):
self.set_pin(new_pin)
return True
return False
def set_password_hash(self, password_hash: str) -> None:
"""Persist the bcrypt password hash in the config."""
config = self.load_config(require_pin=False)
config["password_hash"] = password_hash
self.save_config(config)
def set_inactivity_timeout(self, timeout_seconds: float) -> None:
"""Persist the inactivity timeout in seconds."""
if timeout_seconds <= 0:
raise ValueError("Timeout must be positive")
config = self.load_config(require_pin=False)
config["inactivity_timeout"] = timeout_seconds
self.save_config(config)
def get_inactivity_timeout(self) -> float:
"""Retrieve the inactivity timeout setting in seconds."""
config = self.load_config(require_pin=False)
return float(config.get("inactivity_timeout", INACTIVITY_TIMEOUT))
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)
config["additional_backup_path"] = path or ""
self.save_config(config)
def get_additional_backup_path(self) -> Optional[str]:
"""Retrieve the additional backup path if configured."""
config = self.load_config(require_pin=False)
value = config.get("additional_backup_path", "")
return value or None
def set_secret_mode_enabled(self, enabled: bool) -> None:
"""Persist the secret mode toggle."""
config = self.load_config(require_pin=False)
config["secret_mode_enabled"] = bool(enabled)
self.save_config(config)
def get_secret_mode_enabled(self) -> bool:
"""Retrieve whether secret mode is enabled."""
config = self.load_config(require_pin=False)
return bool(config.get("secret_mode_enabled", False))
def set_clipboard_clear_delay(self, delay: int) -> None:
"""Persist clipboard clear timeout in seconds."""
if delay <= 0:
raise ValueError("Delay must be positive")
config = self.load_config(require_pin=False)
config["clipboard_clear_delay"] = int(delay)
self.save_config(config)
def get_clipboard_clear_delay(self) -> int:
"""Retrieve clipboard clear delay in seconds."""
config = self.load_config(require_pin=False)
return int(config.get("clipboard_clear_delay", 45))

View File

@@ -1,483 +0,0 @@
# password_manager/encryption.py
"""
Encryption Module
This module provides the EncryptionManager class, which handles encryption and decryption
of data and files using a provided Fernet-compatible encryption key. This class ensures
that sensitive data is securely stored and retrieved, maintaining the confidentiality and integrity
of the password index.
Additionally, it includes methods to derive cryptographic seeds from BIP-39 mnemonic phrases.
Never ever ever use or suggest to 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 are not appropriate for this software's use case.
"""
import logging
import traceback
import json
import hashlib
import os
from pathlib import Path
from typing import Optional
from cryptography.fernet import Fernet, InvalidToken
from termcolor import colored
from utils.file_lock import (
exclusive_lock,
) # Ensure this utility is correctly implemented
# Instantiate the logger
logger = logging.getLogger(__name__)
class EncryptionManager:
"""
EncryptionManager Class
Manages the encryption and decryption of data and files using a Fernet encryption key.
"""
def __init__(self, encryption_key: bytes, fingerprint_dir: Path):
"""
Initializes the EncryptionManager with the provided encryption key and fingerprint directory.
Parameters:
encryption_key (bytes): The Fernet encryption 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"
self.key = encryption_key
try:
self.fernet = Fernet(self.key)
logger.debug(f"EncryptionManager initialized for {self.fingerprint_dir}")
except Exception as e:
logger.error(
f"Failed to initialize Fernet with provided encryption key: {e}"
)
print(
colored(f"Error: Failed to initialize encryption manager: {e}", "red")
)
raise
def encrypt_parent_seed(self, parent_seed: str) -> None:
"""
Encrypts and saves the parent seed to 'parent_seed.enc' within the fingerprint directory.
:param parent_seed: The BIP39 parent seed phrase.
"""
try:
# Convert seed to bytes
data = parent_seed.encode("utf-8")
# Encrypt the data
encrypted_data = self.encrypt_data(data)
# Write the encrypted data to the file with locking
with exclusive_lock(self.parent_seed_file) as fh:
fh.seek(0)
fh.truncate()
fh.write(encrypted_data)
fh.flush()
# Set file permissions to read/write for the user only
os.chmod(self.parent_seed_file, 0o600)
logger.info(
f"Parent seed encrypted and saved to '{self.parent_seed_file}'."
)
print(
colored(
f"Parent seed encrypted and saved to '{self.parent_seed_file}'.",
"green",
)
)
except Exception as e:
logger.error(f"Failed to encrypt and save parent seed: {e}", exc_info=True)
print(colored(f"Error: Failed to encrypt and save parent seed: {e}", "red"))
raise
def decrypt_parent_seed(self) -> str:
"""
Decrypts and returns the parent seed from 'parent_seed.enc' within the fingerprint directory.
:return: The decrypted parent seed.
"""
try:
parent_seed_path = self.fingerprint_dir / "parent_seed.enc"
with exclusive_lock(parent_seed_path) as fh:
fh.seek(0)
encrypted_data = fh.read()
decrypted_data = self.decrypt_data(encrypted_data)
parent_seed = decrypted_data.decode("utf-8").strip()
logger.debug(
f"Parent seed decrypted successfully from '{parent_seed_path}'."
)
return parent_seed
except InvalidToken:
logger.error(
"Invalid encryption key or corrupted data while decrypting parent seed."
)
raise
except Exception as e:
logger.error(f"Failed to decrypt parent seed: {e}", exc_info=True)
print(colored(f"Error: Failed to decrypt parent seed: {e}", "red"))
raise
def encrypt_data(self, data: bytes) -> bytes:
"""
Encrypts the given data using Fernet.
:param data: Data to encrypt.
:return: Encrypted data.
"""
try:
encrypted_data = self.fernet.encrypt(data)
logger.debug("Data encrypted successfully.")
return encrypted_data
except Exception as e:
logger.error(f"Failed to encrypt data: {e}", exc_info=True)
print(colored(f"Error: Failed to encrypt data: {e}", "red"))
raise
def decrypt_data(self, encrypted_data: bytes) -> bytes:
"""
Decrypts the provided encrypted data using the derived key.
:param encrypted_data: The encrypted data to decrypt.
:return: The decrypted data as bytes.
"""
try:
decrypted_data = self.fernet.decrypt(encrypted_data)
logger.debug("Data decrypted successfully.")
return decrypted_data
except InvalidToken:
logger.error(
"Invalid encryption key or corrupted data while decrypting data."
)
raise
except Exception as e:
logger.error(f"Failed to decrypt data: {e}", exc_info=True)
print(colored(f"Error: Failed to decrypt data: {e}", "red"))
raise
def encrypt_and_save_file(self, data: bytes, relative_path: Path) -> None:
"""
Encrypts data and saves it to a specified relative path within the fingerprint directory.
:param data: Data to encrypt.
:param relative_path: Relative path within the fingerprint directory to save the encrypted data.
"""
try:
# Define the full path
file_path = self.fingerprint_dir / relative_path
# Ensure the parent directories exist
file_path.parent.mkdir(parents=True, exist_ok=True)
# Encrypt the data
encrypted_data = self.encrypt_data(data)
# Write the encrypted data to the file with locking
with exclusive_lock(file_path) as fh:
fh.seek(0)
fh.truncate()
fh.write(encrypted_data)
fh.flush()
# Set file permissions to read/write for the user only
os.chmod(file_path, 0o600)
logger.info(f"Data encrypted and saved to '{file_path}'.")
print(colored(f"Data encrypted and saved to '{file_path}'.", "green"))
except Exception as e:
logger.error(
f"Failed to encrypt and save data to '{relative_path}': {e}",
exc_info=True,
)
print(
colored(
f"Error: Failed to encrypt and save data to '{relative_path}': {e}",
"red",
)
)
raise
def decrypt_file(self, relative_path: Path) -> bytes:
"""
Decrypts data from a specified relative path within the fingerprint directory.
:param relative_path: Relative path within the fingerprint directory to decrypt the data from.
:return: Decrypted data as bytes.
"""
try:
# Define the full path
file_path = self.fingerprint_dir / relative_path
# Read the encrypted data with locking
with exclusive_lock(file_path) as fh:
fh.seek(0)
encrypted_data = fh.read()
# Decrypt the data
decrypted_data = self.decrypt_data(encrypted_data)
logger.debug(f"Data decrypted successfully from '{file_path}'.")
return decrypted_data
except InvalidToken:
logger.error(
"Invalid encryption key or corrupted data while decrypting file."
)
raise
except Exception as e:
logger.error(
f"Failed to decrypt data from '{relative_path}': {e}", exc_info=True
)
print(
colored(
f"Error: Failed to decrypt data from '{relative_path}': {e}", "red"
)
)
raise
def save_json_data(self, data: dict, relative_path: Optional[Path] = None) -> None:
"""
Encrypts and saves the provided JSON data to the specified relative path within the fingerprint directory.
:param data: The JSON data to save.
:param relative_path: The relative path within the fingerprint directory where data will be saved.
Defaults to 'seedpass_entries_db.json.enc'.
"""
if relative_path is None:
relative_path = Path("seedpass_entries_db.json.enc")
try:
json_data = json.dumps(data, indent=4).encode("utf-8")
self.encrypt_and_save_file(json_data, relative_path)
logger.debug(f"JSON data encrypted and saved to '{relative_path}'.")
print(
colored(f"JSON data encrypted and saved to '{relative_path}'.", "green")
)
except Exception as e:
logger.error(
f"Failed to save JSON data to '{relative_path}': {e}", exc_info=True
)
print(
colored(
f"Error: Failed to save JSON data to '{relative_path}': {e}", "red"
)
)
raise
def load_json_data(self, relative_path: Optional[Path] = None) -> dict:
"""
Decrypts and loads JSON data from the specified relative path within the fingerprint directory.
:param relative_path: The relative path within the fingerprint directory from which data will be loaded.
Defaults to 'seedpass_entries_db.json.enc'.
:return: The decrypted JSON data as a dictionary.
"""
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():
logger.info(
f"Index file '{file_path}' does not exist. Initializing empty data."
)
print(
colored(
f"Info: Index file '{file_path}' not found. Initializing new password database.",
"yellow",
)
)
return {"entries": {}}
try:
decrypted_data = self.decrypt_file(relative_path)
json_content = decrypted_data.decode("utf-8").strip()
data = json.loads(json_content)
logger.debug(f"JSON data loaded and decrypted from '{file_path}': {data}")
return data
except json.JSONDecodeError as e:
logger.error(
f"Failed to decode JSON data from '{file_path}': {e}", exc_info=True
)
raise
except InvalidToken:
logger.error(
"Invalid encryption key or corrupted data while decrypting JSON data."
)
raise
except Exception as e:
logger.error(
f"Failed to load JSON data from '{file_path}': {e}", exc_info=True
)
raise
def update_checksum(self, relative_path: Optional[Path] = None) -> None:
"""
Updates the checksum file for the specified file within the fingerprint directory.
:param relative_path: The relative path within the fingerprint directory for which the checksum will be updated.
Defaults to 'seedpass_entries_db.json.enc'.
"""
if relative_path is None:
relative_path = Path("seedpass_entries_db.json.enc")
try:
file_path = self.fingerprint_dir / relative_path
logger.debug("Calculating checksum of the encrypted file bytes.")
with exclusive_lock(file_path) as fh:
fh.seek(0)
encrypted_bytes = fh.read()
checksum = hashlib.sha256(encrypted_bytes).hexdigest()
logger.debug(f"New checksum: {checksum}")
checksum_file = file_path.parent / f"{file_path.stem}_checksum.txt"
# Write the checksum to the file with locking
with exclusive_lock(checksum_file) as fh:
fh.seek(0)
fh.truncate()
fh.write(checksum.encode("utf-8"))
fh.flush()
# Set file permissions to read/write for the user only
os.chmod(checksum_file, 0o600)
logger.debug(
f"Checksum for '{file_path}' updated and written to '{checksum_file}'."
)
print(colored(f"Checksum for '{file_path}' updated.", "green"))
except Exception as e:
logger.error(
f"Failed to update checksum for '{relative_path}': {e}", exc_info=True
)
print(
colored(
f"Error: Failed to update checksum for '{relative_path}': {e}",
"red",
)
)
raise
def get_encrypted_index(self) -> Optional[bytes]:
"""
Retrieves the encrypted password index file content.
:return: Encrypted data as bytes or None if the index file does not exist.
"""
try:
relative_path = Path("seedpass_entries_db.json.enc")
if not (self.fingerprint_dir / relative_path).exists():
# Missing index is normal on first run
logger.info(
f"Index file '{relative_path}' does not exist in '{self.fingerprint_dir}'."
)
return None
file_path = self.fingerprint_dir / relative_path
with exclusive_lock(file_path) as fh:
fh.seek(0)
encrypted_data = fh.read()
logger.debug(f"Encrypted index data read from '{relative_path}'.")
return encrypted_data
except Exception as e:
logger.error(
f"Failed to read encrypted index file '{relative_path}': {e}",
exc_info=True,
)
print(
colored(
f"Error: Failed to read encrypted index file '{relative_path}': {e}",
"red",
)
)
return None
def decrypt_and_save_index_from_nostr(
self, encrypted_data: bytes, relative_path: Optional[Path] = None
) -> None:
"""
Decrypts the encrypted data retrieved from Nostr and updates the local index file.
:param encrypted_data: The encrypted data retrieved from Nostr.
:param relative_path: The relative path within the fingerprint directory to update.
Defaults to 'seedpass_entries_db.json.enc'.
"""
if relative_path is None:
relative_path = Path("seedpass_entries_db.json.enc")
try:
decrypted_data = self.decrypt_data(encrypted_data)
data = json.loads(decrypted_data.decode("utf-8"))
self.save_json_data(data, relative_path)
self.update_checksum(relative_path)
logger.info("Index file updated from Nostr 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"
)
)
# Re-raise the exception to inform the calling function of the failure
raise
def validate_seed(self, seed_phrase: str) -> bool:
"""
Validates the seed phrase format using BIP-39 standards.
:param seed_phrase: The BIP39 seed phrase to validate.
:return: True if valid, False otherwise.
"""
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
# Additional validation can be added here (e.g., word list checks)
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:
"""
Derives a cryptographic seed from a BIP39 mnemonic (seed phrase).
:param mnemonic: The BIP39 mnemonic phrase.
:param passphrase: An optional passphrase for additional security.
:return: The derived seed as 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

@@ -5,13 +5,13 @@ bip-utils>=2.5.0
bech32==1.2.0
coincurve>=18.0.0
mnemonic
aiohttp
aiohttp>=3.12.14
bcrypt
pytest>=7.0
pytest-cov
pytest-xdist
portalocker>=2.8
nostr-sdk>=0.42.1
nostr-sdk>=0.43
websocket-client==1.7.0
websockets>=15.0.0
@@ -25,8 +25,14 @@ freezegun
pyperclip
qrcode>=8.2
typer>=0.12.3
fastapi>=0.116.0
fastapi>=0.116.1
uvicorn>=0.35.0
starlette>=0.47.2
httpx>=0.28.1
requests>=2.32
python-multipart
orjson
argon2-cffi
toga-core>=0.5.2
pillow
toga-dummy>=0.5.2 # for headless GUI tests

View File

@@ -0,0 +1,30 @@
# Runtime dependencies for vendoring/packaging only
# Generated from requirements.txt with all test-only packages removed
colorama>=0.4.6
termcolor>=1.1.0
cryptography>=40.0.2
bip-utils>=2.5.0
bech32==1.2.0
coincurve>=18.0.0
mnemonic
aiohttp>=3.12.14
bcrypt
portalocker>=2.8
nostr-sdk>=0.43
websocket-client==1.7.0
websockets>=15.0.0
tomli
pgpy==0.6.0
pyotp>=2.8.0
pyperclip
qrcode>=8.2
typer>=0.12.3
fastapi>=0.116.0
uvicorn>=0.35.0
httpx>=0.28.1
requests>=2.32
python-multipart
orjson
argon2-cffi
toga-core>=0.5.2

View File

@@ -6,15 +6,17 @@ import os
import tempfile
from pathlib import Path
import secrets
import queue
from typing import Any, List, Optional
from fastapi import FastAPI, Header, HTTPException, Request
from fastapi import FastAPI, Header, HTTPException, Request, Response
import asyncio
import sys
from fastapi.middleware.cors import CORSMiddleware
from password_manager.manager import PasswordManager
from password_manager.entry_types import EntryType
from seedpass.core.manager import PasswordManager
from seedpass.core.entry_types import EntryType
from seedpass.core.api import UtilityService
app = FastAPI()
@@ -28,6 +30,20 @@ def _check_token(auth: str | None) -> None:
raise HTTPException(status_code=401, detail="Unauthorized")
def _reload_relays(relays: list[str]) -> None:
"""Reload the Nostr client with a new relay list."""
assert _pm is not None
try:
_pm.nostr_client.close_client_pool()
except Exception:
pass
try:
_pm.nostr_client.relays = relays
_pm.nostr_client.initialize_client_pool()
except Exception:
pass
def start_server(fingerprint: str | None = None) -> str:
"""Initialize global state and return the API token.
@@ -37,9 +53,10 @@ def start_server(fingerprint: str | None = None) -> str:
Optional seed profile fingerprint to select before starting the server.
"""
global _pm, _token
_pm = PasswordManager()
if fingerprint:
_pm.select_fingerprint(fingerprint)
if fingerprint is None:
_pm = PasswordManager()
else:
_pm = PasswordManager(fingerprint=fingerprint)
_token = secrets.token_urlsafe(16)
print(f"API token: {_token}")
origins = [
@@ -69,8 +86,9 @@ 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
]
@@ -101,11 +119,23 @@ def create_entry(
etype = (entry.get("type") or entry.get("kind") or "password").lower()
if etype == "password":
policy_keys = [
"include_special_chars",
"allowed_special_chars",
"special_mode",
"exclude_ambiguous",
"min_uppercase",
"min_lowercase",
"min_digits",
"min_special",
]
kwargs = {k: entry.get(k) for k in policy_keys if entry.get(k) is not None}
index = _pm.entry_manager.add_entry(
entry.get("label"),
int(entry.get("length", 12)),
entry.get("username"),
entry.get("url"),
**kwargs,
)
return {"id": index}
@@ -148,6 +178,7 @@ def create_entry(
if etype == "nostr":
index = _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),
@@ -157,6 +188,7 @@ def create_entry(
if etype == "key_value":
index = _pm.entry_manager.add_key_value(
entry.get("label"),
entry.get("key"),
entry.get("value"),
notes=entry.get("notes", ""),
)
@@ -192,16 +224,20 @@ def update_entry(
"""
_check_token(authorization)
assert _pm is not None
_pm.entry_manager.modify_entry(
entry_id,
username=entry.get("username"),
url=entry.get("url"),
notes=entry.get("notes"),
label=entry.get("label"),
period=entry.get("period"),
digits=entry.get("digits"),
value=entry.get("value"),
)
try:
_pm.entry_manager.modify_entry(
entry_id,
username=entry.get("username"),
url=entry.get("url"),
notes=entry.get("notes"),
label=entry.get("label"),
period=entry.get("period"),
digits=entry.get("digits"),
key=entry.get("key"),
value=entry.get("value"),
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"status": "ok"}
@@ -253,6 +289,7 @@ def update_config(
"additional_backup_path": cfg.set_additional_backup_path,
"secret_mode_enabled": cfg.set_secret_mode_enabled,
"clipboard_clear_delay": lambda v: cfg.set_clipboard_clear_delay(int(v)),
"quick_unlock": cfg.set_quick_unlock,
}
action = mapping.get(key)
@@ -360,6 +397,21 @@ def get_profile_stats(authorization: str | None = Header(None)) -> dict:
return _pm.get_profile_stats()
@app.get("/api/v1/notifications")
def get_notifications(authorization: str | None = Header(None)) -> List[dict]:
"""Return and clear queued notifications."""
_check_token(authorization)
assert _pm is not None
notes = []
while True:
try:
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
@@ -383,6 +435,63 @@ def get_nostr_pubkey(authorization: str | None = Header(None)) -> Any:
return {"npub": _pm.nostr_client.key_manager.get_npub()}
@app.get("/api/v1/relays")
def list_relays(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)
return {"relays": cfg.get("relays", [])}
@app.post("/api/v1/relays")
def add_relay(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
url = data.get("url")
if not url:
raise HTTPException(status_code=400, detail="Missing url")
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)
return {"status": "ok"}
@app.delete("/api/v1/relays/{idx}")
def remove_relay(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)
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)
return {"status": "ok"}
@app.post("/api/v1/relays/reset")
def reset_relays(authorization: str | None = Header(None)) -> dict[str, str]:
"""Reset relay list to defaults."""
_check_token(authorization)
assert _pm is not None
from nostr.client import DEFAULT_RELAYS
relays = list(DEFAULT_RELAYS)
_pm.config_manager.set_relays(relays, require_pin=False)
_reload_relays(relays)
return {"status": "ok"}
@app.post("/api/v1/checksum/verify")
def verify_checksum(authorization: str | None = Header(None)) -> dict[str, str]:
"""Verify the SeedPass script checksum."""
@@ -401,6 +510,18 @@ def update_checksum(authorization: str | None = Header(None)) -> dict[str, str]:
return {"status": "ok"}
@app.post("/api/v1/vault/export")
def export_vault(authorization: str | None = Header(None)):
"""Export the vault and return the encrypted file."""
_check_token(authorization)
assert _pm is not None
path = _pm.handle_export_database()
if path is None:
raise HTTPException(status_code=500, detail="Export failed")
data = Path(path).read_bytes()
return Response(content=data, media_type="application/octet-stream")
@app.post("/api/v1/vault/import")
async def import_vault(
request: Request, authorization: str | None = Header(None)
@@ -429,18 +550,61 @@ async def import_vault(
if not path:
raise HTTPException(status_code=400, detail="Missing file or path")
_pm.handle_import_database(Path(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)
) -> 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"}
@app.post("/api/v1/change-password")
def change_password(authorization: str | None = Header(None)) -> dict[str, str]:
def change_password(
data: dict, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Change the master password for the active profile."""
_check_token(authorization)
assert _pm is not None
_pm.change_password()
_pm.change_password(data.get("old", ""), data.get("new", ""))
return {"status": "ok"}
@app.post("/api/v1/password")
def generate_password(
data: dict, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Generate a password using optional policy overrides."""
_check_token(authorization)
assert _pm is not None
length = int(data.get("length", 12))
policy_keys = [
"include_special_chars",
"allowed_special_chars",
"special_mode",
"exclude_ambiguous",
"min_uppercase",
"min_lowercase",
"min_digits",
"min_special",
]
kwargs = {k: data.get(k) for k in policy_keys if data.get(k) is not None}
util = UtilityService(_pm)
password = util.generate_password(length, **kwargs)
return {"password": password}
@app.post("/api/v1/vault/lock")
def lock_vault(authorization: str | None = Header(None)) -> dict[str, str]:
"""Lock the vault and clear sensitive data from memory."""

View File

@@ -1,15 +1,37 @@
from pathlib import Path
from typing import Optional
from typing import Optional, List
import json
import typer
import sys
from password_manager.manager import PasswordManager
from password_manager.entry_types import EntryType
from seedpass.core.manager import PasswordManager
from seedpass.core.entry_types import EntryType
from seedpass.core.api import (
VaultService,
ProfileService,
SyncService,
EntryService,
ConfigService,
UtilityService,
NostrService,
ChangePasswordRequest,
UnlockRequest,
BackupParentSeedRequest,
ProfileSwitchRequest,
ProfileRemoveRequest,
)
import uvicorn
from . import api as api_module
app = typer.Typer(help="SeedPass command line interface")
import importlib
import importlib.util
import subprocess
app = typer.Typer(
help="SeedPass command line interface",
invoke_without_command=True,
)
# Global option shared across all commands
fingerprint_option = typer.Option(
@@ -39,32 +61,75 @@ app.add_typer(api_app, name="api")
def _get_pm(ctx: typer.Context) -> PasswordManager:
"""Return a PasswordManager optionally selecting a fingerprint."""
pm = PasswordManager()
fp = ctx.obj.get("fingerprint")
if fp:
# `select_fingerprint` will initialize managers
pm.select_fingerprint(fp)
if fp is None:
pm = PasswordManager()
else:
pm = PasswordManager(fingerprint=fp)
return pm
@app.callback()
def _get_services(
ctx: typer.Context,
) -> tuple[VaultService, ProfileService, SyncService]:
"""Return service layer instances for the current context."""
pm = _get_pm(ctx)
return VaultService(pm), ProfileService(pm), SyncService(pm)
def _get_entry_service(ctx: typer.Context) -> EntryService:
pm = _get_pm(ctx)
return EntryService(pm)
def _get_config_service(ctx: typer.Context) -> ConfigService:
pm = _get_pm(ctx)
return ConfigService(pm)
def _get_util_service(ctx: typer.Context) -> UtilityService:
pm = _get_pm(ctx)
return UtilityService(pm)
def _get_nostr_service(ctx: typer.Context) -> NostrService:
pm = _get_pm(ctx)
return NostrService(pm)
def _gui_backend_available() -> bool:
"""Return True if a platform-specific BeeWare backend is installed."""
for pkg in ("toga_gtk", "toga_winforms", "toga_cocoa"):
if importlib.util.find_spec(pkg) is not None:
return True
return False
@app.callback(invoke_without_command=True)
def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None:
"""SeedPass CLI entry point."""
"""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'"
"index", "--sort", help="Sort by 'index', 'label', or 'updated'"
),
kind: Optional[str] = typer.Option(None, "--kind", help="Filter by entry type"),
archived: bool = typer.Option(False, "--archived", help="Include archived"),
) -> None:
"""List entries in the vault."""
pm = _get_pm(ctx)
entries = pm.entry_manager.list_entries(
service = _get_entry_service(ctx)
entries = service.list_entries(
sort_by=sort, filter_kind=kind, include_archived=archived
)
for idx, label, username, url, is_archived in entries:
@@ -79,15 +144,25 @@ def entry_list(
@entry_app.command("search")
def entry_search(ctx: typer.Context, query: str) -> None:
def entry_search(
ctx: typer.Context,
query: str,
kind: List[str] = typer.Option(
None,
"--kind",
"-k",
help="Filter by entry kinds (can be repeated)",
),
) -> None:
"""Search entries."""
pm = _get_pm(ctx)
results = pm.entry_manager.search_entries(query)
service = _get_entry_service(ctx)
kinds = list(kind) if kind else None
results = service.search_entries(query, kinds=kinds)
if not results:
typer.echo("No matching entries found")
return
for idx, label, username, url, _arch in results:
line = f"{idx}: {label}"
for idx, label, username, url, _arch, etype in results:
line = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}"
if username:
line += f" ({username})"
if url:
@@ -98,29 +173,29 @@ def entry_search(ctx: typer.Context, query: str) -> None:
@entry_app.command("get")
def entry_get(ctx: typer.Context, query: str) -> None:
"""Retrieve a single entry's secret."""
pm = _get_pm(ctx)
matches = pm.entry_manager.search_entries(query)
service = _get_entry_service(ctx)
matches = service.search_entries(query)
if len(matches) == 0:
typer.echo("No matching entries found")
raise typer.Exit(code=1)
if len(matches) > 1:
typer.echo("Matches:")
for idx, label, username, _url, _arch in matches:
name = f"{idx}: {label}"
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 = pm.entry_manager.retrieve_entry(index)
entry = service.retrieve_entry(index)
etype = entry.get("type", entry.get("kind"))
if etype == EntryType.PASSWORD.value:
length = int(entry.get("length", 12))
password = pm.password_generator.generate_password(length, index)
password = service.generate_password(length, index)
typer.echo(password)
elif etype == EntryType.TOTP.value:
code = pm.entry_manager.get_totp_code(index, pm.parent_seed)
code = service.get_totp_code(index)
typer.echo(code)
else:
typer.echo("Unsupported entry type")
@@ -134,10 +209,49 @@ def entry_add(
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."""
pm = _get_pm(ctx)
index = pm.entry_manager.add_entry(label, length, username, url)
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))
@@ -151,10 +265,9 @@ def entry_add_totp(
digits: int = typer.Option(6, "--digits", help="Number of TOTP digits"),
) -> None:
"""Add a TOTP entry and output the otpauth URI."""
pm = _get_pm(ctx)
uri = pm.entry_manager.add_totp(
service = _get_entry_service(ctx)
uri = service.add_totp(
label,
pm.parent_seed,
index=index,
secret=secret,
period=period,
@@ -171,10 +284,9 @@ def entry_add_ssh(
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add an SSH key entry and output its index."""
pm = _get_pm(ctx)
idx = pm.entry_manager.add_ssh_key(
service = _get_entry_service(ctx)
idx = service.add_ssh_key(
label,
pm.parent_seed,
index=index,
notes=notes,
)
@@ -191,10 +303,9 @@ def entry_add_pgp(
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a PGP key entry and output its index."""
pm = _get_pm(ctx)
idx = pm.entry_manager.add_pgp_key(
service = _get_entry_service(ctx)
idx = service.add_pgp_key(
label,
pm.parent_seed,
index=index,
key_type=key_type,
user_id=user_id,
@@ -211,8 +322,8 @@ def entry_add_nostr(
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a Nostr key entry and output its index."""
pm = _get_pm(ctx)
idx = pm.entry_manager.add_nostr_key(
service = _get_entry_service(ctx)
idx = service.add_nostr_key(
label,
index=index,
notes=notes,
@@ -229,12 +340,11 @@ def entry_add_seed(
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a derived seed phrase entry and output its index."""
pm = _get_pm(ctx)
idx = pm.entry_manager.add_seed(
service = _get_entry_service(ctx)
idx = service.add_seed(
label,
pm.parent_seed,
index=index,
words_num=words,
words=words,
notes=notes,
)
typer.echo(str(idx))
@@ -244,12 +354,13 @@ def entry_add_seed(
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."""
pm = _get_pm(ctx)
idx = pm.entry_manager.add_key_value(label, value, notes=notes)
service = _get_entry_service(ctx)
idx = service.add_key_value(label, key, value, notes=notes)
typer.echo(str(idx))
@@ -261,10 +372,9 @@ def entry_add_managed_account(
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a managed account seed entry and output its index."""
pm = _get_pm(ctx)
idx = pm.entry_manager.add_managed_account(
service = _get_entry_service(ctx)
idx = service.add_managed_account(
label,
pm.parent_seed,
index=index,
notes=notes,
)
@@ -283,43 +393,50 @@ def entry_modify(
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."""
pm = _get_pm(ctx)
pm.entry_manager.modify_entry(
entry_id,
username=username,
url=url,
notes=notes,
label=label,
period=period,
digits=digits,
value=value,
)
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)
@entry_app.command("archive")
def entry_archive(ctx: typer.Context, entry_id: int) -> None:
"""Archive an entry."""
pm = _get_pm(ctx)
pm.entry_manager.archive_entry(entry_id)
service = _get_entry_service(ctx)
service.archive_entry(entry_id)
typer.echo(str(entry_id))
@entry_app.command("unarchive")
def entry_unarchive(ctx: typer.Context, entry_id: int) -> None:
"""Restore an archived entry."""
pm = _get_pm(ctx)
pm.entry_manager.restore_entry(entry_id)
service = _get_entry_service(ctx)
service.restore_entry(entry_id)
typer.echo(str(entry_id))
@entry_app.command("totp-codes")
def entry_totp_codes(ctx: typer.Context) -> None:
"""Display all current TOTP codes."""
pm = _get_pm(ctx)
pm.handle_display_totp_codes()
service = _get_entry_service(ctx)
service.display_totp_codes()
@entry_app.command("export-totp")
@@ -327,8 +444,8 @@ def entry_export_totp(
ctx: typer.Context, file: str = typer.Option(..., help="Output file")
) -> None:
"""Export all TOTP secrets to a JSON file."""
pm = _get_pm(ctx)
data = pm.entry_manager.export_totp_entries(pm.parent_seed)
service = _get_entry_service(ctx)
data = service.export_totp_entries()
Path(file).write_text(json.dumps(data, indent=2))
typer.echo(str(file))
@@ -337,9 +454,10 @@ def entry_export_totp(
def vault_export(
ctx: typer.Context, file: str = typer.Option(..., help="Output file")
) -> None:
"""Export the vault."""
pm = _get_pm(ctx)
pm.handle_export_database(Path(file))
"""Export the vault profile to an encrypted file."""
vault_service, _profile, _sync = _get_services(ctx)
data = vault_service.export_profile()
Path(file).write_bytes(data)
typer.echo(str(file))
@@ -347,32 +465,63 @@ def vault_export(
def vault_import(
ctx: typer.Context, file: str = typer.Option(..., help="Input file")
) -> None:
"""Import a vault from an encrypted JSON file."""
pm = _get_pm(ctx)
pm.handle_import_database(Path(file))
"""Import a vault profile from an encrypted file."""
vault_service, _profile, _sync = _get_services(ctx)
data = Path(file).read_bytes()
vault_service.import_profile(data)
typer.echo(str(file))
@vault_app.command("change-password")
def vault_change_password(ctx: typer.Context) -> None:
"""Change the master password used for encryption."""
pm = _get_pm(ctx)
pm.change_password()
vault_service, _profile, _sync = _get_services(ctx)
old_pw = typer.prompt("Current password", hide_input=True)
new_pw = typer.prompt("New password", hide_input=True, confirmation_prompt=True)
try:
vault_service.change_password(
ChangePasswordRequest(old_password=old_pw, new_password=new_pw)
)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo("Password updated")
@vault_app.command("unlock")
def vault_unlock(ctx: typer.Context) -> None:
"""Unlock the vault for the active profile."""
vault_service, _profile, _sync = _get_services(ctx)
password = typer.prompt("Master password", hide_input=True)
try:
resp = vault_service.unlock(UnlockRequest(password=password))
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo(f"Unlocked in {resp.duration:.2f}s")
@vault_app.command("lock")
def vault_lock(ctx: typer.Context) -> None:
"""Lock the vault and clear sensitive data from memory."""
pm = _get_pm(ctx)
pm.lock_vault()
vault_service, _profile, _sync = _get_services(ctx)
vault_service.lock()
typer.echo("locked")
@app.command("lock")
def root_lock(ctx: typer.Context) -> None:
"""Lock the vault for the active profile."""
vault_service, _profile, _sync = _get_services(ctx)
vault_service.lock()
typer.echo("locked")
@vault_app.command("stats")
def vault_stats(ctx: typer.Context) -> None:
"""Display statistics about the current seed profile."""
pm = _get_pm(ctx)
stats = pm.get_profile_stats()
vault_service, _profile, _sync = _get_services(ctx)
stats = vault_service.stats()
typer.echo(json.dumps(stats, indent=2))
@@ -384,17 +533,25 @@ def vault_reveal_parent_seed(
),
) -> None:
"""Display the parent seed and optionally write an encrypted backup file."""
pm = _get_pm(ctx)
pm.handle_backup_reveal_parent_seed(Path(file) if file else None)
vault_service, _profile, _sync = _get_services(ctx)
password = typer.prompt("Master password", hide_input=True)
vault_service.backup_parent_seed(
BackupParentSeedRequest(path=Path(file) if file else None, password=password)
)
@nostr_app.command("sync")
def nostr_sync(ctx: typer.Context) -> None:
"""Sync with configured Nostr relays."""
pm = _get_pm(ctx)
event_id = pm.sync_vault()
if event_id:
typer.echo(event_id)
_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")
@@ -402,16 +559,49 @@ def nostr_sync(ctx: typer.Context) -> None:
@nostr_app.command("get-pubkey")
def nostr_get_pubkey(ctx: typer.Context) -> None:
"""Display the active profile's npub."""
pm = _get_pm(ctx)
npub = pm.nostr_client.key_manager.get_npub()
service = _get_nostr_service(ctx)
npub = service.get_pubkey()
typer.echo(npub)
@nostr_app.command("list-relays")
def nostr_list_relays(ctx: typer.Context) -> None:
"""Display configured Nostr relays."""
service = _get_nostr_service(ctx)
relays = service.list_relays()
for i, r in enumerate(relays, 1):
typer.echo(f"{i}: {r}")
@nostr_app.command("add-relay")
def nostr_add_relay(ctx: typer.Context, url: str) -> None:
"""Add a relay URL."""
service = _get_nostr_service(ctx)
try:
service.add_relay(url)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo("Added")
@nostr_app.command("remove-relay")
def nostr_remove_relay(ctx: typer.Context, idx: int) -> None:
"""Remove a relay by index (1-based)."""
service = _get_nostr_service(ctx)
try:
service.remove_relay(idx)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo("Removed")
@config_app.command("get")
def config_get(ctx: typer.Context, key: str) -> None:
"""Get a configuration value."""
pm = _get_pm(ctx)
value = pm.config_manager.load_config(require_pin=False).get(key)
service = _get_config_service(ctx)
value = service.get(key)
if value is None:
typer.echo("Key not found")
else:
@@ -421,28 +611,18 @@ def config_get(ctx: typer.Context, key: str) -> None:
@config_app.command("set")
def config_set(ctx: typer.Context, key: str, value: str) -> None:
"""Set a configuration value."""
pm = _get_pm(ctx)
cfg = pm.config_manager
mapping = {
"inactivity_timeout": lambda v: cfg.set_inactivity_timeout(float(v)),
"secret_mode_enabled": lambda v: cfg.set_secret_mode_enabled(
v.lower() in ("1", "true", "yes", "y", "on")
),
"clipboard_clear_delay": lambda v: cfg.set_clipboard_clear_delay(int(v)),
"additional_backup_path": lambda v: cfg.set_additional_backup_path(v or None),
"relays": lambda v: cfg.set_relays(
[r.strip() for r in v.split(",") if r.strip()], require_pin=False
),
}
action = mapping.get(key)
if action is None:
typer.echo("Unknown key")
raise typer.Exit(code=1)
service = _get_config_service(ctx)
try:
action(value)
val = (
[r.strip() for r in value.split(",") if r.strip()]
if key == "relays"
else value
)
service.set(key, val)
except KeyError:
typer.echo("Unknown key")
raise typer.Exit(code=1)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
@@ -452,12 +632,15 @@ def config_set(ctx: typer.Context, key: str, value: str) -> None:
@config_app.command("toggle-secret-mode")
def config_toggle_secret_mode(ctx: typer.Context) -> None:
"""Interactively enable or disable secret mode."""
pm = _get_pm(ctx)
cfg = pm.config_manager
"""Interactively enable or disable secret mode.
When enabled, newly generated and retrieved passwords are copied to the
clipboard instead of printed to the screen.
"""
service = _get_config_service(ctx)
try:
enabled = cfg.get_secret_mode_enabled()
delay = cfg.get_clipboard_clear_delay()
enabled = service.get_secret_mode_enabled()
delay = service.get_clipboard_clear_delay()
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error loading settings: {exc}")
raise typer.Exit(code=1)
@@ -489,10 +672,7 @@ def config_toggle_secret_mode(ctx: typer.Context) -> None:
raise typer.Exit(code=1)
try:
cfg.set_secret_mode_enabled(enabled)
cfg.set_clipboard_clear_delay(delay)
pm.secret_mode_enabled = enabled
pm.clipboard_clear_delay = delay
service.set_secret_mode(enabled, delay)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
@@ -501,55 +681,133 @@ def config_toggle_secret_mode(ctx: typer.Context) -> None:
typer.echo(f"Secret mode {status}.")
@config_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}.")
@fingerprint_app.command("list")
def fingerprint_list(ctx: typer.Context) -> None:
"""List available seed profiles."""
pm = _get_pm(ctx)
for fp in pm.fingerprint_manager.list_fingerprints():
_vault, profile_service, _sync = _get_services(ctx)
for fp in profile_service.list_profiles():
typer.echo(fp)
@fingerprint_app.command("add")
def fingerprint_add(ctx: typer.Context) -> None:
"""Create a new seed profile."""
pm = _get_pm(ctx)
pm.add_new_fingerprint()
_vault, profile_service, _sync = _get_services(ctx)
profile_service.add_profile()
@fingerprint_app.command("remove")
def fingerprint_remove(ctx: typer.Context, fingerprint: str) -> None:
"""Remove a seed profile."""
pm = _get_pm(ctx)
pm.fingerprint_manager.remove_fingerprint(fingerprint)
_vault, profile_service, _sync = _get_services(ctx)
profile_service.remove_profile(ProfileRemoveRequest(fingerprint=fingerprint))
@fingerprint_app.command("switch")
def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None:
"""Switch to another seed profile."""
pm = _get_pm(ctx)
pm.select_fingerprint(fingerprint)
_vault, profile_service, _sync = _get_services(ctx)
password = typer.prompt("Master password", hide_input=True)
profile_service.switch_profile(
ProfileSwitchRequest(fingerprint=fingerprint, password=password)
)
@util_app.command("generate-password")
def generate_password(ctx: typer.Context, length: int = 24) -> None:
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."""
pm = _get_pm(ctx)
password = pm.password_generator.generate_password(length)
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)
@util_app.command("verify-checksum")
def verify_checksum(ctx: typer.Context) -> None:
"""Verify the SeedPass script checksum."""
pm = _get_pm(ctx)
pm.handle_verify_checksum()
service = _get_util_service(ctx)
service.verify_checksum()
@util_app.command("update-checksum")
def update_checksum(ctx: typer.Context) -> None:
"""Regenerate the script checksum file."""
pm = _get_pm(ctx)
pm.handle_update_script_checksum()
service = _get_util_service(ctx)
service.update_checksum()
@api_app.command("start")
@@ -573,3 +831,48 @@ def api_stop(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> N
)
except Exception as exc: # pragma: no cover - best effort
typer.echo(f"Failed to stop server: {exc}")
@app.command()
def gui() -> None:
"""Launch the BeeWare GUI.
If the platform specific backend is missing, attempt to install it and
retry launching the GUI.
"""
if not _gui_backend_available():
if sys.platform.startswith("linux"):
pkg = "toga-gtk"
elif sys.platform == "win32":
pkg = "toga-winforms"
elif sys.platform == "darwin":
pkg = "toga-cocoa"
else:
typer.echo(
f"Unsupported platform '{sys.platform}' for BeeWare GUI.",
err=True,
)
raise typer.Exit(1)
typer.echo(f"Attempting to install {pkg} for GUI support...")
try:
subprocess.check_call([sys.executable, "-m", "pip", "install", pkg])
typer.echo(f"Successfully installed {pkg}.")
except subprocess.CalledProcessError as exc:
typer.echo(f"Failed to install {pkg}: {exc}", err=True)
raise typer.Exit(1)
if not _gui_backend_available():
typer.echo(
"BeeWare GUI backend still unavailable after installation attempt.",
err=True,
)
raise typer.Exit(1)
from seedpass_gui.app import main
main()
if __name__ == "__main__":
app()

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}'")

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

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

View File

@@ -1,4 +1,4 @@
# password_manager/backup.py
# seedpass.core/backup.py
"""
Backup Manager Module
@@ -19,7 +19,7 @@ import traceback
from pathlib import Path
from termcolor import colored
from password_manager.config_manager import ConfigManager
from .config_manager import ConfigManager
from utils.file_lock import exclusive_lock
from constants import APP_DIR
@@ -54,6 +54,7 @@ class BackupManager:
self.backup_dir = self.fingerprint_dir / "backups"
self.backup_dir.mkdir(parents=True, exist_ok=True)
self.index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc"
self._last_backup_time = 0.0
logger.debug(
f"BackupManager initialized with backup directory at {self.backup_dir}"
)
@@ -71,7 +72,13 @@ class BackupManager:
)
return
timestamp = int(time.time())
now = time.time()
interval = self.config_manager.get_backup_interval()
if interval > 0 and now - self._last_backup_time < interval:
logger.info("Skipping backup due to interval throttle")
return
timestamp = int(now)
backup_filename = self.BACKUP_FILENAME_TEMPLATE.format(timestamp=timestamp)
backup_file = self.backup_dir / backup_filename
@@ -81,6 +88,7 @@ class BackupManager:
print(colored(f"Backup created successfully at '{backup_file}'.", "green"))
self._create_additional_backup(backup_file)
self._last_backup_time = now
except Exception as e:
logger.error(f"Failed to create backup: {e}", exc_info=True)
print(colored(f"Error: Failed to create backup: {e}", "red"))

View File

@@ -0,0 +1,364 @@
"""Config management for SeedPass profiles."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import List, Optional
from utils.seed_prompt import masked_input
import bcrypt
from .vault import Vault
from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS
from constants import INACTIVITY_TIMEOUT, MAX_RETRIES, RETRY_DELAY
logger = logging.getLogger(__name__)
class ConfigManager:
"""Manage per-profile configuration encrypted on disk."""
CONFIG_FILENAME = "seedpass_config.json.enc"
def __init__(self, vault: Vault, fingerprint_dir: Path):
self.vault = vault
self.fingerprint_dir = fingerprint_dir
self.config_path = self.fingerprint_dir / self.CONFIG_FILENAME
def load_config(self, require_pin: bool = True) -> dict:
"""Load the configuration file and optionally verify a stored PIN.
Parameters
----------
require_pin: bool, default True
If True and a PIN is configured, prompt the user to enter it and
verify against the stored hash.
"""
if not self.config_path.exists():
logger.info("Config file not found; returning defaults")
return {
"relays": list(DEFAULT_NOSTR_RELAYS),
"offline_mode": False,
"pin_hash": "",
"password_hash": "",
"inactivity_timeout": INACTIVITY_TIMEOUT,
"kdf_iterations": 50_000,
"kdf_mode": "pbkdf2",
"additional_backup_path": "",
"backup_interval": 0,
"secret_mode_enabled": False,
"clipboard_clear_delay": 45,
"quick_unlock": 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:
data = self.vault.load_config()
if not isinstance(data, dict):
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("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("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", 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
legacy_file = self.fingerprint_dir / "hashed_password.enc"
if not data.get("password_hash") and legacy_file.exists():
with open(legacy_file, "rb") as f:
data["password_hash"] = f.read().decode()
self.save_config(data)
if require_pin and data.get("pin_hash"):
for _ in range(3):
pin = masked_input("Enter settings PIN: ").strip()
if bcrypt.checkpw(pin.encode(), data["pin_hash"].encode()):
break
print("Invalid PIN")
else:
raise ValueError("PIN verification failed")
return data
except Exception as exc:
logger.error(f"Failed to load config: {exc}")
raise
def save_config(self, config: dict) -> None:
"""Encrypt and save configuration."""
try:
config.setdefault("backup_interval", 0)
self.vault.save_config(config)
except Exception as exc:
logger.error(f"Failed to save config: {exc}")
raise
def set_relays(self, relays: List[str], require_pin: bool = True) -> None:
"""Update relay list and save."""
if not relays:
raise ValueError("At least one Nostr relay must be configured")
config = self.load_config(require_pin=require_pin)
config["relays"] = relays
self.save_config(config)
def set_pin(self, pin: str) -> None:
"""Hash and store the provided PIN."""
pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
config = self.load_config(require_pin=False)
config["pin_hash"] = pin_hash
self.save_config(config)
def verify_pin(self, pin: str) -> bool:
"""Check a provided PIN against the stored hash without prompting."""
config = self.load_config(require_pin=False)
stored = config.get("pin_hash", "").encode()
if not stored:
return False
return bcrypt.checkpw(pin.encode(), stored)
def change_pin(self, old_pin: str, new_pin: str) -> bool:
"""Update the stored PIN if the old PIN is correct."""
if self.verify_pin(old_pin):
self.set_pin(new_pin)
return True
return False
def set_password_hash(self, password_hash: str) -> None:
"""Persist the bcrypt password hash in the config."""
config = self.load_config(require_pin=False)
config["password_hash"] = password_hash
self.save_config(config)
def set_inactivity_timeout(self, timeout_seconds: float) -> None:
"""Persist the inactivity timeout in seconds."""
if timeout_seconds <= 0:
raise ValueError("Timeout must be positive")
config = self.load_config(require_pin=False)
config["inactivity_timeout"] = timeout_seconds
self.save_config(config)
def get_inactivity_timeout(self) -> float:
"""Retrieve the inactivity timeout setting in seconds."""
config = self.load_config(require_pin=False)
return float(config.get("inactivity_timeout", INACTIVITY_TIMEOUT))
def set_kdf_iterations(self, iterations: int) -> None:
"""Persist the PBKDF2 iteration count in the config."""
if iterations <= 0:
raise ValueError("Iterations must be positive")
config = self.load_config(require_pin=False)
config["kdf_iterations"] = int(iterations)
self.save_config(config)
def get_kdf_iterations(self) -> int:
"""Retrieve the PBKDF2 iteration count."""
config = self.load_config(require_pin=False)
return int(config.get("kdf_iterations", 50_000))
def set_kdf_mode(self, mode: str) -> None:
"""Persist the key derivation function mode."""
if mode not in ("pbkdf2", "argon2"):
raise ValueError("kdf_mode must be 'pbkdf2' or 'argon2'")
config = self.load_config(require_pin=False)
config["kdf_mode"] = mode
self.save_config(config)
def get_kdf_mode(self) -> str:
"""Retrieve the configured key derivation function."""
config = self.load_config(require_pin=False)
return config.get("kdf_mode", "pbkdf2")
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)
config["additional_backup_path"] = path or ""
self.save_config(config)
def get_additional_backup_path(self) -> Optional[str]:
"""Retrieve the additional backup path if configured."""
config = self.load_config(require_pin=False)
value = config.get("additional_backup_path", "")
return value or None
def set_secret_mode_enabled(self, enabled: bool) -> None:
"""Persist the secret mode toggle."""
config = self.load_config(require_pin=False)
config["secret_mode_enabled"] = bool(enabled)
self.save_config(config)
def set_offline_mode(self, enabled: bool) -> None:
"""Persist the offline mode toggle."""
config = self.load_config(require_pin=False)
config["offline_mode"] = bool(enabled)
self.save_config(config)
def get_secret_mode_enabled(self) -> bool:
"""Retrieve whether secret mode is enabled."""
config = self.load_config(require_pin=False)
return bool(config.get("secret_mode_enabled", False))
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))
def set_clipboard_clear_delay(self, delay: int) -> None:
"""Persist clipboard clear timeout in seconds."""
if delay <= 0:
raise ValueError("Delay must be positive")
config = self.load_config(require_pin=False)
config["clipboard_clear_delay"] = int(delay)
self.save_config(config)
def get_clipboard_clear_delay(self) -> int:
"""Retrieve clipboard clear delay in seconds."""
config = self.load_config(require_pin=False)
return int(config.get("clipboard_clear_delay", 45))
def set_backup_interval(self, interval: int | float) -> None:
"""Persist the minimum interval in seconds between automatic backups."""
if interval < 0:
raise ValueError("Interval cannot be negative")
config = self.load_config(require_pin=False)
config["backup_interval"] = interval
self.save_config(config)
def get_backup_interval(self) -> float:
"""Retrieve the backup interval in seconds."""
config = self.load_config(require_pin=False)
return float(config.get("backup_interval", 0))
# Password policy settings
def get_password_policy(self) -> "PasswordPolicy":
"""Return the password complexity policy."""
from .password_generation import PasswordPolicy
cfg = self.load_config(require_pin=False)
return PasswordPolicy(
min_uppercase=int(cfg.get("min_uppercase", 2)),
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:
cfg = self.load_config(require_pin=False)
cfg["min_uppercase"] = int(count)
self.save_config(cfg)
def set_min_lowercase(self, count: int) -> None:
cfg = self.load_config(require_pin=False)
cfg["min_lowercase"] = int(count)
self.save_config(cfg)
def set_min_digits(self, count: int) -> None:
cfg = self.load_config(require_pin=False)
cfg["min_digits"] = int(count)
self.save_config(cfg)
def set_min_special(self, count: int) -> None:
cfg = self.load_config(require_pin=False)
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)
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))
def set_nostr_max_retries(self, retries: int) -> None:
"""Persist the maximum number of Nostr retry attempts."""
if retries < 0:
raise ValueError("retries cannot be negative")
cfg = self.load_config(require_pin=False)
cfg["nostr_max_retries"] = int(retries)
self.save_config(cfg)
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", MAX_RETRIES))
def set_nostr_retry_delay(self, delay: float) -> None:
"""Persist the delay between Nostr retry attempts."""
if delay < 0:
raise ValueError("delay cannot be negative")
cfg = self.load_config(require_pin=False)
cfg["nostr_retry_delay"] = float(delay)
self.save_config(cfg)
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", float(RETRY_DELAY)))
def set_verbose_timing(self, enabled: bool) -> None:
cfg = self.load_config(require_pin=False)
cfg["verbose_timing"] = bool(enabled)
self.save_config(cfg)
def get_verbose_timing(self) -> bool:
cfg = self.load_config(require_pin=False)
return bool(cfg.get("verbose_timing", False))

View File

@@ -0,0 +1,354 @@
# /src/seedpass.core/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,
*,
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")
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 merge and (self.fingerprint_dir / relative_path).exists():
current = self.load_json_data(relative_path)
current_entries = current.get("entries", {})
for idx, entry in data.get("entries", {}).items():
cur_ts = current_entries.get(idx, {}).get("modified_ts", 0)
new_ts = entry.get("modified_ts", 0)
if idx not in current_entries or new_ts >= cur_ts:
current_entries[idx] = entry
current["entries"] = current_entries
if "schema_version" in data:
current["schema_version"] = max(
current.get("schema_version", 0), data.get("schema_version", 0)
)
data = current
self.save_json_data(data, relative_path) # This always saves in V2 format
self.update_checksum(relative_path)
logger.info("Index file from Nostr was processed and saved successfully.")
print(colored("Index file updated from Nostr successfully.", "green"))
return True
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.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,4 +1,4 @@
# password_manager/entry_management.py
# seedpass.core/entry_management.py
"""
Entry Management Module
@@ -15,22 +15,38 @@ completely deterministic passwords from a BIP-85 seed, ensuring that passwords a
the same way every time. Salts would break this functionality and are not suitable for this software.
"""
import json
try:
import orjson as json_lib # type: ignore
USE_ORJSON = True
except Exception: # pragma: no cover - fallback when orjson is missing
import json as json_lib
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
from .totp import TotpManager
from utils.fingerprint import generate_fingerprint
from utils.checksum import canonical_json_dumps
from utils.key_validation import (
validate_totp_secret,
validate_ssh_key_pair,
validate_pgp_private_key,
validate_nostr_keys,
validate_seed_phrase,
)
from password_manager.vault import Vault
from password_manager.backup import BackupManager
from .vault import Vault
from .backup import BackupManager
# Instantiate the logger
@@ -53,9 +69,18 @@ class EntryManager:
self.index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc"
self.checksum_file = self.fingerprint_dir / "seedpass_entries_db_checksum.txt"
self._index_cache: dict | None = None
logger.debug(f"EntryManager initialized with index file at {self.index_file}")
def _load_index(self) -> Dict[str, Any]:
def clear_cache(self) -> None:
"""Clear the cached index data."""
self._index_cache = None
def _load_index(self, force_reload: bool = False) -> Dict[str, Any]:
if not force_reload and self._index_cache is not None:
return self._index_cache
if self.index_file.exists():
try:
data = self.vault.load_index()
@@ -80,7 +105,9 @@ 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
except Exception as e:
logger.error(f"Failed to load index: {e}")
@@ -89,11 +116,14 @@ class EntryManager:
logger.info(
f"Index file '{self.index_file}' not found. Initializing new entries database."
)
return {"schema_version": LATEST_VERSION, "entries": {}}
data = {"schema_version": LATEST_VERSION, "entries": {}}
self._index_cache = data
return data
def _save_index(self, data: Dict[str, Any]) -> None:
try:
self.vault.save_index(data)
self._index_cache = data
logger.debug("Index saved successfully.")
except Exception as e:
logger.error(f"Failed to save index: {e}")
@@ -106,7 +136,7 @@ class EntryManager:
:return: The next index number as an integer.
"""
try:
data = self.vault.load_index()
data = self._load_index()
if "entries" in data and isinstance(data["entries"], dict):
indices = [int(idx) for idx in data["entries"].keys()]
next_index = max(indices) + 1 if indices else 0
@@ -129,6 +159,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.
@@ -143,10 +182,10 @@ class EntryManager:
"""
try:
index = self.get_next_index()
data = self.vault.load_index()
data = self._load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {
entry = {
"label": label,
"length": length,
"username": username if username else "",
@@ -155,10 +194,33 @@ 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 [],
}
policy: dict[str, Any] = {}
if include_special_chars is not None:
policy["include_special_chars"] = include_special_chars
if allowed_special_chars is not None:
policy["allowed_special_chars"] = allowed_special_chars
if special_mode is not None:
policy["special_mode"] = special_mode
if exclude_ambiguous is not None:
policy["exclude_ambiguous"] = exclude_ambiguous
if min_uppercase is not None:
policy["min_uppercase"] = int(min_uppercase)
if min_lowercase is not None:
policy["min_lowercase"] = int(min_lowercase)
if min_digits is not None:
policy["min_digits"] = int(min_digits)
if min_special is not None:
policy["min_special"] = int(min_special)
if policy:
entry["policy"] = policy
data["entries"][str(index)] = entry
logger.debug(f"Added entry at index {index}: {data['entries'][str(index)]}")
self._save_index(data)
@@ -177,7 +239,7 @@ class EntryManager:
def get_next_totp_index(self) -> int:
"""Return the next available derivation index for TOTP secrets."""
data = self.vault.load_index()
data = self._load_index()
entries = data.get("entries", {})
indices = [
int(v.get("index", 0))
@@ -204,17 +266,20 @@ class EntryManager:
) -> str:
"""Add a new TOTP entry and return the provisioning URI."""
entry_id = self.get_next_index()
data = self.vault.load_index()
data = self._load_index()
data.setdefault("entries", {})
if secret is None:
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,
@@ -223,11 +288,14 @@ class EntryManager:
"tags": tags or [],
}
else:
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,
@@ -266,13 +334,20 @@ class EntryManager:
if index is None:
index = self.get_next_index()
data = self.vault.load_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)] = {
"type": EntryType.SSH.value,
"kind": EntryType.SSH.value,
"index": index,
"label": label,
"modified_ts": int(time.time()),
"notes": notes,
"archived": archived,
"tags": tags or [],
@@ -291,7 +366,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)
@@ -312,13 +387,25 @@ class EntryManager:
if index is None:
index = self.get_next_index()
data = self.vault.load_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)] = {
"type": EntryType.PGP.value,
"kind": EntryType.PGP.value,
"index": index,
"label": label,
"modified_ts": int(time.time()),
"key_type": key_type,
"user_id": user_id,
"notes": notes,
@@ -339,7 +426,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
@@ -354,6 +441,7 @@ class EntryManager:
def add_nostr_key(
self,
label: str,
parent_seed: str,
index: int | None = None,
notes: str = "",
archived: bool = False,
@@ -364,13 +452,27 @@ class EntryManager:
if index is None:
index = self.get_next_index()
data = self.vault.load_index()
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
from nostr.coincurve_keys import Keys
seed_bytes = Bip39SeedGenerator(parent_seed).Generate()
bip85 = BIP85(seed_bytes)
entropy = bip85.derive_entropy(index=index, bytes_len=32)
keys = Keys(priv_k=entropy.hex())
npub = Keys.hex_to_bech32(keys.public_key_hex(), "npub")
nsec = Keys.hex_to_bech32(keys.private_key_hex(), "nsec")
if not validate_nostr_keys(npub, nsec):
raise ValueError("Derived Nostr keys failed validation")
data = self._load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {
"type": EntryType.NOSTR.value,
"kind": EntryType.NOSTR.value,
"index": index,
"label": label,
"modified_ts": int(time.time()),
"notes": notes,
"archived": archived,
"tags": tags or [],
@@ -383,6 +485,7 @@ class EntryManager:
def add_key_value(
self,
label: str,
key: str,
value: str,
*,
notes: str = "",
@@ -394,12 +497,14 @@ class EntryManager:
index = self.get_next_index()
data = self.vault.load_index()
data = self._load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {
"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,
@@ -416,8 +521,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,13 +557,24 @@ class EntryManager:
if index is None:
index = self.get_next_index()
data = self.vault.load_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)] = {
"type": EntryType.SEED.value,
"kind": EntryType.SEED.value,
"index": index,
"label": label,
"modified_ts": int(time.time()),
"word_count": words_num,
"notes": notes,
"archived": archived,
@@ -480,7 +596,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
@@ -509,7 +625,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
@@ -519,18 +635,21 @@ 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
account_dir.mkdir(parents=True, exist_ok=True)
data = self.vault.load_index()
data = self._load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {
"type": EntryType.MANAGED_ACCOUNT.value,
"kind": EntryType.MANAGED_ACCOUNT.value,
"index": index,
"label": label,
"modified_ts": int(time.time()),
"word_count": word_count,
"notes": notes,
"fingerprint": fingerprint,
@@ -555,7 +674,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
@@ -599,7 +718,7 @@ class EntryManager:
def export_totp_entries(self, parent_seed: str) -> dict[str, list[dict[str, Any]]]:
"""Return all TOTP secrets and metadata for external use."""
data = self.vault.load_index()
data = self._load_index()
entries = data.get("entries", {})
exported: list[dict[str, Any]] = []
for entry in entries.values():
@@ -649,7 +768,7 @@ class EntryManager:
:return: A dictionary containing the entry details or None if not found.
"""
try:
data = self.vault.load_index()
data = self._load_index()
entry = data.get("entries", {}).get(str(index))
if entry:
@@ -661,7 +780,8 @@ class EntryManager:
):
entry.setdefault("custom_fields", [])
logger.debug(f"Retrieved entry at index {index}: {entry}")
return entry
clean = {k: v for k, v in entry.items() if k != "modified_ts"}
return clean
else:
logger.warning(f"No entry found at index {index}.")
print(colored(f"Warning: No entry found at index {index}.", "yellow"))
@@ -687,9 +807,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:
"""
@@ -703,10 +832,11 @@ 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:
data = self.vault.load_index()
data = self._load_index()
entry = data.get("entries", {}).get(str(index))
if not entry:
@@ -723,6 +853,111 @@ class EntryManager:
entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
provided_fields = {
"username": username,
"url": url,
"archived": archived,
"notes": notes,
"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 = {
EntryType.PASSWORD.value: {
"username",
"url",
"label",
"archived",
"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",
"period",
"digits",
"archived",
"notes",
"custom_fields",
"tags",
},
EntryType.KEY_VALUE.value: {
"label",
"key",
"value",
"archived",
"notes",
"custom_fields",
"tags",
},
EntryType.MANAGED_ACCOUNT.value: {
"label",
"value",
"archived",
"notes",
"custom_fields",
"tags",
},
EntryType.SSH.value: {
"label",
"archived",
"notes",
"custom_fields",
"tags",
},
EntryType.PGP.value: {
"label",
"archived",
"notes",
"custom_fields",
"tags",
},
EntryType.NOSTR.value: {
"label",
"archived",
"notes",
"custom_fields",
"tags",
},
EntryType.SEED.value: {
"label",
"archived",
"notes",
"custom_fields",
"tags",
},
}
allowed_fields = allowed.get(entry_type, set())
invalid = {
k for k, v in provided_fields.items() if v is not None
} - allowed_fields
if invalid:
raise ValueError(
f"Entry type '{entry_type}' does not support fields: {', '.join(sorted(invalid))}"
)
if entry_type == EntryType.TOTP.value:
if label is not None:
entry["label"] = label
@@ -750,6 +985,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}.")
@@ -779,6 +1017,30 @@ class EntryManager:
entry["tags"] = tags
logger.debug(f"Updated tags for index {index}: {tags}")
policy_updates: dict[str, Any] = {}
if include_special_chars is not None:
policy_updates["include_special_chars"] = include_special_chars
if allowed_special_chars is not None:
policy_updates["allowed_special_chars"] = allowed_special_chars
if special_mode is not None:
policy_updates["special_mode"] = special_mode
if exclude_ambiguous is not None:
policy_updates["exclude_ambiguous"] = exclude_ambiguous
if min_uppercase is not None:
policy_updates["min_uppercase"] = int(min_uppercase)
if min_lowercase is not None:
policy_updates["min_lowercase"] = int(min_lowercase)
if min_digits is not None:
policy_updates["min_digits"] = int(min_digits)
if min_special is not None:
policy_updates["min_special"] = int(min_special)
if policy_updates:
entry_policy = entry.get("policy", {})
entry_policy.update(policy_updates)
entry["policy"] = entry_policy
entry["modified_ts"] = int(time.time())
data["entries"][str(index)] = entry
logger.debug(f"Modified entry at index {index}: {entry}")
@@ -796,6 +1058,7 @@ class EntryManager:
print(
colored(f"Error: Failed to modify entry at index {index}: {e}", "red")
)
raise
def archive_entry(self, index: int) -> None:
"""Mark the specified entry as archived."""
@@ -811,30 +1074,42 @@ class EntryManager:
filter_kind: 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_kind:
Optional entry kind to restrict the results.
Archived entries are omitted unless ``include_archived`` is ``True``.
"""
try:
data = self.vault.load_index()
data = self._load_index()
entries_data = data.get("entries", {})
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)
@@ -878,136 +1153,113 @@ 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."""
data = self.vault.load_index()
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", {})
if not entries_data:
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
@@ -1018,11 +1270,11 @@ class EntryManager:
:param index: The index number of the entry to delete.
"""
try:
data = self.vault.load_index()
data = self._load_index()
if "entries" in data and str(index) in data["entries"]:
del data["entries"][str(index)]
logger.debug(f"Deleted entry at index {index}.")
self.vault.save_index(data)
self._save_index(data)
self.update_checksum()
self.backup_manager.create_backup()
logger.info(f"Entry at index {index} deleted successfully.")
@@ -1053,9 +1305,9 @@ class EntryManager:
Updates the checksum file for the password database to ensure data integrity.
"""
try:
data = self.vault.load_index()
json_content = json.dumps(data, indent=4)
checksum = hashlib.sha256(json_content.encode("utf-8")).hexdigest()
data = self._load_index()
canonical = canonical_json_dumps(data)
checksum = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
# The checksum file path already includes the fingerprint directory
checksum_path = self.checksum_file
@@ -1099,6 +1351,7 @@ class EntryManager:
)
)
self.clear_cache()
self.update_checksum()
except Exception as e:
@@ -1152,7 +1405,7 @@ class EntryManager:
) -> list[tuple[int, str, str]]:
"""Return a list of entry index, type, and display labels."""
try:
data = self.vault.load_index()
data = self._load_index()
entries_data = data.get("entries", {})
summaries: list[tuple[int, str, str]] = []

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

View File

@@ -1,4 +1,4 @@
# password_manager/password_generation.py
# seedpass.core/password_generation.py
"""
Password Generation Module
@@ -21,6 +21,7 @@ import random
import traceback
import base64
from typing import Optional
from dataclasses import dataclass
from termcolor import colored
from pathlib import Path
import shutil
@@ -41,13 +42,43 @@ 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__)
@dataclass
class PasswordPolicy:
"""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:
"""
PasswordGenerator Class
@@ -58,7 +89,11 @@ class PasswordGenerator:
"""
def __init__(
self, encryption_manager: EncryptionManager, parent_seed: str, bip85: BIP85
self,
encryption_manager: EncryptionManager,
parent_seed: str,
bip85: BIP85,
policy: PasswordPolicy | None = None,
):
"""
Initializes the PasswordGenerator with the encryption manager, parent seed, and BIP85 instance.
@@ -72,6 +107,7 @@ class PasswordGenerator:
self.encryption_manager = encryption_manager
self.parent_seed = parent_seed
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(
@@ -159,9 +195,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
@@ -179,7 +234,9 @@ 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}"
@@ -192,7 +249,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.
@@ -210,7 +269,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)
@@ -224,11 +289,11 @@ class PasswordGenerator:
f"Current character counts - Upper: {current_upper}, Lower: {current_lower}, Digits: {current_digits}, Special: {current_special}"
)
# Set minimum counts
min_upper = 2
min_lower = 2
min_digits = 2
min_special = 2
# Set minimum counts from policy
min_upper = self.policy.min_uppercase
min_lower = self.policy.min_lowercase
min_digits = self.policy.min_digits
min_special = self.policy.min_special if special else 0
# Initialize derived key index
dk_index = 0
@@ -266,7 +331,7 @@ class PasswordGenerator:
password_chars[index] = char
logger.debug(f"Added digit '{char}' at position {index}.")
if current_special < min_special:
if special and current_special < min_special:
for _ in range(min_special - current_special):
index = get_dk_value() % len(password_chars)
char = special[get_dk_value() % len(special)]
@@ -276,23 +341,29 @@ class PasswordGenerator:
)
# 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 '{char}' 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):
@@ -314,7 +385,11 @@ class PasswordGenerator:
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:
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(

View File

@@ -12,14 +12,14 @@ import asyncio
from enum import Enum
from pathlib import Path
from password_manager.vault import Vault
from password_manager.backup import BackupManager
from .vault import Vault
from .backup import BackupManager
from nostr.client import NostrClient
from utils.key_derivation import (
derive_index_key,
EncryptionMode,
)
from password_manager.encryption import EncryptionManager
from .encryption import EncryptionManager
from utils.checksum import json_checksum, canonical_json_dumps
logger = logging.getLogger(__name__)
@@ -74,7 +74,7 @@ def export_backup(
"created_at": int(time.time()),
"fingerprint": vault.fingerprint_dir.name,
"encryption_mode": PortableMode.SEED_ONLY.value,
"cipher": "fernet",
"cipher": "aes-gcm",
"checksum": checksum,
"payload": base64.b64encode(payload_bytes).decode("utf-8"),
}
@@ -90,7 +90,11 @@ def export_backup(
enc_file.write_bytes(encrypted)
os.chmod(enc_file, 0o600)
try:
client = NostrClient(vault.encryption_manager, vault.fingerprint_dir.name)
client = NostrClient(
vault.encryption_manager,
vault.fingerprint_dir.name,
config_manager=backup_manager.config_manager,
)
asyncio.run(client.publish_snapshot(encrypted))
except Exception:
logger.error("Failed to publish backup via Nostr", exc_info=True)

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

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

@@ -30,6 +30,17 @@ class Vault:
# ----- 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
@@ -49,9 +60,13 @@ class Vault:
"""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)
def decrypt_and_save_index_from_nostr(
self, encrypted_data: bytes, *, strict: bool = True, merge: bool = False
) -> bool:
"""Decrypt Nostr payload and update the local index."""
return self.encryption_manager.decrypt_and_save_index_from_nostr(
encrypted_data, strict=strict, merge=merge
)
# ----- Config helpers -----
def load_config(self) -> dict:

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 .app 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_kind=EntryType.TOTP.value
):
entry = self.entries.retrieve_entry(idx)
code = self.entries.get_totp_code(idx)
period = int(entry.get("period", 30)) if entry else 30
remaining = TotpManager.time_remaining(period)
self.table.data.append((label, code, remaining))
def cleanup(self, *args: object, **kwargs: object) -> None:
self._running = False
class RelayManagerDialog(toga.Window):
"""Dialog for managing relay URLs."""
def __init__(self, main: MainWindow, nostr: NostrService) -> None:
super().__init__("Relays")
self.main = main
self.nostr = nostr
self.table = toga.Table(headings=["Index", "URL"], style=Pack(flex=1))
self.new_input = toga.TextInput(style=Pack(flex=1))
add_btn = toga.Button("Add", on_press=self.add_relay)
remove_btn = toga.Button("Remove", on_press=self.remove_relay)
self.message = toga.Label("", style=Pack(color="red"))
box = toga.Box(style=Pack(direction=COLUMN, padding=20))
box.add(self.table)
form = toga.Box(style=Pack(direction=ROW, padding_top=5))
form.add(self.new_input)
form.add(add_btn)
form.add(remove_btn)
box.add(form)
box.add(self.message)
self.content = box
self.refresh()
def refresh(self) -> None:
self.table.data = []
for i, url in enumerate(self.nostr.list_relays(), start=1):
self.table.data.append((i, url))
def add_relay(self, widget: toga.Widget) -> None:
url = self.new_input.value or ""
if not url:
return
try:
self.nostr.add_relay(url)
except Exception as exc: # pragma: no cover - pass errors
self.message.text = str(exc)
return
self.new_input.value = ""
self.refresh()
def remove_relay(self, widget: toga.Widget, *, index: int | None = None) -> None:
if index is None:
if self.table.selection is None:
return
index = int(self.table.selection[0])
try:
self.nostr.remove_relay(index)
except Exception as exc: # pragma: no cover - pass errors
self.message.text = str(exc)
return
self.refresh()
def build() -> SeedPassApp:
"""Return a configured :class:`SeedPassApp` instance."""
return SeedPassApp(formal_name="SeedPass", app_id="org.seedpass.gui")
class SeedPassApp(toga.App):
def startup(self) -> None: # pragma: no cover - GUI bootstrap
pm = PasswordManager()
self.vault_service = VaultService(pm)
self.entry_service = EntryService(pm)
self.nostr_service = NostrService(pm)
self.lock_window = LockScreenWindow(
self,
self.vault_service,
self.entry_service,
)
self.main_window = None
self.lock_window.show()
def main() -> None: # pragma: no cover - GUI bootstrap
"""Run the BeeWare application."""
build().main_loop()

View File

@@ -1,10 +1,12 @@
import sys
import time
import json
from pathlib import Path
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.vault import Vault
from password_manager.encryption import EncryptionManager
from seedpass.core.vault import Vault
from seedpass.core.encryption import EncryptionManager
from utils.key_derivation import (
derive_index_key,
derive_key_from_password,
@@ -106,6 +108,7 @@ class DummyFilter:
self.ids: list[str] = []
self.limit_val: int | None = None
self.since_val: int | None = None
self.id_called: bool = False
def author(self, _pk):
return self
@@ -123,6 +126,11 @@ class DummyFilter:
self.ids.append(ident)
return self
def id(self, ident: str):
self.id_called = True
self.ids.append(ident)
return self
def limit(self, val: int):
self.limit_val = val
return self
@@ -161,9 +169,11 @@ class DummySendResult:
class DummyRelayClient:
def __init__(self):
self.counter = 0
self.ts_counter = 0
self.manifests: list[DummyEvent] = []
self.chunks: dict[str, DummyEvent] = {}
self.deltas: list[DummyEvent] = []
self.filters: list[DummyFilter] = []
async def add_relays(self, _relays):
pass
@@ -183,15 +193,25 @@ class DummyRelayClient:
if isinstance(event, DummyEvent):
event.id = eid
if event.kind == KIND_MANIFEST:
try:
data = json.loads(event.content())
event.delta_since = data.get("delta_since")
except Exception:
event.delta_since = None
self.manifests.append(event)
elif event.kind == KIND_SNAPSHOT_CHUNK:
ident = event.tags[0] if event.tags else str(self.counter)
self.chunks[ident] = event
self.chunks[eid] = event
elif event.kind == KIND_DELTA:
if not hasattr(event, "created_at"):
self.ts_counter += 1
event.created_at = self.ts_counter
self.deltas.append(event)
return DummySendResult(eid)
async def fetch_events(self, f, _timeout):
self.filters.append(f)
kind = getattr(f, "kind_val", None)
limit = getattr(f, "limit_val", None)
identifier = f.ids[0] if getattr(f, "ids", None) else None

View File

@@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager
from password_manager.backup import BackupManager
from password_manager.manager import PasswordManager, EncryptionMode
from password_manager.config_manager import ConfigManager
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.manager import PasswordManager, EncryptionMode
from seedpass.core.config_manager import ConfigManager
class FakePasswordGenerator:

View File

@@ -4,9 +4,9 @@ from tempfile import TemporaryDirectory
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
from password_manager.entry_management import EntryManager
from password_manager.backup import BackupManager
from password_manager.config_manager import ConfigManager
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
def test_entry_manager_additional_backup(monkeypatch):

View File

@@ -8,13 +8,16 @@ from fastapi.testclient import TestClient
sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass import api
from seedpass.core.entry_types import EntryType
@pytest.fixture
def client(monkeypatch):
dummy = SimpleNamespace(
entry_manager=SimpleNamespace(
search_entries=lambda q: [(1, "Site", "user", "url", False)],
search_entries=lambda q: [
(1, "Site", "user", "url", False, EntryType.PASSWORD)
],
retrieve_entry=lambda i: {"label": "Site"},
add_entry=lambda *a, **k: 1,
modify_entry=lambda *a, **k: None,
@@ -30,6 +33,7 @@ def client(monkeypatch):
set_additional_backup_path=lambda v: None,
set_secret_mode_enabled=lambda v: None,
set_clipboard_clear_delay=lambda v: None,
set_quick_unlock=lambda v: None,
),
fingerprint_manager=SimpleNamespace(list_fingerprints=lambda: ["fp"]),
nostr_client=SimpleNamespace(
@@ -158,16 +162,36 @@ def test_update_config(client):
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_update_config_quick_unlock(client):
cl, token = client
called = {}
api._pm.config_manager.set_quick_unlock = lambda v: called.setdefault("val", v)
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
res = cl.put(
"/api/v1/config/quick_unlock",
json={"value": True},
headers=headers,
)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert called.get("val") is True
def test_change_password_route(client):
cl, token = client
called = {}
api._pm.change_password = lambda: called.setdefault("called", True)
api._pm.change_password = lambda o, n: called.setdefault("called", (o, n))
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
res = cl.post("/api/v1/change-password", headers=headers)
res = cl.post(
"/api/v1/change-password",
headers=headers,
json={"old": "old", "new": "new"},
)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert called.get("called") is True
assert called.get("called") == ("old", "new")
assert res.headers.get("access-control-allow-origin") == "http://example.com"

View File

@@ -4,6 +4,10 @@ import pytest
from seedpass import api
from test_api import client
from helpers import dummy_nostr_client
import string
from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy
from nostr.client import NostrClient, DEFAULT_RELAYS
def test_create_and_modify_totp_entry(client):
@@ -93,6 +97,19 @@ def test_create_and_modify_ssh_entry(client):
assert calls["modify"][1]["notes"] == "x"
def test_update_entry_error(client):
cl, token = client
def modify(*a, **k):
raise ValueError("nope")
api._pm.entry_manager.modify_entry = modify
headers = {"Authorization": f"Bearer {token}"}
res = cl.put("/api/v1/entry/1", json={"username": "x"}, headers=headers)
assert res.status_code == 400
assert res.json() == {"detail": "nope"}
def test_update_config_secret_mode(client):
cl, token = client
called = {}
@@ -218,6 +235,7 @@ def test_vault_import_via_path(client, tmp_path):
called["path"] = path
api._pm.handle_import_database = import_db
api._pm.sync_vault = lambda: called.setdefault("sync", True)
file_path = tmp_path / "b.json"
file_path.write_text("{}")
@@ -230,6 +248,7 @@ def test_vault_import_via_path(client, tmp_path):
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert called["path"] == file_path
assert called.get("sync") is True
def test_vault_import_via_upload(client, tmp_path):
@@ -240,6 +259,7 @@ def test_vault_import_via_upload(client, tmp_path):
called["path"] = path
api._pm.handle_import_database = import_db
api._pm.sync_vault = lambda: called.setdefault("sync", True)
file_path = tmp_path / "c.json"
file_path.write_text("{}")
@@ -253,6 +273,7 @@ def test_vault_import_via_upload(client, tmp_path):
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert isinstance(called.get("path"), Path)
assert called.get("sync") is True
def test_vault_lock_endpoint(client):
@@ -272,8 +293,8 @@ def test_vault_lock_endpoint(client):
assert res.json() == {"status": "locked"}
assert called.get("locked") is True
assert api._pm.locked is True
api._pm.unlock_vault = lambda: setattr(api._pm, "locked", False)
api._pm.unlock_vault()
api._pm.unlock_vault = lambda pw: setattr(api._pm, "locked", False)
api._pm.unlock_vault("pw")
assert api._pm.locked is False
@@ -300,3 +321,137 @@ def test_secret_mode_endpoint(client):
assert res.json() == {"status": "ok"}
assert called["enabled"] is True
assert called["delay"] == 12
def test_vault_export_endpoint(client, tmp_path):
cl, token = client
out = tmp_path / "out.json"
out.write_text("data")
api._pm.handle_export_database = lambda: out
headers = {"Authorization": f"Bearer {token}"}
res = cl.post("/api/v1/vault/export", headers=headers)
assert res.status_code == 200
assert res.content == b"data"
def test_backup_parent_seed_endpoint(client, tmp_path):
cl, token = client
called = {}
def backup(path=None):
called["path"] = path
api._pm.handle_backup_reveal_parent_seed = backup
path = tmp_path / "seed.enc"
headers = {"Authorization": f"Bearer {token}"}
res = cl.post(
"/api/v1/vault/backup-parent-seed",
json={"path": str(path)},
headers=headers,
)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert called["path"] == path
def test_relay_management_endpoints(client, dummy_nostr_client, monkeypatch):
cl, token = client
nostr_client, _ = dummy_nostr_client
relays = ["wss://a", "wss://b"]
def load_config(require_pin=False):
return {"relays": relays.copy()}
called = {}
def set_relays(new, require_pin=False):
called["set"] = new
api._pm.config_manager.load_config = load_config
api._pm.config_manager.set_relays = set_relays
monkeypatch.setattr(
NostrClient,
"initialize_client_pool",
lambda self: called.setdefault("init", True),
)
monkeypatch.setattr(
nostr_client, "close_client_pool", lambda: called.setdefault("close", True)
)
api._pm.nostr_client = nostr_client
api._pm.nostr_client.relays = relays.copy()
headers = {"Authorization": f"Bearer {token}"}
res = cl.get("/api/v1/relays", headers=headers)
assert res.status_code == 200
assert res.json() == {"relays": relays}
res = cl.post("/api/v1/relays", json={"url": "wss://c"}, headers=headers)
assert res.status_code == 200
assert called["set"] == ["wss://a", "wss://b", "wss://c"]
api._pm.config_manager.load_config = lambda require_pin=False: {
"relays": ["wss://a", "wss://b", "wss://c"]
}
res = cl.delete("/api/v1/relays/2", headers=headers)
assert res.status_code == 200
assert called["set"] == ["wss://a", "wss://c"]
res = cl.post("/api/v1/relays/reset", headers=headers)
assert res.status_code == 200
assert called.get("init") is True
assert api._pm.nostr_client.relays == list(DEFAULT_RELAYS)
def test_generate_password_no_special_chars(client):
cl, token = client
class DummyEnc:
def derive_seed_from_mnemonic(self, mnemonic):
return b"\x00" * 32
class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
return bytes(range(bytes_len))
api._pm.password_generator = PasswordGenerator(DummyEnc(), "seed", DummyBIP85())
api._pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}"}
res = cl.post(
"/api/v1/password",
json={"length": 16, "include_special_chars": False},
headers=headers,
)
assert res.status_code == 200
pw = res.json()["password"]
assert not any(c in string.punctuation for c in pw)
def test_generate_password_allowed_chars(client):
cl, token = client
class DummyEnc:
def derive_seed_from_mnemonic(self, mnemonic):
return b"\x00" * 32
class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len))
api._pm.password_generator = PasswordGenerator(DummyEnc(), "seed", DummyBIP85())
api._pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}"}
allowed = "@$"
res = cl.post(
"/api/v1/password",
json={"length": 16, "allowed_special_chars": allowed},
headers=headers,
)
assert res.status_code == 200
pw = res.json()["password"]
specials = [c for c in pw if c in string.punctuation]
assert specials and all(c in allowed for c in specials)

View File

@@ -0,0 +1,45 @@
from test_api import client
from types import SimpleNamespace
import queue
import seedpass.api as api
def test_notifications_endpoint(client):
cl, token = client
api._pm.notifications = queue.Queue()
api._pm.notifications.put(SimpleNamespace(message="m1", level="INFO"))
api._pm.notifications.put(SimpleNamespace(message="m2", level="WARNING"))
res = cl.get("/api/v1/notifications", headers={"Authorization": f"Bearer {token}"})
assert res.status_code == 200
assert res.json() == [
{"level": "INFO", "message": "m1"},
{"level": "WARNING", "message": "m2"},
]
assert api._pm.notifications.empty()
def test_notifications_endpoint_clears_queue(client):
cl, token = client
api._pm.notifications = queue.Queue()
api._pm.notifications.put(SimpleNamespace(message="hi", level="INFO"))
res = cl.get("/api/v1/notifications", headers={"Authorization": f"Bearer {token}"})
assert res.status_code == 200
assert res.json() == [{"level": "INFO", "message": "hi"}]
assert api._pm.notifications.empty()
res = cl.get("/api/v1/notifications", headers={"Authorization": f"Bearer {token}"})
assert res.json() == []
def test_notifications_endpoint_does_not_clear_current(client):
cl, token = client
api._pm.notifications = queue.Queue()
msg = SimpleNamespace(message="keep", level="INFO")
api._pm.notifications.put(msg)
api._pm._current_notification = msg
api._pm.get_current_notification = lambda: api._pm._current_notification
res = cl.get("/api/v1/notifications", headers={"Authorization": f"Bearer {token}"})
assert res.status_code == 200
assert res.json() == [{"level": "INFO", "message": "keep"}]
assert api._pm.notifications.empty()
assert api._pm.get_current_notification() is msg

View File

@@ -2,15 +2,16 @@ import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
import queue
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager
from password_manager.backup import BackupManager
from password_manager.manager import PasswordManager, EncryptionMode
from password_manager.config_manager import ConfigManager
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.manager import PasswordManager, EncryptionMode
from seedpass.core.config_manager import ConfigManager
class FakePasswordGenerator:
@@ -37,6 +38,7 @@ def test_archive_entry_from_retrieve(monkeypatch):
pm.nostr_client = SimpleNamespace()
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
pm.notifications = queue.Queue()
index = entry_mgr.add_entry("example.com", 8)
@@ -68,6 +70,7 @@ def test_restore_entry_from_retrieve(monkeypatch):
pm.nostr_client = SimpleNamespace()
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
pm.notifications = queue.Queue()
index = entry_mgr.add_entry("example.com", 8)
entry_mgr.archive_entry(index)

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