112 Commits
0.0.2 ... 0.0.4

Author SHA1 Message Date
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
44 changed files with 2126 additions and 734 deletions

View File

@@ -81,10 +81,15 @@ 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: Upload pytest log
if: always()
uses: actions/upload-artifact@v4
with:
name: pytest-log-${{ matrix.os }}
path: pytest.log
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:

5
.gitignore vendored
View File

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

View File

@@ -31,6 +31,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
- [Running the Application](#running-the-application)
- [Managing Multiple Seeds](#managing-multiple-seeds)
- [Additional Entry Types](#additional-entry-types)
- [Building a standalone executable](#building-a-standalone-executable)
- [Security Considerations](#security-considerations)
- [Contributing](#contributing)
- [License](#license)
@@ -331,6 +332,15 @@ When **Secret Mode** is enabled, SeedPass copies retrieved passwords directly to
2. Choose how many seconds to keep passwords on the clipboard.
3. 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
SeedPass supports storing more than just passwords and 2FA secrets. You can also create entries for:
@@ -360,8 +370,9 @@ SeedPass allows you to manage multiple seed profiles (previously referred to as
- **Add a New Seed Profile:**
1. From the main menu, select **Settings** then **Profiles** and choose "Add a New Seed Profile".
2. Choose to enter an existing seed or generate a new one.
3. If generating a new seed, you'll be provided with a 12-word BIP-85 seed phrase. **Ensure you write this down and store it securely.**
2. Choose to paste in a full seed, enter one word at a time, or generate a new seed.
3. If you enter the seed word by word, each word is hidden with `*` and the screen refreshes after every entry for clarity. SeedPass then shows the completed phrase for confirmation so you can fix any mistakes before it is stored.
4. If generating a new seed, you'll be provided with a 12-word BIP-85 seed phrase. **Ensure you write this down and store it securely.**
- **Switch Between Seed Profiles:**
1. From the **Profiles** menu, select "Switch Seed Profile".
@@ -369,8 +380,12 @@ SeedPass allows you to manage multiple seed profiles (previously referred to as
3. Enter the number corresponding to the seed profile you wish to switch to.
4. Enter the master password associated with that seed profile.
- **List All Seed Profiles:**
- **List All Seed Profiles:**
In the **Profiles** menu, choose "List All Seed Profiles" to view all existing profiles.
- **Set Seed Profile Name:**
In the **Profiles** menu, choose "Set Seed Profile Name" to assign an optional
label to the currently selected profile. The name is stored locally and shown
alongside the fingerprint in menus.
**Note:** The term "seed profile" is used to represent different sets of seeds you can manage within SeedPass. This provides an intuitive way to handle multiple identities or sets of passwords.
@@ -459,7 +474,9 @@ subfolder (or adjust `APP_DIR` in `constants.py`) if you want to load it with
the main application. The fingerprint is printed after creation and the
encrypted index is published to Nostr. Use that same seed phrase to load
SeedPass. The app checks Nostr on startup and pulls any newer snapshot so your
vault stays in sync across machines.
vault stays in sync across machines. If no snapshot exists or the download
cannot be decrypted (for example when using a brand-new seed), SeedPass
automatically initializes an empty index instead of exiting.
### Automatically Updating the Script Checksum
@@ -486,6 +503,41 @@ 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
```
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.
## Security Considerations

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

@@ -156,6 +156,14 @@ $ seedpass entry get "email"
Code: 123456
```
### Viewing Entry Details
Picking an entry from `entry list` or `entry search` displays its metadata first
so you can review the label, username and notes. Sensitive fields are hidden
until you confirm you want to reveal them. After showing the secret, the details
view offers the same actions as `entry get`—edit the entry, archive it or show
QR codes for supported types.
### `vault` Commands
- **`seedpass vault export`** Export the entire vault to an encrypted JSON file.

View File

@@ -357,7 +357,8 @@ SeedPass allows you to manage multiple seed profiles (previously referred to as
- **Add a New Seed Profile:**
- From the main menu, select **Settings** then **Profiles** and choose "Add a New Seed Profile".
- Choose to enter an existing seed or generate a new one.
- Choose to paste in a full seed, enter one word at a time, or generate a new seed.
- When entering a seed word by word, each word is hidden with `*` and the screen refreshes after every entry for clarity. You'll review the completed phrase after the last word and can correct mistakes before it is saved.
- If generating a new seed, you'll be provided with a 12-word BIP-85 seed phrase. **Ensure you write this down and store it securely.**
- **Switch Between Seed Profiles:**
@@ -368,6 +369,8 @@ SeedPass allows you to manage multiple seed profiles (previously referred to as
- **List All Seed Profiles:**
- In the **Profiles** menu, choose "List All Seed Profiles" to view all existing profiles.
- **Set Seed Profile Name:**
- In the **Profiles** menu, choose "Set Seed Profile Name" to assign a label to the current profile. The name is stored locally and shown next to the fingerprint.
**Note:** The term "seed profile" is used to represent different sets of seeds you can manage within SeedPass. This provides an intuitive way to handle multiple identities or sets of passwords.

View File

@@ -40,7 +40,7 @@
</li>
<li role="none"><a href="#disclaimer" role="menuitem">Disclaimer</a>
</li>
<li role="none"><a href="https://beta-seedpass-docs.netlify.app/" role="menuitem">Docs</a>
<li role="none"><a href="https://docs.seedpass.me/" role="menuitem">Docs</a>
</li>
</ul>
</div>

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

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

@@ -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
@@ -151,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)):
@@ -195,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)):
@@ -239,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)
@@ -357,14 +365,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"))
@@ -641,6 +650,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
@@ -756,6 +784,7 @@ def handle_profiles_menu(password_manager: PasswordManager) -> None:
print(color_text("2. Add a New Seed Profile", "menu"))
print(color_text("3. Remove an Existing Seed Profile", "menu"))
print(color_text("4. List All Seed Profiles", "menu"))
print(color_text("5. Set Seed Profile Name", "menu"))
choice = input("Select an option or press Enter to go back: ").strip()
password_manager.update_activity()
if choice == "1":
@@ -767,6 +796,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:

View File

@@ -14,6 +14,7 @@ class ChunkMeta:
id: str
size: int
hash: str
event_id: Optional[str] = None
@dataclass

View File

@@ -78,6 +78,7 @@ def prepare_snapshot(
id=f"seedpass-chunk-{i:04d}",
size=len(chunk),
hash=hashlib.sha256(chunk).hexdigest(),
event_id=None,
)
)
@@ -372,7 +373,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(
{
@@ -400,6 +407,60 @@ class NostrClient:
logger.info("publish_snapshot completed in %.2f seconds", duration)
return manifest, manifest_id
async def _fetch_chunks_with_retry(
self, manifest_event
) -> tuple[Manifest, list[bytes]] | None:
"""Retrieve all chunks referenced by ``manifest_event`` with retries."""
pubkey = self.keys.public_key()
timeout = timedelta(seconds=10)
try:
data = json.loads(manifest_event.content())
manifest = Manifest(
ver=data["ver"],
algo=data["algo"],
chunks=[ChunkMeta(**c) for c in data["chunks"]],
delta_since=(
int(data["delta_since"])
if data.get("delta_since") is not None
else None
),
)
except Exception:
return None
chunks: list[bytes] = []
for meta in manifest.chunks:
attempt = 0
chunk_bytes: bytes | None = None
while attempt < 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
attempt += 1
if attempt < MAX_RETRIES:
await asyncio.sleep(RETRY_DELAY)
if chunk_bytes is None:
return None
chunks.append(chunk_bytes)
man_id = getattr(manifest_event, "id", None)
if hasattr(man_id, "to_hex"):
man_id = man_id.to_hex()
self.current_manifest = manifest
self.current_manifest_id = man_id
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:
@@ -407,48 +468,18 @@ class NostrClient:
await self._connect_async()
pubkey = self.keys.public_key()
f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).limit(1)
f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).limit(3)
timeout = timedelta(seconds=10)
events = (await self.client.fetch_events(f, timeout)).to_vec()
if not events:
return None
manifest_event = events[0]
manifest_raw = manifest_event.content()
data = json.loads(manifest_raw)
manifest = Manifest(
ver=data["ver"],
algo=data["algo"],
chunks=[ChunkMeta(**c) for c in data["chunks"]],
delta_since=(
int(data["delta_since"])
if data.get("delta_since") is not None
else None
),
)
chunks: list[bytes] = []
for meta in manifest.chunks:
cf = (
Filter()
.author(pubkey)
.kind(Kind(KIND_SNAPSHOT_CHUNK))
.identifier(meta.id)
.limit(1)
)
cev = (await self.client.fetch_events(cf, timeout)).to_vec()
if not cev:
raise ValueError(f"Missing chunk {meta.id}")
chunk_bytes = base64.b64decode(cev[0].content().encode("utf-8"))
if hashlib.sha256(chunk_bytes).hexdigest() != meta.hash:
raise ValueError(f"Checksum mismatch for chunk {meta.id}")
chunks.append(chunk_bytes)
for manifest_event in events:
result = await self._fetch_chunks_with_retry(manifest_event)
if result is not None:
return result
self.current_manifest = manifest
man_id = getattr(manifest_event, "id", None)
if hasattr(man_id, "to_hex"):
man_id = man_id.to_hex()
self.current_manifest_id = man_id
return manifest, chunks
return None
async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str:
"""Publish a delta event referencing a manifest."""

View File

@@ -6,7 +6,7 @@ import logging
from pathlib import Path
from typing import List, Optional
import getpass
from utils.seed_prompt import masked_input
import bcrypt
@@ -93,7 +93,7 @@ class ConfigManager:
self.save_config(data)
if require_pin and data.get("pin_hash"):
for _ in range(3):
pin = getpass.getpass("Enter settings PIN: ").strip()
pin = masked_input("Enter settings PIN: ").strip()
if bcrypt.checkpw(pin.encode(), data["pin_hash"].encode()):
break
print("Invalid PIN")

View File

@@ -223,15 +223,28 @@ class EncryptionManager:
return fh.read()
def decrypt_and_save_index_from_nostr(
self, encrypted_data: bytes, relative_path: Optional[Path] = None
) -> None:
"""Decrypts data from Nostr and saves it, automatically using the new format."""
self,
encrypted_data: bytes,
relative_path: Optional[Path] = None,
*,
strict: bool = True,
) -> 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
) # This now handles both formats
decrypted_data = self.decrypt_data(encrypted_data)
if USE_ORJSON:
data = json_lib.loads(decrypted_data)
else:
@@ -240,18 +253,22 @@ class EncryptionManager:
self.update_checksum(relative_path)
logger.info("Index file from Nostr was processed and saved successfully.")
print(colored("Index file updated from Nostr successfully.", "green"))
except Exception as e:
logger.error(
f"Failed to decrypt and save data from Nostr: {e}",
exc_info=True,
)
print(
colored(
f"Error: Failed to decrypt and save data from Nostr: {e}",
"red",
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,
)
)
raise
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."""

View File

@@ -437,8 +437,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
):
@@ -920,6 +920,7 @@ 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.
@@ -932,7 +933,8 @@ class EntryManager:
if not entries_data:
logger.info("No entries found.")
print(colored("No entries found.", "yellow"))
if verbose:
print(colored("No entries found.", "yellow"))
return []
def sort_key(item: Tuple[str, Dict[str, Any]]):
@@ -987,51 +989,59 @@ 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(

File diff suppressed because it is too large Load Diff

View File

@@ -60,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:
def decrypt_and_save_index_from_nostr(
self, encrypted_data: bytes, *, strict: bool = True
) -> bool:
"""Decrypt Nostr payload and overwrite the local index."""
self.encryption_manager.decrypt_and_save_index_from_nostr(encrypted_data)
return self.encryption_manager.decrypt_and_save_index_from_nostr(
encrypted_data, strict=strict
)
# ----- Config helpers -----
def load_config(self) -> dict:

View File

@@ -0,0 +1,29 @@
# 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.42.1
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

View File

@@ -419,9 +419,14 @@ def vault_reveal_parent_seed(
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)
result = pm.sync_vault()
if result:
typer.echo("Event IDs:")
typer.echo(f"- manifest: {result['manifest_id']}")
for cid in result["chunk_ids"]:
typer.echo(f"- chunk: {cid}")
for did in result["delta_ids"]:
typer.echo(f"- delta: {did}")
else:
typer.echo("Error: Failed to sync vault")

View File

@@ -108,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
@@ -125,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
@@ -167,6 +173,7 @@ class DummyRelayClient:
self.manifests: list[DummyEvent] = []
self.chunks: dict[str, DummyEvent] = {}
self.deltas: list[DummyEvent] = []
self.filters: list[DummyFilter] = []
async def add_relays(self, _relays):
pass
@@ -195,6 +202,7 @@ class DummyRelayClient:
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
@@ -203,6 +211,7 @@ class DummyRelayClient:
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

@@ -152,3 +152,41 @@ def test_view_archived_entries_removed_after_restore(monkeypatch, capsys):
note = pm.notifications.get_nowait()
assert note.level == "WARNING"
assert note.message == "No archived entries found."
def test_archived_entries_menu_hides_active(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = vault
pm.entry_manager = entry_mgr
pm.backup_manager = backup_mgr
pm.parent_seed = TEST_SEED
pm.nostr_client = SimpleNamespace()
pm.fingerprint_dir = tmp_path
pm.is_dirty = False
pm.notifications = queue.Queue()
archived_idx = entry_mgr.add_entry("archived.com", 8)
active_idx = entry_mgr.add_entry("active.com", 8)
# Archive only the first entry
monkeypatch.setattr("builtins.input", lambda *_: str(archived_idx))
pm.handle_archive_entry()
assert entry_mgr.retrieve_entry(archived_idx)["archived"] is True
assert entry_mgr.retrieve_entry(active_idx)["archived"] is False
# View archived entries and immediately exit
inputs = iter([""])
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
pm.handle_view_archived_entries()
out = capsys.readouterr().out
assert "archived.com" in out
assert "active.com" not in out

View File

@@ -53,7 +53,11 @@ class DummyPM:
self.nostr_client = SimpleNamespace(
key_manager=SimpleNamespace(get_npub=lambda: "npub")
)
self.sync_vault = lambda: "event"
self.sync_vault = lambda: {
"manifest_id": "event",
"chunk_ids": ["c1"],
"delta_ids": [],
}
self.config_manager = SimpleNamespace(
load_config=lambda require_pin=False: {"inactivity_timeout": 30},
set_inactivity_timeout=lambda v: None,

View File

@@ -47,7 +47,8 @@ def test_full_sync_roundtrip(dummy_nostr_client):
manifest_id = relay.manifests[-1].id
# Manager B retrieves snapshot
pm_b.sync_index_from_nostr_if_missing()
result = pm_b.attempt_initial_sync()
assert result is True
entries = pm_b.entry_manager.list_entries()
assert [e[1] for e in entries] == ["site1"]

View File

@@ -47,7 +47,8 @@ def test_full_sync_roundtrip(dummy_nostr_client):
manifest_id = relay.manifests[-1].id
# Manager B retrieves snapshot
pm_b.sync_index_from_nostr_if_missing()
result = pm_b.attempt_initial_sync()
assert result is True
entries = pm_b.entry_manager.list_entries()
assert [e[1] for e in entries] == ["site1"]

View File

@@ -63,7 +63,7 @@ def test_index_export_import_round_trip():
},
}
)
vault.decrypt_and_save_index_from_nostr(encrypted)
assert vault.decrypt_and_save_index_from_nostr(encrypted)
loaded = vault.load_index()
assert loaded["entries"] == original["entries"]

View File

@@ -2,6 +2,8 @@ from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
import pytest
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
import sys
@@ -11,6 +13,7 @@ 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.entry_types import EntryType
from password_manager.config_manager import ConfigManager
@@ -30,6 +33,8 @@ def test_handle_list_entries(monkeypatch, capsys):
pm.backup_manager = backup_mgr
pm.nostr_client = SimpleNamespace()
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
pm.secret_mode_enabled = False
entry_mgr.add_totp("Example", TEST_SEED)
entry_mgr.add_entry("example.com", 12)
@@ -80,12 +85,322 @@ def test_list_entries_show_details(monkeypatch, capsys):
lambda *a, **k: "b",
)
inputs = iter(["1", "0", "n"])
inputs = iter(["1", "0"])
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
pm.handle_list_entries()
out = capsys.readouterr().out
assert "Retrieved 2FA Code" in out
assert "123456" in out
assert "Label: Example" in out
assert "Period: 30s" in out
assert "API" in out
assert "acct" in out
def test_show_entry_details_by_index(monkeypatch):
"""Ensure entry details screen triggers expected calls."""
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = vault
pm.entry_manager = entry_mgr
pm.backup_manager = backup_mgr
pm.nostr_client = SimpleNamespace()
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
index = entry_mgr.add_entry("example.com", 12)
header_calls = []
monkeypatch.setattr(
"password_manager.manager.clear_header_with_notification",
lambda *a, **k: header_calls.append(True),
)
call_order = []
monkeypatch.setattr(
pm,
"display_entry_details",
lambda *a, **k: call_order.append("display"),
)
monkeypatch.setattr(
pm,
"_entry_actions_menu",
lambda *a, **k: call_order.append("actions"),
)
monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None)
monkeypatch.setattr(
"password_manager.manager.confirm_action", lambda *a, **k: False
)
pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw123")
monkeypatch.setattr(pm, "notify", lambda *a, **k: None)
pm.show_entry_details_by_index(index)
assert len(header_calls) == 1
assert call_order == ["display", "actions"]
def _setup_manager(tmp_path):
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = vault
pm.entry_manager = entry_mgr
pm.backup_manager = backup_mgr
pm.parent_seed = TEST_SEED
pm.nostr_client = SimpleNamespace()
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
return pm, entry_mgr
def _detail_common(monkeypatch, pm):
monkeypatch.setattr(
"password_manager.manager.clear_header_with_notification",
lambda *a, **k: None,
)
monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None)
monkeypatch.setattr("builtins.input", lambda *a, **k: "")
monkeypatch.setattr(
"password_manager.manager.confirm_action", lambda *a, **k: False
)
monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b")
monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None)
monkeypatch.setattr(pm, "notify", lambda *a, **k: None)
pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw123")
called = []
monkeypatch.setattr(pm, "_entry_actions_menu", lambda *a, **k: called.append(True))
return called
def test_show_seed_entry_details(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
idx = entry_mgr.add_seed("seed", TEST_SEED, words_num=12)
called = _detail_common(monkeypatch, pm)
pm.show_entry_details_by_index(idx)
out = capsys.readouterr().out
assert "Type: Seed Phrase" in out
assert "Label: seed" in out
assert "Words: 12" in out
assert f"Derivation Index: {idx}" in out
assert called == [True]
def test_show_ssh_entry_details(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
idx = entry_mgr.add_ssh_key("ssh", TEST_SEED)
data = entry_mgr._load_index(force_reload=True)
data["entries"][str(idx)]["public_key_label"] = "server"
data["entries"][str(idx)]["fingerprint"] = "abc123"
entry_mgr._save_index(data)
called = _detail_common(monkeypatch, pm)
pm.show_entry_details_by_index(idx)
out = capsys.readouterr().out
assert "Type: SSH Key" in out
assert "Label: ssh" in out
assert f"Derivation Index: {idx}" in out
assert "server" in out
assert "abc123" in out
assert called == [True]
def test_show_pgp_entry_details(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
idx = entry_mgr.add_pgp_key("pgp", TEST_SEED, user_id="test")
_k, fp = entry_mgr.get_pgp_key(idx, TEST_SEED)
called = _detail_common(monkeypatch, pm)
pm.show_entry_details_by_index(idx)
out = capsys.readouterr().out
assert "Type: PGP Key" in out
assert "Label: pgp" in out
assert "Key Type: ed25519" in out
assert "User ID: test" in out
assert f"Derivation Index: {idx}" in out
assert fp in out
assert called == [True]
def test_show_nostr_entry_details(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
idx = entry_mgr.add_nostr_key("nostr")
called = _detail_common(monkeypatch, pm)
pm.show_entry_details_by_index(idx)
out = capsys.readouterr().out
assert "Type: Nostr Key" in out
assert "Label: nostr" in out
assert f"Derivation Index: {idx}" in out
assert called == [True]
def test_show_managed_account_entry_details(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
idx = entry_mgr.add_managed_account("acct", TEST_SEED)
fp = entry_mgr.retrieve_entry(idx).get("fingerprint")
called = _detail_common(monkeypatch, pm)
pm.show_entry_details_by_index(idx)
out = capsys.readouterr().out
assert "Type: Managed Account" in out
assert "Label: acct" in out
assert f"Derivation Index: {idx}" in out
assert "Words: 12" in out
assert fp in out
assert called == [True]
@pytest.mark.parametrize(
"entry_type",
[
"password",
"seed",
"ssh",
"pgp",
"nostr",
"totp",
"key_value",
"managed_account",
],
)
def test_show_entry_details_sensitive(monkeypatch, capsys, entry_type):
"""Ensure sensitive details are displayed for each entry type."""
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw123")
monkeypatch.setattr(
"password_manager.manager.confirm_action", lambda *a, **k: True
)
monkeypatch.setattr(
"password_manager.manager.copy_to_clipboard", lambda *a, **k: None
)
monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b")
monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None)
monkeypatch.setattr(
"password_manager.manager.TotpManager.print_qr_code", lambda *a, **k: None
)
monkeypatch.setattr(
"password_manager.manager.clear_header_with_notification",
lambda *a, **k: None,
)
monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None)
input_val = "r" if entry_type == "managed_account" else ""
monkeypatch.setattr("builtins.input", lambda *a, **k: input_val)
called = []
monkeypatch.setattr(
pm, "_entry_actions_menu", lambda *a, **k: called.append(True)
)
if entry_type == "password":
idx = entry_mgr.add_entry("example", 8)
expected = "pw123"
elif entry_type == "seed":
idx = entry_mgr.add_seed("seed", TEST_SEED, words_num=12)
expected = entry_mgr.get_seed_phrase(idx, TEST_SEED)
elif entry_type == "ssh":
idx = entry_mgr.add_ssh_key("ssh", TEST_SEED)
priv, pub = entry_mgr.get_ssh_key_pair(idx, TEST_SEED)
expected = priv
extra = pub
elif entry_type == "pgp":
idx = entry_mgr.add_pgp_key("pgp", TEST_SEED, user_id="test")
priv, fp = entry_mgr.get_pgp_key(idx, TEST_SEED)
expected = priv
extra = fp
elif entry_type == "nostr":
idx = entry_mgr.add_nostr_key("nostr")
_npub, nsec = entry_mgr.get_nostr_key_pair(idx, TEST_SEED)
expected = nsec
elif entry_type == "totp":
entry_mgr.add_totp("Example", TEST_SEED)
idx = 0
monkeypatch.setattr(
pm.entry_manager, "get_totp_code", lambda *a, **k: "123456"
)
monkeypatch.setattr(
pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1
)
expected = "123456"
elif entry_type == "key_value":
idx = entry_mgr.add_key_value("API", "abc")
expected = "abc"
else: # managed_account
idx = entry_mgr.add_managed_account("acct", TEST_SEED)
expected = entry_mgr.get_managed_account_seed(idx, TEST_SEED)
pm.show_entry_details_by_index(idx)
out = capsys.readouterr().out
assert expected in out
if entry_type in {"ssh", "pgp"}:
assert extra in out
assert called == [True]
@pytest.mark.parametrize(
"entry_type", [EntryType.PASSWORD, EntryType.TOTP, EntryType.KEY_VALUE]
)
def test_show_entry_details_with_enum_type(monkeypatch, capsys, entry_type):
"""Entries storing an EntryType enum should display correctly."""
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
if entry_type == EntryType.PASSWORD:
idx = entry_mgr.add_entry("example.com", 8)
expect = "example.com"
elif entry_type == EntryType.TOTP:
entry_mgr.add_totp("Example", TEST_SEED)
idx = 0
monkeypatch.setattr(
pm.entry_manager, "get_totp_code", lambda *a, **k: "123456"
)
monkeypatch.setattr(
pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1
)
expect = "Label: Example"
else: # KEY_VALUE
idx = entry_mgr.add_key_value("API", "abc")
expect = "API"
data = entry_mgr._load_index(force_reload=True)
data["entries"][str(idx)]["type"] = entry_type
entry_mgr._save_index(data)
called = _detail_common(monkeypatch, pm)
pm.show_entry_details_by_index(idx)
out = capsys.readouterr().out
assert expect in out
assert called == [True]

View File

@@ -41,11 +41,11 @@ def test_search_entries_prompt_for_details(monkeypatch, capsys):
monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None)
monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b")
inputs = iter(["Example", "0", "n", ""])
inputs = iter(["Example", "0"])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
pm.handle_search_entries()
out = capsys.readouterr().out
assert "0. Example" in out
assert "Retrieved 2FA Code" in out
assert "123456" in out
assert "Label: Example" in out
assert "Period: 30s" in out

View File

@@ -0,0 +1,67 @@
import builtins
from mnemonic import Mnemonic
from password_manager.manager import PasswordManager
from utils import seed_prompt
def test_validate_bip85_seed_invalid_word():
pm = PasswordManager.__new__(PasswordManager)
bad_phrase = "abandon " * 11 + "zzzz"
assert not pm.validate_bip85_seed(bad_phrase)
def test_validate_bip85_seed_checksum_failure():
pm = PasswordManager.__new__(PasswordManager)
# Use a known valid phrase to avoid randomness causing a valid checksum
phrase = (
"legal winner thank year wave sausage worth useful legal winner thank yellow"
)
words = phrase.split()
words[-1] = "abandon"
bad_phrase = " ".join(words)
assert not pm.validate_bip85_seed(bad_phrase)
def test_setup_existing_seed_words(monkeypatch):
m = Mnemonic("english")
phrase = m.generate(strength=128)
words = phrase.split()
word_iter = iter(words)
monkeypatch.setattr(
"password_manager.manager.masked_input",
lambda *_: next(word_iter),
)
# Ensure prompt_seed_words uses the patched function
monkeypatch.setattr(seed_prompt, "masked_input", lambda *_: next(word_iter))
monkeypatch.setattr(builtins, "input", lambda *_: "y")
pm = PasswordManager.__new__(PasswordManager)
monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed: seed)
result = pm.setup_existing_seed(method="words")
assert result == phrase
def test_setup_existing_seed_paste(monkeypatch):
m = Mnemonic("english")
phrase = m.generate(strength=128)
called = {}
def fake_masked_input(prompt: str) -> str:
called["prompt"] = prompt
return phrase
monkeypatch.setattr("password_manager.manager.masked_input", fake_masked_input)
monkeypatch.setattr(
builtins,
"input",
lambda *_: (_ for _ in ()).throw(RuntimeError("input called")),
)
pm = PasswordManager.__new__(PasswordManager)
monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed: seed)
result = pm.setup_existing_seed(method="paste")
assert result == phrase
assert called["prompt"].startswith("Enter your 12-word BIP-85 seed")

View File

@@ -39,7 +39,7 @@ class MockClient:
class FakeId:
def to_hex(self_inner):
return "abcd"
return "a" * 64
class FakeOutput:
def __init__(self):

View File

@@ -7,6 +7,7 @@ from password_manager.entry_management import EntryManager
from password_manager.backup import BackupManager
from password_manager.config_manager import ConfigManager
from nostr.client import prepare_snapshot
from nostr.backup_models import KIND_SNAPSHOT_CHUNK
def test_manifest_generation(tmp_path):
@@ -35,10 +36,18 @@ def test_retrieve_multi_chunk_snapshot(dummy_nostr_client):
data = os.urandom(120000)
manifest, _ = asyncio.run(client.publish_snapshot(data, limit=50000))
assert len(manifest.chunks) > 1
for meta in manifest.chunks:
assert meta.event_id
fetched_manifest, chunk_bytes = asyncio.run(client.fetch_latest_snapshot())
assert len(chunk_bytes) == len(manifest.chunks)
assert [c.event_id for c in fetched_manifest.chunks] == [
c.event_id for c in manifest.chunks
]
joined = b"".join(chunk_bytes)
assert gzip.decompress(joined) == data
for f in relay.filters:
if getattr(f, "kind_val", None) == KIND_SNAPSHOT_CHUNK:
assert f.id_called
def test_publish_and_fetch_deltas(dummy_nostr_client):
@@ -56,3 +65,70 @@ def test_publish_and_fetch_deltas(dummy_nostr_client):
assert relay.manifests[-1].delta_since == second_ts
deltas = asyncio.run(client.fetch_deltas_since(0))
assert deltas == [d1, d2]
def test_fetch_snapshot_fallback_on_missing_chunk(dummy_nostr_client, monkeypatch):
import os
import gzip
client, relay = dummy_nostr_client
monkeypatch.setattr("nostr.client.MAX_RETRIES", 3)
monkeypatch.setattr("nostr.client.RETRY_DELAY", 0)
data1 = os.urandom(60000)
manifest1, _ = asyncio.run(client.publish_snapshot(data1))
data2 = os.urandom(60000)
manifest2, _ = asyncio.run(client.publish_snapshot(data2))
missing = manifest2.chunks[0]
if missing.event_id:
relay.chunks.pop(missing.event_id, None)
relay.chunks.pop(missing.id, None)
relay.filters.clear()
fetched_manifest, chunk_bytes = asyncio.run(client.fetch_latest_snapshot())
assert gzip.decompress(b"".join(chunk_bytes)) == data1
assert [c.event_id for c in fetched_manifest.chunks] == [
c.event_id for c in manifest1.chunks
]
attempts = sum(
1
for f in relay.filters
if getattr(f, "kind_val", None) == KIND_SNAPSHOT_CHUNK
and (
missing.id in getattr(f, "ids", [])
or (missing.event_id and missing.event_id in getattr(f, "ids", []))
)
)
assert attempts == 3
def test_fetch_snapshot_uses_event_ids(dummy_nostr_client):
import os
import gzip
client, relay = dummy_nostr_client
data = os.urandom(60000)
manifest, _ = asyncio.run(client.publish_snapshot(data))
# Remove identifier keys so chunks can only be fetched via event_id
for meta in manifest.chunks:
relay.chunks.pop(meta.id, None)
relay.filters.clear()
fetched_manifest, chunk_bytes = asyncio.run(client.fetch_latest_snapshot())
assert gzip.decompress(b"".join(chunk_bytes)) == data
id_filters = [
f.id_called
for f in relay.filters
if getattr(f, "kind_val", None) == KIND_SNAPSHOT_CHUNK
]
assert id_filters and all(id_filters)

View File

@@ -93,3 +93,46 @@ def test_show_private_key_qr(monkeypatch, capsys):
out = capsys.readouterr().out
assert called == [nsec]
assert color_text(f"nsec: {nsec}", "deterministic") in out
def test_qr_menu_case_insensitive(monkeypatch):
"""QR menu should appear even if entry type is uppercase."""
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = vault
pm.entry_manager = entry_mgr
pm.backup_manager = backup_mgr
pm.parent_seed = TEST_SEED
pm.nostr_client = FakeNostrClient()
pm.fingerprint_dir = tmp_path
pm.is_dirty = False
pm.secret_mode_enabled = False
idx = entry_mgr.add_nostr_key("main")
npub, _ = entry_mgr.get_nostr_key_pair(idx, TEST_SEED)
# Modify index to use uppercase type/kind
data = enc_mgr.load_json_data(entry_mgr.index_file)
data["entries"][str(idx)]["type"] = "NOSTR"
data["entries"][str(idx)]["kind"] = "NOSTR"
enc_mgr.save_json_data(data, entry_mgr.index_file)
entry_mgr._index_cache = None
inputs = iter([str(idx), "q", "p", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
called = []
monkeypatch.setattr(
"password_manager.manager.TotpManager.print_qr_code",
lambda data: called.append(data),
)
pm.handle_retrieve_entry()
assert called == [f"nostr:{npub}"]

View File

@@ -68,6 +68,8 @@ class DummyClient:
def test_fetch_latest_snapshot():
data = b"seedpass" * 1000
manifest, chunks = prepare_snapshot(data, 50000)
for i, m in enumerate(manifest.chunks):
m.event_id = f"{i:064x}"
manifest_json = json.dumps(
{
"ver": manifest.ver,
@@ -98,3 +100,6 @@ def test_fetch_latest_snapshot():
assert manifest == result_manifest
assert result_chunks == chunks
assert [c.event_id for c in manifest.chunks] == [
c.event_id for c in result_manifest.chunks
]

View File

@@ -9,16 +9,14 @@ from utils import password_prompt
def test_prompt_new_password(monkeypatch):
responses = cycle(["goodpass", "goodpass"])
monkeypatch.setattr(
password_prompt.getpass, "getpass", lambda prompt: next(responses)
)
monkeypatch.setattr(password_prompt, "masked_input", lambda prompt: next(responses))
result = password_prompt.prompt_new_password()
assert result == "goodpass"
def test_prompt_new_password_retry(monkeypatch, caplog):
seq = iter(["pass1", "pass2", "passgood", "passgood"])
monkeypatch.setattr(password_prompt.getpass, "getpass", lambda prompt: next(seq))
monkeypatch.setattr(password_prompt, "masked_input", lambda prompt: next(seq))
caplog.set_level(logging.WARNING)
result = password_prompt.prompt_new_password()
assert "User entered a password shorter" in caplog.text
@@ -26,7 +24,7 @@ def test_prompt_new_password_retry(monkeypatch, caplog):
def test_prompt_existing_password(monkeypatch):
monkeypatch.setattr(password_prompt.getpass, "getpass", lambda prompt: "mypassword")
monkeypatch.setattr(password_prompt, "masked_input", lambda prompt: "mypassword")
assert password_prompt.prompt_existing_password() == "mypassword"

View File

@@ -9,12 +9,17 @@ import main
def test_handle_post_success(capsys):
pm = SimpleNamespace(
sync_vault=lambda alt_summary=None: "abcd",
sync_vault=lambda alt_summary=None: {
"manifest_id": "abcd",
"chunk_ids": ["c1", "c2"],
"delta_ids": ["d1"],
},
)
main.handle_post_to_nostr(pm)
out = capsys.readouterr().out
assert "✅ Sync complete." in out
assert "abcd" in out
assert "c1" in out and "c2" in out and "d1" in out
def test_handle_post_failure(capsys):
@@ -24,3 +29,24 @@ def test_handle_post_failure(capsys):
main.handle_post_to_nostr(pm)
out = capsys.readouterr().out
assert "❌ Sync failed…" in out
def test_handle_post_prints_all_ids(capsys):
pm = SimpleNamespace(
sync_vault=lambda alt_summary=None: {
"manifest_id": "m1",
"chunk_ids": ["c1", "c2"],
"delta_ids": ["d1", "d2"],
}
)
main.handle_post_to_nostr(pm)
out_lines = capsys.readouterr().out.splitlines()
expected = [
" manifest: m1",
" chunk: c1",
" chunk: c2",
" delta: d1",
" delta: d2",
]
for line in expected:
assert any(line in ol for ol in out_lines)

View File

@@ -41,7 +41,7 @@ def test_add_and_delete_entry(monkeypatch):
manager_module.PasswordManager, "generate_bip85_seed", lambda self: seed
)
monkeypatch.setattr(manager_module, "confirm_action", lambda *_a, **_k: True)
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "2")
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "3")
pm.add_new_fingerprint()

View File

@@ -6,6 +6,9 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
from utils.fingerprint_manager import FingerprintManager
from password_manager.manager import PasswordManager, EncryptionMode
from helpers import create_vault, dummy_nostr_client
import gzip
from nostr.backup_models import Manifest, ChunkMeta
VALID_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
@@ -51,3 +54,55 @@ def test_add_and_switch_fingerprint(monkeypatch):
assert pm.current_fingerprint == fingerprint
assert fm.current_fingerprint == fingerprint
assert pm.fingerprint_dir == expected_dir
def test_sync_index_missing_bad_data(monkeypatch, dummy_nostr_client):
client, _relay = dummy_nostr_client
with TemporaryDirectory() as tmpdir:
dir_path = Path(tmpdir)
vault, _enc = create_vault(dir_path)
pm = PasswordManager.__new__(PasswordManager)
pm.fingerprint_dir = dir_path
pm.vault = vault
pm.nostr_client = client
pm.sync_vault = lambda *a, **k: None
manifest = Manifest(
ver=1,
algo="aes-gcm",
chunks=[ChunkMeta(id="c0", size=1, hash="00")],
delta_since=None,
)
monkeypatch.setattr(
client,
"fetch_latest_snapshot",
lambda: (manifest, [gzip.compress(b"garbage")]),
)
monkeypatch.setattr(client, "fetch_deltas_since", lambda *_a, **_k: [])
result = pm.attempt_initial_sync()
assert result is False
index_path = dir_path / "seedpass_entries_db.json.enc"
assert not index_path.exists()
def test_attempt_initial_sync_incomplete_data(monkeypatch, dummy_nostr_client):
client, _relay = dummy_nostr_client
with TemporaryDirectory() as tmpdir:
dir_path = Path(tmpdir)
vault, _enc = create_vault(dir_path)
pm = PasswordManager.__new__(PasswordManager)
pm.fingerprint_dir = dir_path
pm.vault = vault
pm.nostr_client = client
pm.sync_vault = lambda *a, **k: None
# Simulate relay snapshot retrieval failure due to missing chunks
monkeypatch.setattr(client, "fetch_latest_snapshot", lambda: None)
result = pm.attempt_initial_sync()
assert result is False
index_path = dir_path / "seedpass_entries_db.json.enc"
assert not index_path.exists()

View File

@@ -0,0 +1,58 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
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
import pytest
@pytest.mark.parametrize(
"adder,needs_confirm",
[
(lambda mgr: mgr.add_seed("seed", TEST_SEED), True),
(lambda mgr: mgr.add_pgp_key("pgp", TEST_SEED, user_id="test"), True),
(lambda mgr: mgr.add_ssh_key("ssh", TEST_SEED), True),
(lambda mgr: mgr.add_nostr_key("nostr"), False),
],
)
def test_pause_before_entry_actions(monkeypatch, adder, needs_confirm):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = vault
pm.entry_manager = entry_mgr
pm.backup_manager = backup_mgr
pm.parent_seed = TEST_SEED
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
index = adder(entry_mgr)
pause_calls = []
monkeypatch.setattr(
"password_manager.manager.pause", lambda *a, **k: pause_calls.append(True)
)
monkeypatch.setattr(pm, "_entry_actions_menu", lambda *a, **k: None)
monkeypatch.setattr("builtins.input", lambda *a, **k: str(index))
if needs_confirm:
monkeypatch.setattr(
"password_manager.manager.confirm_action", lambda *a, **k: True
)
pm.handle_retrieve_entry()
assert len(pause_calls) == 1

View File

@@ -0,0 +1,76 @@
import types
from utils import seed_prompt
def test_masked_input_posix_backspace(monkeypatch, capsys):
seq = iter(["a", "b", "\x7f", "c", "\n"])
monkeypatch.setattr(seed_prompt.sys.stdin, "read", lambda n=1: next(seq))
monkeypatch.setattr(seed_prompt.sys.stdin, "fileno", lambda: 0)
if seed_prompt.termios is None:
fake_termios = types.SimpleNamespace(
tcgetattr=lambda fd: None,
tcsetattr=lambda fd, *_: None,
TCSADRAIN=1,
)
monkeypatch.setattr(seed_prompt, "termios", fake_termios)
else:
monkeypatch.setattr(seed_prompt.termios, "tcgetattr", lambda fd: None)
monkeypatch.setattr(seed_prompt.termios, "tcsetattr", lambda fd, *_: None)
if seed_prompt.tty is None:
fake_tty = types.SimpleNamespace(setraw=lambda fd: None)
monkeypatch.setattr(seed_prompt, "tty", fake_tty)
else:
monkeypatch.setattr(seed_prompt.tty, "setraw", lambda fd: None)
monkeypatch.setattr(seed_prompt.sys, "platform", "linux", raising=False)
result = seed_prompt.masked_input("Enter: ")
assert result == "ac"
out = capsys.readouterr().out
assert out.startswith("Enter: ")
assert out.count("*") == 3
def test_masked_input_windows_space(monkeypatch, capsys):
seq = iter(["x", "y", " ", "z", "\r"])
fake_msvcrt = types.SimpleNamespace(getwch=lambda: next(seq))
monkeypatch.setattr(seed_prompt, "msvcrt", fake_msvcrt)
monkeypatch.setattr(seed_prompt.sys, "platform", "win32", raising=False)
result = seed_prompt.masked_input("Password: ")
assert result == "xy z"
out = capsys.readouterr().out
assert out.startswith("Password: ")
assert out.count("*") == 4
def test_prompt_seed_words_valid(monkeypatch):
from mnemonic import Mnemonic
m = Mnemonic("english")
phrase = m.generate(strength=128)
words = phrase.split()
word_iter = iter(words)
monkeypatch.setattr(seed_prompt, "masked_input", lambda *_: next(word_iter))
monkeypatch.setattr("builtins.input", lambda *_: "y")
result = seed_prompt.prompt_seed_words(len(words))
assert result == phrase
def test_prompt_seed_words_invalid_word(monkeypatch):
from mnemonic import Mnemonic
m = Mnemonic("english")
phrase = m.generate(strength=128)
words = phrase.split()
# Insert an invalid word for the first entry then the correct one
inputs = iter(["invalid"] + words)
monkeypatch.setattr(seed_prompt, "masked_input", lambda *_: next(inputs))
monkeypatch.setattr("builtins.input", lambda *_: "y")
result = seed_prompt.prompt_seed_words(len(words))
assert result == phrase

View File

@@ -288,7 +288,11 @@ def test_nostr_sync(monkeypatch):
def sync_vault():
called["called"] = True
return "evt123"
return {
"manifest_id": "evt123",
"chunk_ids": ["c1"],
"delta_ids": ["d1"],
}
pm = SimpleNamespace(sync_vault=sync_vault, select_fingerprint=lambda fp: None)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
@@ -296,6 +300,8 @@ def test_nostr_sync(monkeypatch):
assert result.exit_code == 0
assert called.get("called") is True
assert "evt123" in result.stdout
assert "c1" in result.stdout
assert "d1" in result.stdout
def test_generate_password(monkeypatch):

View File

@@ -25,6 +25,7 @@ try:
update_checksum_file,
)
from .password_prompt import prompt_for_password
from .seed_prompt import masked_input, prompt_seed_words
from .input_utils import timed_input
from .memory_protection import InMemorySecret
from .clipboard import copy_to_clipboard
@@ -58,6 +59,8 @@ __all__ = [
"exclusive_lock",
"shared_lock",
"prompt_for_password",
"masked_input",
"prompt_seed_words",
"timed_input",
"InMemorySecret",
"copy_to_clipboard",

View File

@@ -34,7 +34,11 @@ class FingerprintManager:
self.app_dir = app_dir
self.fingerprints_file = self.app_dir / "fingerprints.json"
self._ensure_app_directory()
self.fingerprints, self.current_fingerprint = self._load_fingerprints()
(
self.fingerprints,
self.current_fingerprint,
self.names,
) = self._load_fingerprints()
def get_current_fingerprint_dir(self) -> Optional[Path]:
"""
@@ -62,25 +66,26 @@ class FingerprintManager:
)
raise
def _load_fingerprints(self) -> tuple[list[str], Optional[str]]:
"""Return stored fingerprints and the last used fingerprint."""
def _load_fingerprints(self) -> tuple[list[str], Optional[str], dict[str, str]]:
"""Return stored fingerprints, the last used fingerprint, and name mapping."""
try:
if self.fingerprints_file.exists():
with open(self.fingerprints_file, "r") as f:
data = json.load(f)
fingerprints = data.get("fingerprints", [])
current = data.get("last_used")
names = data.get("names", {})
logger.debug(
f"Loaded fingerprints: {fingerprints} (last used: {current})"
)
return fingerprints, current
return fingerprints, current, names
logger.debug(
"fingerprints.json not found. Initializing empty fingerprint list."
)
return [], None
return [], None, {}
except Exception as e:
logger.error(f"Failed to load fingerprints: {e}", exc_info=True)
return [], None
return [], None, {}
def _save_fingerprints(self):
"""
@@ -92,6 +97,7 @@ class FingerprintManager:
{
"fingerprints": self.fingerprints,
"last_used": self.current_fingerprint,
"names": self.names,
},
f,
indent=4,
@@ -116,6 +122,7 @@ class FingerprintManager:
fingerprint = generate_fingerprint(seed_phrase)
if fingerprint and fingerprint not in self.fingerprints:
self.fingerprints.append(fingerprint)
self.names.setdefault(fingerprint, "")
self.current_fingerprint = fingerprint
self._save_fingerprints()
logger.info(f"Fingerprint {fingerprint} added successfully.")
@@ -144,6 +151,7 @@ class FingerprintManager:
if fingerprint in self.fingerprints:
try:
self.fingerprints.remove(fingerprint)
self.names.pop(fingerprint, None)
if self.current_fingerprint == fingerprint:
self.current_fingerprint = (
self.fingerprints[0] if self.fingerprints else None
@@ -198,6 +206,26 @@ class FingerprintManager:
logger.error(f"Fingerprint {fingerprint} not found.")
return False
def set_name(self, fingerprint: str, name: str | None) -> bool:
"""Set a custom name for a fingerprint."""
if fingerprint not in self.fingerprints:
return False
if name:
self.names[fingerprint] = name
else:
self.names.pop(fingerprint, None)
self._save_fingerprints()
return True
def get_name(self, fingerprint: str) -> Optional[str]:
"""Return the custom name for ``fingerprint`` if set."""
return self.names.get(fingerprint) or None
def display_name(self, fingerprint: str) -> str:
"""Return name and fingerprint for display."""
name = self.get_name(fingerprint)
return f"{name} ({fingerprint})" if name else fingerprint
def get_fingerprint_directory(self, fingerprint: str) -> Optional[Path]:
"""
Retrieves the directory path for a given fingerprint.

View File

@@ -11,11 +11,10 @@ this module enhances code reuse, security, and maintainability across the applic
Ensure that all dependencies are installed and properly configured in your environment.
"""
import getpass
from utils.seed_prompt import masked_input
import logging
import sys
import unicodedata
import traceback
from termcolor import colored
from colorama import init as colorama_init
@@ -53,8 +52,8 @@ def prompt_new_password() -> str:
while attempts < max_retries:
try:
password = getpass.getpass(prompt="Enter a new password: ").strip()
confirm_password = getpass.getpass(prompt="Confirm your password: ").strip()
password = masked_input("Enter a new password: ").strip()
confirm_password = masked_input("Confirm your password: ").strip()
if not password:
print(
@@ -128,7 +127,7 @@ def prompt_existing_password(
attempts = 0
while attempts < max_retries:
try:
password = getpass.getpass(prompt=prompt_message).strip()
password = masked_input(prompt_message).strip()
if not password:
print(

152
src/utils/seed_prompt.py Normal file
View File

@@ -0,0 +1,152 @@
import os
import sys
try:
import msvcrt # type: ignore
except ImportError: # pragma: no cover - Windows only
msvcrt = None # type: ignore
try:
import termios
import tty
except ImportError: # pragma: no cover - POSIX only
termios = None # type: ignore
tty = None # type: ignore
from utils.terminal_utils import clear_screen
def _masked_input_windows(prompt: str) -> str:
"""Windows implementation using ``msvcrt``."""
if msvcrt is None: # pragma: no cover - should not happen
return input(prompt)
sys.stdout.write(prompt)
sys.stdout.flush()
buffer: list[str] = []
while True:
ch = msvcrt.getwch()
if ch in ("\r", "\n"):
sys.stdout.write("\n")
return "".join(buffer)
if ch in ("\b", "\x7f"):
if buffer:
buffer.pop()
sys.stdout.write("\b \b")
else:
buffer.append(ch)
sys.stdout.write("*")
sys.stdout.flush()
def _masked_input_posix(prompt: str) -> str:
"""POSIX implementation using ``termios`` and ``tty``."""
if termios is None or tty is None: # pragma: no cover - should not happen
return input(prompt)
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
sys.stdout.write(prompt)
sys.stdout.flush()
buffer: list[str] = []
try:
tty.setraw(fd)
while True:
ch = sys.stdin.read(1)
if ch in ("\r", "\n"):
sys.stdout.write("\n")
return "".join(buffer)
if ch in ("\x7f", "\b"):
if buffer:
buffer.pop()
sys.stdout.write("\b \b")
else:
buffer.append(ch)
sys.stdout.write("*")
sys.stdout.flush()
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
def masked_input(prompt: str) -> str:
"""Return input from the user while masking typed characters."""
if sys.platform == "win32":
return _masked_input_windows(prompt)
return _masked_input_posix(prompt)
def prompt_seed_words(count: int = 12) -> str:
"""Prompt the user for a BIP-39 seed phrase.
The user is asked for each word one at a time. A numbered list is
displayed showing ``*`` for entered words and ``_`` for words yet to be
provided. After all words are entered the user is asked to confirm each
word individually. If the user answers ``no`` to a confirmation prompt the
word can be re-entered.
Parameters
----------
count:
Number of words to prompt for. Defaults to ``12``.
Returns
-------
str
The complete seed phrase.
Raises
------
ValueError
If the resulting phrase fails ``Mnemonic.check`` validation.
"""
from mnemonic import Mnemonic
m = Mnemonic("english")
words: list[str] = [""] * count
idx = 0
while idx < count:
clear_screen()
progress = [f"{i+1}: {'*' if w else '_'}" for i, w in enumerate(words)]
print("\n".join(progress))
entered = masked_input(f"Enter word number {idx+1}: ").strip().lower()
if entered not in m.wordlist:
print("Invalid word, try again.")
continue
words[idx] = entered
idx += 1
for i in range(count):
while True:
clear_screen()
progress = [f"{j+1}: {'*' if j < i else '_'}" for j in range(count)]
print("\n".join(progress))
response = (
input(f"Is this the correct word for number {i+1}? {words[i]} (Y/N): ")
.strip()
.lower()
)
if response in ("y", "yes"):
break
if response in ("n", "no"):
while True:
clear_screen()
progress = [f"{j+1}: {'*' if j < i else '_'}" for j in range(count)]
print("\n".join(progress))
new_word = (
masked_input(f"Re-enter word number {i+1}: ").strip().lower()
)
if new_word in m.wordlist:
words[i] = new_word
break
print("Invalid word, try again.")
# Ask for confirmation again with the new word
else:
print("Please respond with 'Y' or 'N'.")
continue
phrase = " ".join(words)
if not m.check(phrase):
raise ValueError("Invalid BIP-39 seed phrase")
return phrase

View File

@@ -8,6 +8,20 @@ from termcolor import colored
from utils.color_scheme import color_text
def format_profile(fingerprint: str | None, pm=None) -> str | None:
"""Return display string for a fingerprint with optional custom name."""
if not fingerprint:
return None
if pm and getattr(pm, "fingerprint_manager", None):
try:
name = pm.fingerprint_manager.get_name(fingerprint)
if name:
return f"{name} ({fingerprint})"
except Exception:
pass
return fingerprint
def clear_screen() -> None:
"""Clear the terminal screen using an ANSI escape code."""
print("\033c", end="")
@@ -18,16 +32,17 @@ def clear_and_print_fingerprint(
breadcrumb: str | None = None,
parent_fingerprint: str | None = None,
child_fingerprint: str | None = None,
pm=None,
) -> None:
"""Clear the screen and optionally display the current fingerprint and path."""
clear_screen()
header_fp = None
if parent_fingerprint and child_fingerprint:
header_fp = f"{parent_fingerprint} > Managed Account > {child_fingerprint}"
header_fp = f"{format_profile(parent_fingerprint, pm)} > Managed Account > {format_profile(child_fingerprint, pm)}"
elif fingerprint:
header_fp = fingerprint
header_fp = format_profile(fingerprint, pm)
elif parent_fingerprint or child_fingerprint:
header_fp = parent_fingerprint or child_fingerprint
header_fp = format_profile(parent_fingerprint or child_fingerprint, pm)
if header_fp:
header = f"Seed Profile: {header_fp}"
if breadcrumb:
@@ -36,15 +51,15 @@ def clear_and_print_fingerprint(
def clear_and_print_profile_chain(
fingerprints: list[str] | None, breadcrumb: str | None = None
fingerprints: list[str] | None, breadcrumb: str | None = None, pm=None
) -> None:
"""Clear the screen and display a chain of fingerprints."""
clear_screen()
if not fingerprints:
return
chain = fingerprints[0]
chain = format_profile(fingerprints[0], pm)
for fp in fingerprints[1:]:
chain += f" > Managed Account > {fp}"
chain += f" > Managed Account > {format_profile(fp, pm)}"
header = f"Seed Profile: {chain}"
if breadcrumb:
header += f" > {breadcrumb}"
@@ -63,11 +78,11 @@ def clear_header_with_notification(
clear_screen()
header_fp = None
if parent_fingerprint and child_fingerprint:
header_fp = f"{parent_fingerprint} > Managed Account > {child_fingerprint}"
header_fp = f"{format_profile(parent_fingerprint, pm)} > Managed Account > {format_profile(child_fingerprint, pm)}"
elif fingerprint:
header_fp = fingerprint
header_fp = format_profile(fingerprint, pm)
elif parent_fingerprint or child_fingerprint:
header_fp = parent_fingerprint or child_fingerprint
header_fp = format_profile(parent_fingerprint or child_fingerprint, pm)
if header_fp:
header = f"Seed Profile: {header_fp}"
if breadcrumb: