Merge pull request #173 from PR0M3TH3AN/beta

Beta
This commit is contained in:
thePR0M3TH3AN
2025-07-02 23:34:53 -04:00
committed by GitHub
71 changed files with 1632 additions and 781 deletions

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 ensures that your passwords are safe and accessible across devices. The index for retrieving each password is securely stored on Nostr relays, allowing seamless password recovery on multiple devices without compromising security.
**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)
@@ -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. For instance, the maximum size of the index before the Nostr backup starts to have problems is unknown. Additionally, the security of the program's memory management and logs has not been evaluated and may leak sensitive information.
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.
---
### Supported OS
@@ -42,6 +42,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.
- **Checksum Verification:** Ensure the integrity of the script with checksum verification.
- **Multiple Seed Profiles:** Manage separate seed profiles and switch between them seamlessly.
- **Interactive TUI:** Navigate through menus to add, retrieve, and modify entries as well as configure Nostr settings.
@@ -112,11 +113,29 @@ SeedPass and create a backup:
# Start the application
python src/main.py
# Export your index using seed-only encryption
seedpass export --mode seed-only --file "~/seedpass_backup.json"
# Export your index
seedpass export --file "~/seedpass_backup.json"
# Later you can restore it
seedpass import --mode seed-only --file "~/seedpass_backup.json"
seedpass import --file "~/seedpass_backup.json"
```
### 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": {
"website": "example.com",
"length": 8,
"type": "password",
"notes": ""
}
}
}
```
@@ -155,29 +174,6 @@ python src/main.py
Enter your choice (1-5):
```
### Encryption Mode
Use the `--encryption-mode` flag to control how SeedPass derives the key used to
encrypt your vault. Valid values are:
- `seed-only` default mode that derives the vault key solely from your BIP-85
seed.
- `seed+pw` combines the seed with your master password for key derivation.
- `pw-only` derives the key from your password alone.
You can set this option when launching the application:
```bash
python src/main.py --encryption-mode seed+pw
```
To make the choice persistent, add it to `~/.seedpass/config.toml`:
```toml
encryption_mode = "seed+pw"
```
SeedPass will read this value on startup and use the specified mode by default.
### Managing Multiple Seeds
@@ -233,11 +229,23 @@ Back in the Settings menu you can:
SeedPass includes a small suite of unit tests located under `src/tests`. After activating your virtual environment and installing dependencies, 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
```
### 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
NOSTR_TEST_DELAY=10 pytest -vv src/tests/test_nostr_index_size.py -m "desktop and network"
```
### Automatically Updating the Script Checksum
SeedPass stores a SHA-256 checksum for the main program in `~/.seedpass/seedpass_script_checksum.txt`.
@@ -275,7 +283,7 @@ Mutation testing is disabled in the GitHub workflow due to reliability issues an
- **Protect Your Passwords:** Do not share your master password or seed phrases with anyone and ensure they are strong and unique.
- **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. The maximum size of the password index before encountering issues with Nostr backups is unknown. Additionally, 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 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.

View File

@@ -25,17 +25,17 @@ The **Advanced CLI Commands** document provides an in-depth guide to the various
- [13. Disable Auto-Lock](#13-disable-auto-lock)
- [14. Generate a Strong Password](#14-generate-a-strong-password)
- [15. Verify Script Checksum](#15-verify-script-checksum)
- [16. Post Encrypted Index to Nostr](#16-post-encrypted-index-to-nostr)
- [16. Post Encrypted Snapshots to Nostr](#16-post-encrypted-snapshots-to-nostr)
- [17. Retrieve from Nostr](#17-retrieve-from-nostr)
- [18. Display Nostr Public Key](#18-display-nostr-public-key)
- [19. Set Custom Nostr Relays](#19-set-custom-nostr-relays)
- [20. Enable "Secret" Mode](#20-enable-secret-mode)
- [21. Batch Post Index Items to Nostr](#21-batch-post-index-items-to-nostr)
- [21. Batch Post Snapshot Deltas to Nostr](#21-batch-post-snapshot-deltas-to-nostr)
- [22. Show All Passwords](#22-show-all-passwords)
- [23. Add Notes to an Entry](#23-add-notes-to-an-entry)
- [24. Add Tags to an Entry](#24-add-tags-to-an-entry)
- [25. Search by Tag or Title](#25-search-by-tag-or-title)
- [26. Automatically Post Index to Nostr After Edit](#26-automatically-post-index-to-nostr-after-edit)
- [26. Automatically Post Deltas to Nostr After Edit](#26-automatically-post-deltas-to-nostr-after-edit)
- [27. Initial Setup Prompt for Seed Generation/Import](#27-initial-setup-prompt-for-seed-generationimport)
3. [Notes on New CLI Commands](#notes-on-new-cli-commands)
@@ -62,17 +62,17 @@ The following table provides a quick reference to all available advanced CLI com
| Disable auto-lock | `autolock --disable` | `-DL` | `--auto-lock --disable` | `seedpass autolock --disable` |
| Generate a strong password | `generate` | `-G` | `--generate` | `seedpass generate --length 20` |
| Verify script checksum | `verify` | `-V` | `--verify` | `seedpass verify` |
| Post encrypted index to Nostr | `post` | `-P` | `--post` | `seedpass post` |
| Retrieve from Nostr | `get-nostr` | `-GN` | `--get-nostr` | `seedpass get-nostr` |
| Post encrypted snapshots to Nostr | `post` | `-P` | `--post` | `seedpass post` |
| Retrieve snapshots from Nostr | `get-nostr` | `-GN` | `--get-nostr` | `seedpass get-nostr` |
| Display Nostr public key | `show-pubkey` | `-K` | `--show-pubkey` | `seedpass show-pubkey` |
| Set Custom Nostr Relays | `set-relays` | `-SR` | `--set-relays` | `seedpass set-relays --add "wss://relay1.example.com" --add "wss://relay2.example.com"` |
| Enable "Secret" Mode | `set-secret` | `-SS` | `--set-secret` | `seedpass set-secret --enable` or `seedpass set-secret --disable` |
| Batch Post Index Items to Nostr | `batch-post` | `-BP` | `--batch-post` | `seedpass batch-post --start 0 --end 9` or `seedpass batch-post --range 10-19` |
| Batch Post Snapshot Deltas to Nostr | `batch-post` | `-BP` | `--batch-post` | `seedpass batch-post --start 0 --end 9` or `seedpass batch-post --range 10-19` |
| Show All Passwords | `show-all` | `-SA` | `--show-all` | `seedpass show-all` |
| Add Notes to an Entry | `add-notes` | `-AN` | `--add-notes` | `seedpass add-notes --index 3 --notes "This is a secured account"` |
| Add Tags to an Entry | `add-tags` | `-AT` | `--add-tags` | `seedpass add-tags --index 3 --tags "personal,finance"` |
| Search by Tag or Title | `search-by` | `-SB` | `--search-by` | `seedpass search-by --tag "work"` or `seedpass search-by --title "GitHub"` |
| Automatically Post Index to Nostr After Edit | `auto-post` | `-AP` | `--auto-post` | `seedpass auto-post --enable` or `seedpass auto-post --disable` |
| Automatically Post Deltas After Edit | `auto-post` | `-AP` | `--auto-post` | `seedpass auto-post --enable` or `seedpass auto-post --disable` |
| Initial Setup Prompt for Seed Generation/Import | `setup` | `-ST` | `--setup` | `seedpass setup` |
---
@@ -219,23 +219,6 @@ seedpass export --file "backup_passwords.json"
**Options:**
- `--file` (`-F`): The destination file path for the exported data. If omitted, the export
is saved to the current profile's `exports` directory under `~/.seedpass/<profile>/exports/`.
- `--mode` (`-M`): Choose the encryption mode for the exported file. Valid values are:
`seed-only`, `seed+pw`, `pw-only`, and `plaintext`.
**Examples:**
```bash
# Standard encrypted export
seedpass export --mode seed-only --file "backup.json"
# Combine seed and master password for the export key
seedpass export --mode seed+pw --file "backup.json"
# Derive the key solely from your password
seedpass export --mode pw-only --file "backup.json"
# Plaintext JSON export (not recommended)
seedpass export --mode plaintext --file "backup.json"
```
**Warning:** The `plaintext` mode writes an unencrypted index to disk. Only use it
for debugging and delete the file immediately after use.
---
@@ -255,15 +238,6 @@ seedpass import --file "backup_passwords.json"
**Options:**
- `--file` (`-F`): The source file path containing the password entries to import.
- `--mode` (`-M`): Indicates the encryption mode used when the file was exported. Accepted values are `seed-only`, `seed+pw`, `pw-only`, and `plaintext`.
**Examples:**
```bash
# Import a standard encrypted backup
seedpass import --mode seed-only --file "backup.json"
# Import a backup that also used the master password
seedpass import --mode seed+pw --file "backup.json"
```
---
@@ -389,14 +363,14 @@ seedpass verify
---
### 16. Post Encrypted Index to Nostr
### 16. Post Encrypted Snapshots to Nostr
**Command:** `post`
**Short Flag:** `-P`
**Long Flag:** `--post`
**Description:**
Posts the encrypted password index to the Nostr network, facilitating secure backups and synchronization across devices.
Posts encrypted snapshot chunks of the index to the Nostr network, followed by compact delta events for subsequent changes. This approach enables reliable backups and efficient synchronization across devices.
**Usage Example:**
```bash
@@ -412,7 +386,7 @@ seedpass post
**Long Flag:** `--get-nostr`
**Description:**
Retrieves the encrypted password index from the Nostr network, allowing users to restore their password data on a new device.
Retrieves the encrypted snapshot chunks and any delta events from the Nostr network, allowing users to reconstruct the latest index on a new device.
**Usage Example:**
```bash
@@ -444,7 +418,7 @@ seedpass show-pubkey
**Long Flag:** `--set-relays`
**Description:**
Allows users to specify custom Nostr relays for publishing their encrypted backup index, providing flexibility and control over data distribution.
Allows users to specify custom Nostr relays for publishing their encrypted backup snapshots, providing flexibility and control over data distribution.
Relay URLs are stored in an encrypted configuration file located in `~/.seedpass/<fingerprint>/seedpass_config.json.enc` and loaded each time the Nostr client starts. New accounts use the following default relays until changed:
```
@@ -484,14 +458,14 @@ seedpass set-secret --disable
---
### 21. Batch Post Index Items to Nostr
### 21. Batch Post Snapshot Deltas to Nostr
**Command:** `batch-post`
**Short Flag:** `-BP`
**Long Flag:** `--batch-post`
**Description:**
Posts a specified range of index items to the Nostr network in batches, ensuring efficient and manageable data transmission.
Posts a specified range of snapshot delta events to the Nostr network in batches, ensuring efficient and manageable data transmission.
**Usage Examples:**
```bash
@@ -583,14 +557,14 @@ seedpass search-by --title "GitHub"
---
### 26. Automatically Post Index to Nostr After Edit
### 26. Automatically Post Deltas to Nostr After Edit
**Command:** `auto-post`
**Short Flag:** `-AP`
**Long Flag:** `--auto-post`
**Description:**
Enables or disables the automatic posting of the password index to the Nostr network whenever an edit occurs, ensuring real-time backups.
Enables or disables the automatic posting of snapshot delta events to the Nostr network whenever an edit occurs, ensuring real-time backups.
**Usage Examples:**
```bash
@@ -621,7 +595,7 @@ seedpass setup
**Features to Implement:**
- **Seed Choice Prompt:** Asks users whether they want to generate a new seed or import an existing one.
- **Encryption of Seed:** Uses the user-selected password to encrypt the seed, whether generated or imported.
- **Profile Creation:** Upon first login, automatically generates a profile and checks for existing index data notes that can be pulled and decrypted.
- **Profile Creation:** Upon first login, automatically generates a profile and checks for existing snapshot data that can be pulled and decrypted.
---
@@ -782,8 +756,8 @@ seedpass fingerprint rename A1B2C3D4 PersonalProfile
## Notes on New CLI Commands
1. **Automatically Post Index to Nostr After Edit (`auto-post`):**
- **Purpose:** Enables or disables the automatic posting of the index to Nostr whenever an edit occurs.
1. **Automatically Post Deltas to Nostr After Edit (`auto-post`):**
- **Purpose:** Enables or disables the automatic posting of snapshot deltas to Nostr whenever an edit occurs.
- **Usage Examples:**
- Enable auto-post: `seedpass auto-post --enable`
- Disable auto-post: `seedpass auto-post --disable`
@@ -793,7 +767,7 @@ seedpass fingerprint rename A1B2C3D4 PersonalProfile
- **Features to Implement:**
- **Seed Choice Prompt:** Ask users whether they want to generate a new seed or import an existing one.
- **Encryption of Seed:** Use the user-selected password to encrypt the seed, whether generated or imported.
- **Profile Creation:** Upon first login, automatically generate a profile and check for existing index data notes that can be pulled and decrypted.
- **Profile Creation:** Upon first login, automatically generate a profile and check for existing snapshot data that can be pulled and decrypted.
- **Usage Example:** `seedpass setup`
3. **Advanced CLI Enhancements:**
@@ -807,8 +781,8 @@ seedpass fingerprint rename A1B2C3D4 PersonalProfile
- **Description:** When running `seedpass setup`, prompts users to either enter an existing seed or generate a new one, followed by password creation for encryption.
- **Usage Example:** `seedpass setup`
- **Automatic Profile Generation and Index Retrieval:**
- **Description:** During the initial setup or first login, generates a profile and attempts to retrieve and decrypt any existing index data from Nostr.
- **Automatic Profile Generation and Snapshot Retrieval:**
- **Description:** During the initial setup or first login, generates a profile and attempts to retrieve and decrypt any existing snapshots and deltas from Nostr.
- **Usage Example:** `seedpass setup` (handles internally)
---

View File

@@ -29,7 +29,27 @@
## Introduction
**SeedPass** is a secure password generator and manager leveraging **Bitcoin's BIP-85 standard** and integrating with the **Nostr network** for decentralized synchronization. To enhance modularity, scalability, and security, SeedPass now manages each password or data entry as a separate JSON file within a **Fingerprint-Based Backup and Local Storage** system. This document outlines the new entry management system, ensuring that new `kind` types can be added seamlessly without disrupting existing functionalities.
**SeedPass** is a secure password generator and manager leveraging **Bitcoin's BIP-85 standard** and integrating with the **Nostr network** for decentralized synchronization. Instead of pushing one large index file, SeedPass posts **snapshot chunks** of the index followed by lightweight **delta events** whenever changes occur. This chunked approach improves reliability and keeps bandwidth usage minimal. To enhance modularity, scalability, and security, SeedPass now manages each password or data entry as a separate JSON file within a **Fingerprint-Based Backup and Local Storage** system. This document outlines the new entry management system, ensuring that new `kind` types can be added seamlessly without disrupting existing functionalities.
---
## Index File Format
All entries belonging to a seed profile are summarized in an encrypted file named `seedpass_entries_db.json.enc`. This index starts with `schema_version` `2` and contains an `entries` object keyed by entry numbers.
```json
{
"schema_version": 2,
"entries": {
"0": {
"website": "example.com",
"length": 8,
"type": "password",
"notes": ""
}
}
}
```
---
@@ -444,9 +464,8 @@ All backups are organized based on fingerprints, ensuring that each seed's data
│ │ ├── entry_1_v1.json
│ │ └── ...
│ ├── parent_seed.enc
│ ├── seedpass_passwords_checksum.txt
── seedpass_passwords_db_checksum.txt
│ └── seedpass_passwords_db.json
│ ├── seedpass_entries_db_checksum.txt
── seedpass_entries_db.json
├── b5c6d7e8/
│ ├── entries/
│ │ ├── entry_0.json
@@ -458,9 +477,8 @@ All backups are organized based on fingerprints, ensuring that each seed's data
│ │ ├── entry_1_v1.json
│ │ └── ...
│ ├── parent_seed.enc
│ ├── seedpass_passwords_checksum.txt
── seedpass_passwords_db_checksum.txt
│ └── seedpass_passwords_db.json
│ ├── seedpass_entries_db_checksum.txt
── seedpass_entries_db.json
└── ...
```
@@ -498,9 +516,9 @@ seedpass rollback --fingerprint a1b2c3d4 --file entry_0_v1.json
│ │ ├── entry_1_v1.json
│ │ └── ...
│ ├── parent_seed.enc
│ ├── seedpass_passwords_checksum.txt
│ ├── seedpass_passwords_db_checksum.txt
│ └── seedpass_passwords_db.json
│ ├── seedpass_script_checksum.txt
│ ├── seedpass_entries_db_checksum.txt
│ └── seedpass_entries_db.json
├── ...
```

View File

@@ -4,10 +4,12 @@ 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 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("."))

View File

@@ -1,9 +1,11 @@
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.

View File

@@ -53,7 +53,7 @@
<div class="container">
<h1 id="intro-heading">SeedPass: Secure Password Manager</h1>
<p><strong>SeedPass</strong> 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.</p>
<p>By integrating with the <strong>Nostr network</strong>, SeedPass ensures that your passwords are safe and accessible across devices.</p>
<p>By integrating with the <strong>Nostr network</strong>, SeedPass compresses your encrypted vault and publishes it in 50&#8201;KB chunks. Each chunk is sent as a parameterised replaceable event, with deltas tracking changes between snapshots and automatic rotation when deltas grow large.</p>
<a href="https://github.com/PR0M3TH3AN/SeedPass" class="btn-primary cta-button"><i class="fas fa-download" aria-hidden="true"></i> Get Started</a>
</div>
</section>
@@ -64,7 +64,7 @@
<ul>
<li><i class="fas fa-key" aria-hidden="true"></i> Deterministic password generation using BIP-85</li>
<li><i class="fas fa-lock" aria-hidden="true"></i> Encrypted local storage for seeds and sensitive data</li>
<li><i class="fas fa-network-wired" aria-hidden="true"></i> Nostr relay integration for secure backup and retrieval</li>
<li><i class="fas fa-network-wired" aria-hidden="true"></i> Nostr relay integration with parameterised replaceable events for chunked snapshots and deltas</li>
<li><i class="fas fa-exchange-alt" aria-hidden="true"></i> Seed/Fingerprint switching for managing multiple profiles</li>
<li><i class="fas fa-check" aria-hidden="true"></i> Checksum verification to ensure script integrity</li>
<li><i class="fas fa-terminal" aria-hidden="true"></i> Interactive TUI for managing entries and settings</li>
@@ -91,7 +91,7 @@
<h3 class="subsection-title">Seed/Fingerprint Switching</h3>
<p>SeedPass allows you to manage multiple seed profiles (fingerprints). You can switch between different seeds to compartmentalize your passwords.</p>
<h3 class="subsection-title">Nostr Relay Integration</h3>
<p>By integrating with the Nostr network, SeedPass securely backs up your encrypted password index to Nostr relays, allowing you to retrieve your index on multiple devices without compromising security.</p>
<p>SeedPass publishes your encrypted vault to Nostr in 50&#8201;KB chunks using parameterised replaceable events. A manifest describes each snapshot while deltas record updates. When too many deltas accumulate, a new snapshot is rotated in automatically.</p>
<h3 class="subsection-title">Checksum Verification</h3>
<p>Built-in checksum verification ensures your SeedPass installation hasn't been tampered with.</p>
<h3 class="subsection-title">Interactive TUI</h3>
@@ -209,7 +209,7 @@ Enter your choice (1-5):
<section class="disclaimer" id="disclaimer" aria-labelledby="disclaimer-heading">
<div class="container">
<h2 class="section-title" id="disclaimer-heading">Disclaimer</h2>
<p><strong>⚠️ Disclaimer:</strong> This software was not developed by an experienced security expert and should be used with caution. There may be bugs and missing features. For instance, the maximum size of the index before the Nostr backup starts to have problems is unknown. Additionally, the security of the program's memory management and logs has not been evaluated and may leak sensitive information.</p>
<p><strong>⚠️ Disclaimer:</strong> This software was not developed by an experienced security expert and should be used with caution. There may be bugs and missing features. Additionally, the security of the program's memory management and logs has not been evaluated and may leak sensitive information.</p>
</div>
</section>
</main>

View File

@@ -14,7 +14,7 @@
| ------------ | ------------------------------------------------------------------------------ |
| **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 *and* one replaceable Nostr event (kind 31111) |
| **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 |
---

View File

@@ -7,5 +7,6 @@ testpaths = src/tests
markers =
network: tests that require network connectivity
stress: long running stress tests
desktop: desktop only tests
filterwarnings =
ignore::DeprecationWarning:multiprocessing.popen_fork

View File

@@ -6,7 +6,6 @@ base58==2.1.1
bcrypt==4.3.0
bech32==1.2.0
bip-utils==2.9.3
bip85==0.2.0
cbor2==5.6.5
certifi==2025.6.15
cffi==1.17.1

View File

@@ -8,11 +8,12 @@ if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
from utils.checksum import calculate_checksum
from constants import SCRIPT_CHECKSUM_FILE
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"
checksum = calculate_checksum(str(script_path))
if checksum is None:

View File

@@ -1,10 +1,7 @@
# constants.py
import os
import logging
import sys
from pathlib import Path
import traceback
# Instantiate the logger
logger = logging.getLogger(__name__)
@@ -15,38 +12,32 @@ logger = logging.getLogger(__name__)
MAX_RETRIES = 3 # Maximum number of retries for relay connections
RETRY_DELAY = 5 # Seconds to wait before retrying a failed connection
try:
# -----------------------------------
# Application Directory and Paths
# -----------------------------------
APP_DIR = Path.home() / ".seedpass"
APP_DIR.mkdir(exist_ok=True, parents=True) # Ensure the directory exists
if logger.isEnabledFor(logging.DEBUG):
logger.info(f"Application directory created at {APP_DIR}")
except Exception as e:
if logger.isEnabledFor(logging.DEBUG):
logger.error(f"Failed to create application directory: {e}", exc_info=True)
try:
PARENT_SEED_FILE = APP_DIR / "parent_seed.enc" # Encrypted parent seed
if logger.isEnabledFor(logging.DEBUG):
logger.info(f"Parent seed file path set to {PARENT_SEED_FILE}")
except Exception as e:
if logger.isEnabledFor(logging.DEBUG):
logger.error(f"Error setting file paths: {e}", exc_info=True)
# -----------------------------------
# Application Directory and Paths
# -----------------------------------
APP_DIR = Path.home() / ".seedpass"
PARENT_SEED_FILE = APP_DIR / "parent_seed.enc" # Encrypted parent seed
# -----------------------------------
# Checksum Files for Integrity
# -----------------------------------
try:
SCRIPT_CHECKSUM_FILE = (
APP_DIR / "seedpass_script_checksum.txt"
) # Checksum for main script
if logger.isEnabledFor(logging.DEBUG):
logger.info(f"Checksum file path set: Script {SCRIPT_CHECKSUM_FILE}")
except Exception as e:
if logger.isEnabledFor(logging.DEBUG):
logger.error(f"Error setting checksum file paths: {e}", exc_info=True)
SCRIPT_CHECKSUM_FILE = (
APP_DIR / "seedpass_script_checksum.txt"
) # Checksum for main script
def initialize_app() -> None:
"""Ensure the application directory exists."""
try:
APP_DIR.mkdir(exist_ok=True, parents=True)
if logger.isEnabledFor(logging.DEBUG):
logger.info(f"Application directory created at {APP_DIR}")
except Exception as exc:
if logger.isEnabledFor(logging.DEBUG):
logger.error(
f"Failed to create application directory: {exc}", exc_info=True
)
# -----------------------------------
# Password Generation Constants

View File

@@ -31,6 +31,12 @@ from cryptography.hazmat.backends import default_backend
logger = logging.getLogger(__name__)
class Bip85Error(Exception):
"""Exception raised for BIP85-related errors."""
pass
class BIP85:
def __init__(self, seed_bytes: bytes | str):
"""Initialize from BIP39 seed bytes or BIP32 xprv string."""
@@ -43,7 +49,7 @@ class BIP85:
except Exception as e:
logging.error(f"Error initializing BIP32 context: {e}", exc_info=True)
print(f"{Fore.RED}Error initializing BIP32 context: {e}")
sys.exit(1)
raise Bip85Error(f"Error initializing BIP32 context: {e}")
def derive_entropy(
self, index: int, bytes_len: int, app_no: int = 39, words_len: int | None = None
@@ -90,21 +96,23 @@ class BIP85:
print(
f"{Fore.RED}Error: Derived entropy length is {len(entropy)} bytes; expected {bytes_len} bytes."
)
sys.exit(1)
raise Bip85Error(
f"Derived entropy length is {len(entropy)} bytes; expected {bytes_len} bytes."
)
logging.debug(f"Derived entropy: {entropy.hex()}")
return entropy
except Exception as e:
logging.error(f"Error deriving entropy: {e}", exc_info=True)
print(f"{Fore.RED}Error deriving entropy: {e}")
sys.exit(1)
raise Bip85Error(f"Error deriving entropy: {e}")
def derive_mnemonic(self, index: int, words_num: int) -> str:
bytes_len = {12: 16, 18: 24, 24: 32}.get(words_num)
if not bytes_len:
logging.error(f"Unsupported number of words: {words_num}")
print(f"{Fore.RED}Error: Unsupported number of words: {words_num}")
sys.exit(1)
raise Bip85Error(f"Unsupported number of words: {words_num}")
entropy = self.derive_entropy(
index=index, bytes_len=bytes_len, app_no=39, words_len=words_num
@@ -118,7 +126,7 @@ class BIP85:
except Exception as e:
logging.error(f"Error generating mnemonic: {e}", exc_info=True)
print(f"{Fore.RED}Error generating mnemonic: {e}")
sys.exit(1)
raise Bip85Error(f"Error generating mnemonic: {e}")
def derive_symmetric_key(self, index: int = 0, app_no: int = 2) -> bytes:
"""Derive 32 bytes of entropy for symmetric key usage."""
@@ -129,4 +137,4 @@ class BIP85:
except Exception as e:
logging.error(f"Error deriving symmetric key: {e}", exc_info=True)
print(f"{Fore.RED}Error deriving symmetric key: {e}")
sys.exit(1)
raise Bip85Error(f"Error deriving symmetric key: {e}")

View File

@@ -7,16 +7,20 @@ import signal
import getpass
import time
import argparse
import asyncio
import gzip
import tomli
from colorama import init as colorama_init
from termcolor import colored
import traceback
from password_manager.manager import PasswordManager
from password_manager.portable_backup import PortableMode
from nostr.client import NostrClient
from constants import INACTIVITY_TIMEOUT
from utils.key_derivation import EncryptionMode
from constants import INACTIVITY_TIMEOUT, initialize_app
from utils.password_prompt import PasswordPromptError
from utils import timed_input
from local_bip85.bip85 import Bip85Error
colorama_init()
@@ -225,23 +229,18 @@ def handle_post_to_nostr(
Handles the action of posting the encrypted password index to Nostr.
"""
try:
# Get the encrypted data from the index file
encrypted_data = password_manager.get_encrypted_data()
if encrypted_data:
# Post to Nostr
success = password_manager.nostr_client.publish_json_to_nostr(
encrypted_data,
alt_summary=alt_summary,
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",
)
)
if success:
print(colored("\N{WHITE HEAVY CHECK MARK} Sync complete.", "green"))
logging.info("Encrypted index posted to Nostr successfully.")
else:
print(colored("\N{CROSS MARK} Sync failed…", "red"))
logging.error("Failed to post encrypted index to Nostr.")
logging.info("Encrypted index posted to Nostr successfully.")
else:
print(colored("No data available to post.", "yellow"))
logging.warning("No data available to post to Nostr.")
print(colored("\N{CROSS MARK} Sync failed…", "red"))
logging.error("Failed to post encrypted index to Nostr.")
except Exception as e:
logging.error(f"Failed to post to Nostr: {e}", exc_info=True)
print(colored(f"Error: Failed to post to Nostr: {e}", "red"))
@@ -252,12 +251,22 @@ def handle_retrieve_from_nostr(password_manager: PasswordManager):
Handles the action of retrieving the encrypted password index from Nostr.
"""
try:
# Use the Nostr client from the password_manager
encrypted_data = password_manager.nostr_client.retrieve_json_from_nostr_sync()
if encrypted_data:
# Decrypt and save the index
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
password_manager.encryption_manager.decrypt_and_save_index_from_nostr(
encrypted_data
encrypted
)
print(colored("Encrypted index retrieved and saved successfully.", "green"))
logging.info("Encrypted index retrieved and saved successfully from Nostr.")
@@ -379,6 +388,40 @@ def handle_reset_relays(password_manager: PasswordManager) -> None:
print(colored(f"Error: {e}", "red"))
def handle_set_inactivity_timeout(password_manager: PasswordManager) -> None:
"""Change the inactivity timeout for the current seed profile."""
cfg_mgr = password_manager.config_manager
if cfg_mgr is None:
print(colored("Configuration manager unavailable.", "red"))
return
try:
current = cfg_mgr.get_inactivity_timeout() / 60
print(colored(f"Current timeout: {current:.1f} minutes", "cyan"))
except Exception as e:
logging.error(f"Error loading timeout: {e}")
print(colored(f"Error: {e}", "red"))
return
value = input("Enter new timeout in minutes: ").strip()
if not value:
print(colored("No timeout entered.", "yellow"))
return
try:
minutes = float(value)
if minutes <= 0:
print(colored("Timeout must be positive.", "red"))
return
except ValueError:
print(colored("Invalid number.", "red"))
return
try:
cfg_mgr.set_inactivity_timeout(minutes * 60)
password_manager.inactivity_timeout = minutes * 60
print(colored("Inactivity timeout updated.", "green"))
except Exception as e:
logging.error(f"Error saving timeout: {e}")
print(colored(f"Error: {e}", "red"))
def handle_profiles_menu(password_manager: PasswordManager) -> None:
"""Submenu for managing seed profiles."""
while True:
@@ -460,8 +503,9 @@ def handle_settings(password_manager: PasswordManager) -> None:
print("5. Backup Parent Seed")
print("6. Export database")
print("7. Import database")
print("8. Lock Vault")
print("9. Back")
print("8. Set inactivity timeout")
print("9. Lock Vault")
print("10. Back")
choice = input("Select an option: ").strip()
if choice == "1":
handle_profiles_menu(password_manager)
@@ -480,10 +524,12 @@ def handle_settings(password_manager: PasswordManager) -> None:
if path:
password_manager.handle_import_database(Path(path))
elif choice == "8":
handle_set_inactivity_timeout(password_manager)
elif choice == "9":
password_manager.lock_vault()
print(colored("Vault locked. Please re-enter your password.", "yellow"))
password_manager.unlock_vault()
elif choice == "9":
elif choice == "10":
break
else:
print(colored("Invalid choice.", "red"))
@@ -523,7 +569,15 @@ def display_menu(
for handler in logging.getLogger().handlers:
handler.flush()
print(colored(menu, "cyan"))
choice = input("Enter your choice (1-5): ").strip()
try:
choice = timed_input(
"Enter your choice (1-5): ", inactivity_timeout
).strip()
except TimeoutError:
print(colored("Session timed out. Vault locked.", "yellow"))
password_manager.lock_vault()
password_manager.unlock_vault()
continue
password_manager.update_activity()
if not choice:
print(
@@ -568,6 +622,7 @@ def display_menu(
if __name__ == "__main__":
# Configure logging with both file and console handlers
configure_logging()
initialize_app()
logger = logging.getLogger(__name__)
logger.info("Starting SeedPass Password Manager")
@@ -576,18 +631,7 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command")
parser.add_argument(
"--encryption-mode",
choices=[m.value for m in EncryptionMode],
help="Select encryption mode",
)
exp = sub.add_parser("export")
exp.add_argument(
"--mode",
choices=[m.value for m in PortableMode],
default=PortableMode.SEED_ONLY.value,
)
exp.add_argument("--file")
imp = sub.add_parser("import")
@@ -595,28 +639,21 @@ if __name__ == "__main__":
args = parser.parse_args()
mode_value = cfg.get("encryption_mode", EncryptionMode.SEED_ONLY.value)
if args.encryption_mode:
mode_value = args.encryption_mode
try:
enc_mode = EncryptionMode(mode_value)
except ValueError:
logger.error(f"Invalid encryption mode: {mode_value}")
print(colored(f"Error: Invalid encryption mode '{mode_value}'", "red"))
sys.exit(1)
# Initialize PasswordManager and proceed with application logic
try:
password_manager = PasswordManager(encryption_mode=enc_mode)
password_manager = PasswordManager()
logger.info("PasswordManager initialized successfully.")
except (PasswordPromptError, Bip85Error) as e:
logger.error(f"Failed to initialize PasswordManager: {e}", exc_info=True)
print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red"))
sys.exit(1)
except Exception as e:
logger.error(f"Failed to initialize PasswordManager: {e}", exc_info=True)
print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red"))
sys.exit(1)
if args.command == "export":
mode = PortableMode(args.mode)
password_manager.handle_export_database(mode, Path(args.file))
password_manager.handle_export_database(Path(args.file))
sys.exit(0)
elif args.command == "import":
password_manager.handle_import_database(Path(args.file))
@@ -643,7 +680,9 @@ if __name__ == "__main__":
# Display the interactive menu to the user
try:
display_menu(password_manager)
display_menu(
password_manager, inactivity_timeout=password_manager.inactivity_timeout
)
except KeyboardInterrupt:
logger.info("Program terminated by user via KeyboardInterrupt.")
print(colored("\nProgram terminated by user.", "yellow"))
@@ -654,6 +693,16 @@ if __name__ == "__main__":
logging.error(f"Error during shutdown: {e}")
print(colored(f"Error during shutdown: {e}", "red"))
sys.exit(0)
except (PasswordPromptError, Bip85Error) as e:
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()
logging.info("NostrClient closed successfully.")
except Exception as close_error:
logging.error(f"Error during shutdown: {close_error}")
print(colored(f"Error during shutdown: {close_error}", "red"))
sys.exit(1)
except Exception as e:
logger.error(f"An unexpected error occurred: {e}", exc_info=True)
print(colored(f"Error: An unexpected error occurred: {e}", "red"))

View File

@@ -5,12 +5,30 @@
from importlib import import_module
import logging
from .backup_models import (
KIND_MANIFEST,
KIND_SNAPSHOT_CHUNK,
KIND_DELTA,
Manifest,
ChunkMeta,
)
logger = logging.getLogger(__name__)
__all__ = ["NostrClient"]
__all__ = [
"NostrClient",
"KIND_MANIFEST",
"KIND_SNAPSHOT_CHUNK",
"KIND_DELTA",
"Manifest",
"ChunkMeta",
"prepare_snapshot",
]
def __getattr__(name: str):
if name == "NostrClient":
return import_module(".client", __name__).NostrClient
if name == "prepare_snapshot":
return import_module(".client", __name__).prepare_snapshot
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")

View File

@@ -0,0 +1,26 @@
from dataclasses import dataclass
from typing import List, Optional
# Event kind constants used for SeedPass backups
KIND_MANIFEST = 30070
KIND_SNAPSHOT_CHUNK = 30071
KIND_DELTA = 30072
@dataclass
class ChunkMeta:
"""Metadata for an individual snapshot chunk."""
id: str
size: int
hash: str
@dataclass
class Manifest:
"""Structure of the backup manifest JSON."""
ver: int
algo: str
chunks: List[ChunkMeta]
delta_since: Optional[str] = None

View File

@@ -3,9 +3,11 @@
import base64
import json
import logging
from typing import List, Optional
import time
from typing import List, Optional, Tuple
import hashlib
import asyncio
import gzip
# Imports from the nostr-sdk library
from nostr_sdk import (
@@ -19,8 +21,10 @@ from nostr_sdk import (
Tag,
)
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 utils.file_lock import exclusive_lock
@@ -38,6 +42,44 @@ DEFAULT_RELAYS = [
]
def prepare_snapshot(
encrypted_bytes: bytes, limit: int
) -> Tuple[Manifest, list[bytes]]:
"""Compress and split the encrypted vault into chunks.
Each chunk is hashed with SHA-256 and described in the returned
:class:`Manifest`.
Parameters
----------
encrypted_bytes : bytes
The encrypted vault contents.
limit : int
Maximum chunk size in bytes.
Returns
-------
Tuple[Manifest, list[bytes]]
The manifest describing all chunks and the list of chunk bytes.
"""
compressed = gzip.compress(encrypted_bytes)
chunks = [compressed[i : i + limit] for i in range(0, len(compressed), limit)]
metas: list[ChunkMeta] = []
for i, chunk in enumerate(chunks):
metas.append(
ChunkMeta(
id=f"seedpass-chunk-{i:04d}",
size=len(chunk),
hash=hashlib.sha256(chunk).hexdigest(),
)
)
manifest = Manifest(ver=1, algo="gzip", chunks=metas)
return manifest, chunks
class NostrClient:
"""Interact with the Nostr network using nostr-sdk."""
@@ -69,6 +111,13 @@ class NostrClient:
self.relays = relays if relays else DEFAULT_RELAYS
# store the last error encountered during network operations
self.last_error: Optional[str] = None
self.delta_threshold = 100
self.current_manifest: Manifest | None = None
self._delta_events: list[str] = []
# Configure and initialize the nostr-sdk Client
signer = NostrSigner.keys(self.keys)
self.client = Client(signer)
@@ -93,7 +142,7 @@ class NostrClient:
encrypted_json: bytes,
to_pubkey: str | None = None,
alt_summary: str | None = None,
) -> bool:
) -> str | None:
"""Builds and publishes a Kind 1 text note or direct message.
Parameters
@@ -106,6 +155,7 @@ class NostrClient:
If provided, include an ``alt`` tag so uploads can be
associated with a specific event like a password change.
"""
self.last_error = None
try:
content = base64.b64encode(encrypted_json).decode("utf-8")
@@ -127,11 +177,12 @@ class NostrClient:
else str(event_output)
)
logger.info(f"Successfully published event with ID: {event_id_hex}")
return True
return event_id_hex
except Exception as e:
self.last_error = str(e)
logger.error(f"Failed to publish JSON to Nostr: {e}")
return False
return None
def publish_event(self, event):
"""Publish a prepared event to the configured relays."""
@@ -140,13 +191,33 @@ class NostrClient:
async def _publish_event(self, event):
return await self.client.send_event(event)
def retrieve_json_from_nostr_sync(self) -> Optional[bytes]:
"""Retrieves the latest Kind 1 event from the author."""
try:
return asyncio.run(self._retrieve_json_from_nostr())
except Exception as e:
logger.error("Failed to retrieve events from Nostr: %s", e)
return None
def update_relays(self, new_relays: List[str]) -> None:
"""Reconnect the client using a new set of relays."""
self.close_client_pool()
self.relays = new_relays
signer = NostrSigner.keys(self.keys)
self.client = Client(signer)
self.initialize_client_pool()
def retrieve_json_from_nostr_sync(
self, retries: int = 0, delay: float = 2.0
) -> Optional[bytes]:
"""Retrieve the latest Kind 1 event from the author with optional retries."""
self.last_error = None
attempt = 0
while True:
try:
result = asyncio.run(self._retrieve_json_from_nostr())
if result is not None:
return result
except Exception as e:
self.last_error = str(e)
logger.error("Failed to retrieve events from Nostr: %s", e)
if attempt >= retries:
break
attempt += 1
time.sleep(delay)
return None
async def _retrieve_json_from_nostr(self) -> Optional[bytes]:
# Filter for the latest text note (Kind 1) from our public key
@@ -157,7 +228,8 @@ class NostrClient:
events = (await self.client.fetch_events(f, timeout)).to_vec()
if not events:
logger.warning("No events found on relays for this user.")
self.last_error = "No events found on relays for this user."
logger.warning(self.last_error)
return None
latest_event = events[0]
@@ -165,8 +237,139 @@ class NostrClient:
if content_b64:
return base64.b64decode(content_b64.encode("utf-8"))
self.last_error = "Latest event contained no content"
return None
async def publish_snapshot(
self, encrypted_bytes: bytes, limit: int = 50_000
) -> tuple[Manifest, str]:
"""Publish a compressed snapshot split into chunks.
Parameters
----------
encrypted_bytes : bytes
Vault contents already encrypted with the user's key.
limit : int, optional
Maximum chunk size in bytes. Defaults to 50 kB.
"""
manifest, chunks = prepare_snapshot(encrypted_bytes, limit)
for meta, chunk in zip(manifest.chunks, chunks):
content = base64.b64encode(chunk).decode("utf-8")
builder = EventBuilder(Kind(KIND_SNAPSHOT_CHUNK), content).tags(
[Tag.identifier(meta.id)]
)
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
await self.client.send_event(event)
manifest_json = json.dumps(
{
"ver": manifest.ver,
"algo": manifest.algo,
"chunks": [meta.__dict__ for meta in manifest.chunks],
"delta_since": manifest.delta_since,
}
)
manifest_event = (
EventBuilder(Kind(KIND_MANIFEST), manifest_json)
.build(self.keys.public_key())
.sign_with_keys(self.keys)
)
result = await self.client.send_event(manifest_event)
manifest_id = result.id.to_hex() if hasattr(result, "id") else str(result)
self.current_manifest = manifest
self._delta_events = []
return manifest, manifest_id
async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None:
"""Retrieve the latest manifest and all snapshot chunks."""
pubkey = self.keys.public_key()
f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).limit(1)
timeout = timedelta(seconds=10)
events = (await self.client.fetch_events(f, timeout)).to_vec()
if not events:
return None
manifest_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"),
)
chunks: list[bytes] = []
for meta in manifest.chunks:
cf = (
Filter()
.author(pubkey)
.kind(Kind(KIND_SNAPSHOT_CHUNK))
.identifier(meta.id)
.limit(1)
)
cev = (await self.client.fetch_events(cf, timeout)).to_vec()
if not cev:
raise ValueError(f"Missing chunk {meta.id}")
chunk_bytes = base64.b64decode(cev[0].content().encode("utf-8"))
if hashlib.sha256(chunk_bytes).hexdigest() != meta.hash:
raise ValueError(f"Checksum mismatch for chunk {meta.id}")
chunks.append(chunk_bytes)
self.current_manifest = manifest
return manifest, chunks
async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str:
"""Publish a delta event referencing a manifest."""
content = base64.b64encode(delta_bytes).decode("utf-8")
tag = Tag.event(EventId.parse(manifest_id))
builder = EventBuilder(Kind(KIND_DELTA), content).tags([tag])
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
result = await self.client.send_event(event)
delta_id = result.id.to_hex() if hasattr(result, "id") else str(result)
if self.current_manifest is not None:
self.current_manifest.delta_since = delta_id
self._delta_events.append(delta_id)
return delta_id
async def fetch_deltas_since(self, version: int) -> list[bytes]:
"""Retrieve delta events newer than the given version."""
pubkey = self.keys.public_key()
f = (
Filter()
.author(pubkey)
.kind(Kind(KIND_DELTA))
.since(Timestamp.from_secs(version))
)
timeout = timedelta(seconds=10)
events = (await self.client.fetch_events(f, timeout)).to_vec()
deltas: list[bytes] = []
for ev in events:
deltas.append(base64.b64decode(ev.content().encode("utf-8")))
if self.current_manifest is not None:
snap_size = sum(c.size for c in self.current_manifest.chunks)
if (
len(deltas) >= self.delta_threshold
or sum(len(d) for d in deltas) > snap_size
):
# Publish a new snapshot to consolidate deltas
joined = b"".join(deltas)
await self.publish_snapshot(joined)
exp = Timestamp.from_secs(int(time.time()))
for ev in events:
exp_builder = EventBuilder(Kind(KIND_DELTA), ev.content()).tags(
[Tag.expiration(exp)]
)
exp_event = exp_builder.build(
self.keys.public_key()
).sign_with_keys(self.keys)
await self.client.send_event(exp_event)
return deltas
def close_client_pool(self) -> None:
"""Disconnects the client from all relays."""
try:

View File

@@ -4,7 +4,7 @@
from importlib import import_module
__all__ = ["PasswordManager", "ConfigManager", "Vault"]
__all__ = ["PasswordManager", "ConfigManager", "Vault", "EntryType"]
def __getattr__(name: str):
@@ -14,4 +14,6 @@ def __getattr__(name: str):
return import_module(".config_manager", __name__).ConfigManager
if name == "Vault":
return import_module(".vault", __name__).Vault
if name == "EntryType":
return import_module(".entry_types", __name__).EntryType
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")

View File

@@ -35,7 +35,7 @@ class BackupManager:
timestamped filenames to facilitate easy identification and retrieval.
"""
BACKUP_FILENAME_TEMPLATE = "passwords_db_backup_{timestamp}.json.enc"
BACKUP_FILENAME_TEMPLATE = "entries_db_backup_{timestamp}.json.enc"
def __init__(self, fingerprint_dir: Path):
"""
@@ -47,7 +47,7 @@ class BackupManager:
self.fingerprint_dir = fingerprint_dir
self.backup_dir = self.fingerprint_dir / "backups"
self.backup_dir.mkdir(parents=True, exist_ok=True)
self.index_file = self.fingerprint_dir / "seedpass_passwords_db.json.enc"
self.index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc"
logger.debug(
f"BackupManager initialized with backup directory at {self.backup_dir}"
)
@@ -79,7 +79,7 @@ class BackupManager:
def restore_latest_backup(self) -> None:
try:
backup_files = sorted(
self.backup_dir.glob("passwords_db_backup_*.json.enc"),
self.backup_dir.glob("entries_db_backup_*.json.enc"),
key=lambda x: x.stat().st_mtime,
reverse=True,
)
@@ -112,7 +112,7 @@ class BackupManager:
def list_backups(self) -> None:
try:
backup_files = sorted(
self.backup_dir.glob("passwords_db_backup_*.json.enc"),
self.backup_dir.glob("entries_db_backup_*.json.enc"),
key=lambda x: x.stat().st_mtime,
reverse=True,
)

View File

@@ -13,6 +13,8 @@ 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__)
@@ -41,6 +43,7 @@ class ConfigManager:
"relays": list(DEFAULT_NOSTR_RELAYS),
"pin_hash": "",
"password_hash": "",
"inactivity_timeout": INACTIVITY_TIMEOUT,
}
try:
data = self.vault.load_config()
@@ -50,6 +53,7 @@ class ConfigManager:
data.setdefault("relays", list(DEFAULT_NOSTR_RELAYS))
data.setdefault("pin_hash", "")
data.setdefault("password_hash", "")
data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT)
# Migrate legacy hashed_password.enc if present and password_hash is missing
legacy_file = self.fingerprint_dir / "hashed_password.enc"
@@ -113,3 +117,16 @@ class ConfigManager:
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))

View File

@@ -246,10 +246,10 @@ class EncryptionManager:
: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_passwords_db.json.enc'.
Defaults to 'seedpass_entries_db.json.enc'.
"""
if relative_path is None:
relative_path = Path("seedpass_passwords_db.json.enc")
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)
@@ -273,11 +273,11 @@ class EncryptionManager:
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_passwords_db.json.enc'.
Defaults to 'seedpass_entries_db.json.enc'.
:return: The decrypted JSON data as a dictionary.
"""
if relative_path is None:
relative_path = Path("seedpass_passwords_db.json.enc")
relative_path = Path("seedpass_entries_db.json.enc")
file_path = self.fingerprint_dir / relative_path
@@ -291,7 +291,7 @@ class EncryptionManager:
"yellow",
)
)
return {"passwords": {}}
return {"entries": {}}
try:
decrypted_data = self.decrypt_file(relative_path)
@@ -320,10 +320,10 @@ class EncryptionManager:
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_passwords_db.json.enc'.
Defaults to 'seedpass_entries_db.json.enc'.
"""
if relative_path is None:
relative_path = Path("seedpass_passwords_db.json.enc")
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.")
@@ -368,7 +368,7 @@ class EncryptionManager:
:return: Encrypted data as bytes or None if the index file does not exist.
"""
try:
relative_path = Path("seedpass_passwords_db.json.enc")
relative_path = Path("seedpass_entries_db.json.enc")
if not (self.fingerprint_dir / relative_path).exists():
logger.error(
f"Index file '{relative_path}' does not exist in '{self.fingerprint_dir}'."
@@ -407,10 +407,10 @@ class EncryptionManager:
:param encrypted_data: The encrypted data retrieved from Nostr.
:param relative_path: The relative path within the fingerprint directory to update.
Defaults to 'seedpass_passwords_db.json.enc'.
Defaults to 'seedpass_entries_db.json.enc'.
"""
if relative_path is None:
relative_path = Path("seedpass_passwords_db.json.enc")
relative_path = Path("seedpass_entries_db.json.enc")
try:
decrypted_data = self.decrypt_data(encrypted_data)
data = json.loads(decrypted_data.decode("utf-8"))

View File

@@ -28,6 +28,7 @@ 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.vault import Vault
from utils.file_lock import exclusive_lock
@@ -49,8 +50,8 @@ class EntryManager:
self.fingerprint_dir = fingerprint_dir
# Use paths relative to the fingerprint directory
self.index_file = self.fingerprint_dir / "seedpass_passwords_db.json.enc"
self.checksum_file = self.fingerprint_dir / "seedpass_passwords_db_checksum.txt"
self.index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc"
self.checksum_file = self.fingerprint_dir / "seedpass_entries_db_checksum.txt"
logger.debug(f"EntryManager initialized with index file at {self.index_file}")
@@ -62,12 +63,12 @@ class EntryManager:
return data
except Exception as e:
logger.error(f"Failed to load index: {e}")
return {"schema_version": LATEST_VERSION, "passwords": {}}
return {"schema_version": LATEST_VERSION, "entries": {}}
else:
logger.info(
f"Index file '{self.index_file}' not found. Initializing new password database."
f"Index file '{self.index_file}' not found. Initializing new entries database."
)
return {"schema_version": LATEST_VERSION, "passwords": {}}
return {"schema_version": LATEST_VERSION, "entries": {}}
def _save_index(self, data: Dict[str, Any]) -> None:
try:
@@ -79,14 +80,14 @@ class EntryManager:
def get_next_index(self) -> int:
"""
Retrieves the next available index for a new password entry.
Retrieves the next available index for a new entry.
:return: The next index number as an integer.
"""
try:
data = self.vault.load_index()
if "passwords" in data and isinstance(data["passwords"], dict):
indices = [int(idx) for idx in data["passwords"].keys()]
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
else:
next_index = 0
@@ -104,32 +105,35 @@ class EntryManager:
username: Optional[str] = None,
url: Optional[str] = None,
blacklisted: bool = False,
notes: str = "",
) -> int:
"""
Adds a new password entry to the encrypted JSON index file.
Adds a new entry to the encrypted JSON index file.
:param website_name: The name of the website.
:param length: The desired length of the password.
:param username: (Optional) The username associated with the website.
:param url: (Optional) The URL of the website.
:param blacklisted: (Optional) Whether the password is blacklisted. Defaults to False.
:param notes: (Optional) Extra notes to attach to the entry.
:return: The assigned index of the new entry.
"""
try:
index = self.get_next_index()
data = self.vault.load_index()
data["passwords"][str(index)] = {
data.setdefault("entries", {})
data["entries"][str(index)] = {
"website": website_name,
"length": length,
"username": username if username else "",
"url": url if url else "",
"blacklisted": blacklisted,
"type": EntryType.PASSWORD.value,
"notes": notes,
}
logger.debug(
f"Added entry at index {index}: {data['passwords'][str(index)]}"
)
logger.debug(f"Added entry at index {index}: {data['entries'][str(index)]}")
self._save_index(data)
self.update_checksum()
@@ -145,6 +149,39 @@ class EntryManager:
print(colored(f"Error: Failed to add entry: {e}", "red"))
sys.exit(1)
def add_totp(self, notes: str = "") -> int:
"""Placeholder for adding a TOTP entry."""
index = self.get_next_index()
data = self.vault.load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {"type": EntryType.TOTP.value, "notes": notes}
self._save_index(data)
self.update_checksum()
self.backup_index_file()
raise NotImplementedError("TOTP entry support not implemented yet")
def add_ssh_key(self, notes: str = "") -> int:
"""Placeholder for adding an SSH key entry."""
index = self.get_next_index()
data = self.vault.load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {"type": EntryType.SSH.value, "notes": notes}
self._save_index(data)
self.update_checksum()
self.backup_index_file()
raise NotImplementedError("SSH key entry support not implemented yet")
def add_seed(self, notes: str = "") -> int:
"""Placeholder for adding a seed entry."""
index = self.get_next_index()
data = self.vault.load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {"type": EntryType.SEED.value, "notes": notes}
self._save_index(data)
self.update_checksum()
self.backup_index_file()
raise NotImplementedError("Seed entry support not implemented yet")
def get_encrypted_index(self) -> Optional[bytes]:
"""
Retrieves the encrypted password index file's contents.
@@ -162,14 +199,14 @@ class EntryManager:
def retrieve_entry(self, index: int) -> Optional[Dict[str, Any]]:
"""
Retrieves a password entry based on the provided index.
Retrieves an entry based on the provided index.
:param index: The index number of the password entry.
:param index: The index number of the entry.
:return: A dictionary containing the entry details or None if not found.
"""
try:
data = self.vault.load_index()
entry = data.get("passwords", {}).get(str(index))
entry = data.get("entries", {}).get(str(index))
if entry:
logger.debug(f"Retrieved entry at index {index}: {entry}")
@@ -194,18 +231,20 @@ class EntryManager:
username: Optional[str] = None,
url: Optional[str] = None,
blacklisted: Optional[bool] = None,
notes: Optional[str] = None,
) -> None:
"""
Modifies an existing password entry based on the provided index and new values.
Modifies an existing entry based on the provided index and new values.
:param index: The index number of the password entry to modify.
:param index: The index number of the entry to modify.
:param username: (Optional) The new username.
:param url: (Optional) The new URL.
:param blacklisted: (Optional) The new blacklist status.
:param notes: (Optional) New notes to attach to the entry.
"""
try:
data = self.vault.load_index()
entry = data.get("passwords", {}).get(str(index))
entry = data.get("entries", {}).get(str(index))
if not entry:
logger.warning(
@@ -233,7 +272,11 @@ class EntryManager:
f"Updated blacklist status to '{blacklisted}' for index {index}."
)
data["passwords"][str(index)] = entry
if notes is not None:
entry["notes"] = notes
logger.debug(f"Updated notes for index {index}.")
data["entries"][str(index)] = entry
logger.debug(f"Modified entry at index {index}: {entry}")
self._save_index(data)
@@ -253,21 +296,21 @@ class EntryManager:
def list_entries(self) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]:
"""
Lists all password entries in the index.
Lists all entries in the index.
:return: A list of tuples containing entry details: (index, website, username, url, blacklisted)
"""
try:
data = self.vault.load_index()
passwords = data.get("passwords", {})
entries_data = data.get("entries", {})
if not passwords:
logger.info("No password entries found.")
print(colored("No password entries found.", "yellow"))
if not entries_data:
logger.info("No entries found.")
print(colored("No entries found.", "yellow"))
return []
entries = []
for idx, entry in sorted(passwords.items(), key=lambda x: int(x[0])):
for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])):
entries.append(
(
int(idx),
@@ -296,14 +339,14 @@ class EntryManager:
def delete_entry(self, index: int) -> None:
"""
Deletes a password entry based on the provided index.
Deletes an entry based on the provided index.
:param index: The index number of the password entry to delete.
:param index: The index number of the entry to delete.
"""
try:
data = self.vault.load_index()
if "passwords" in data and str(index) in data["passwords"]:
del data["passwords"][str(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.update_checksum()
@@ -367,7 +410,7 @@ class EntryManager:
return
timestamp = int(time.time())
backup_filename = f"passwords_db_backup_{timestamp}.json.enc"
backup_filename = f"entries_db_backup_{timestamp}.json.enc"
backup_path = self.fingerprint_dir / backup_filename
with open(index_file_path, "rb") as original_file, open(
@@ -425,7 +468,7 @@ class EntryManager:
def list_all_entries(self) -> None:
"""
Displays all password entries in a formatted manner.
Displays all entries in a formatted manner.
"""
try:
entries = self.list_entries()
@@ -433,7 +476,7 @@ class EntryManager:
print(colored("No entries to display.", "yellow"))
return
print(colored("\n[+] Listing All Password Entries:\n", "green"))
print(colored("\n[+] Listing All Entries:\n", "green"))
for entry in entries:
index, website, username, url, blacklisted = entry
print(colored(f"Index: {index}", "cyan"))

View File

@@ -0,0 +1,13 @@
# password_manager/entry_types.py
"""Enumerations for entry types used by SeedPass."""
from enum import Enum
class EntryType(str, Enum):
"""Enumeration of different entry types supported by the manager."""
PASSWORD = "password"
TOTP = "totp"
SSH = "ssh"
SEED = "seed"

View File

@@ -24,16 +24,11 @@ from password_manager.entry_management import EntryManager
from password_manager.password_generation import PasswordGenerator
from password_manager.backup import BackupManager
from password_manager.vault import Vault
from password_manager.portable_backup import (
export_backup,
import_backup,
PortableMode,
)
from password_manager.portable_backup import export_backup, import_backup
from utils.key_derivation import (
derive_key_from_parent_seed,
derive_key_from_password,
derive_index_key,
DEFAULT_ENCRYPTION_MODE,
EncryptionMode,
)
from utils.checksum import calculate_checksum, verify_checksum
@@ -50,14 +45,18 @@ from constants import (
MIN_PASSWORD_LENGTH,
MAX_PASSWORD_LENGTH,
DEFAULT_PASSWORD_LENGTH,
INACTIVITY_TIMEOUT,
DEFAULT_SEED_BACKUP_FILENAME,
initialize_app,
)
import traceback
import asyncio
import gzip
import bcrypt
from pathlib import Path
from local_bip85.bip85 import BIP85
from local_bip85.bip85 import BIP85, Bip85Error
from bip_utils import Bip39SeedGenerator, Bip39MnemonicGenerator, Bip39Languages
from datetime import datetime
@@ -80,11 +79,10 @@ class PasswordManager:
verification, ensuring the integrity and confidentiality of the stored password database.
"""
def __init__(
self, encryption_mode: EncryptionMode = DEFAULT_ENCRYPTION_MODE
) -> None:
def __init__(self) -> None:
"""Initialize the PasswordManager."""
self.encryption_mode: EncryptionMode = encryption_mode
initialize_app()
self.encryption_mode: EncryptionMode = EncryptionMode.SEED_ONLY
self.encryption_manager: Optional[EncryptionManager] = None
self.entry_manager: Optional[EntryManager] = None
self.password_generator: Optional[PasswordGenerator] = None
@@ -101,6 +99,7 @@ class PasswordManager:
self.last_update: float = time.time()
self.last_activity: float = time.time()
self.locked: bool = False
self.inactivity_timeout: float = INACTIVITY_TIMEOUT
# Initialize the fingerprint manager first
self.initialize_fingerprint_manager()
@@ -197,7 +196,8 @@ class PasswordManager:
def add_new_fingerprint(self):
"""
Adds a new seed profile by generating it from a seed phrase.
Adds a new seed profile by prompting for encryption mode and generating
it from a seed phrase.
"""
try:
choice = input(
@@ -279,11 +279,7 @@ class PasswordManager:
sys.exit(1)
return False
key = derive_index_key(
self.parent_seed,
password,
self.encryption_mode,
)
key = derive_index_key(self.parent_seed)
self.encryption_manager = EncryptionManager(key, fingerprint_dir)
self.vault = Vault(self.encryption_manager, fingerprint_dir)
@@ -480,25 +476,6 @@ class PasswordManager:
"""
print(colored("No existing seed found. Let's set up a new one!", "yellow"))
print("Choose encryption mode [Enter for seed-only]")
print(" 1) seed-only")
print(" 2) seed+password")
print(" 3) password-only (legacy)")
mode_choice = input("Select option: ").strip()
if mode_choice == "2":
self.encryption_mode = EncryptionMode.SEED_PLUS_PW
elif mode_choice == "3":
self.encryption_mode = EncryptionMode.PW_ONLY
print(
colored(
"⚠️ Password-only encryption is less secure and not recommended.",
"yellow",
)
)
else:
self.encryption_mode = EncryptionMode.SEED_ONLY
choice = input(
"Do you want to (1) Enter an existing BIP-85 seed or (2) Generate a new BIP-85 seed? (1/2): "
).strip()
@@ -553,11 +530,7 @@ class PasswordManager:
# Initialize EncryptionManager with key and fingerprint_dir
password = prompt_for_password()
index_key = derive_index_key(
parent_seed,
password,
self.encryption_mode,
)
index_key = derive_index_key(parent_seed)
seed_key = derive_key_from_password(password)
self.encryption_manager = EncryptionManager(index_key, fingerprint_dir)
@@ -674,6 +647,10 @@ class PasswordManager:
bip85 = BIP85(master_seed)
mnemonic = bip85.derive_mnemonic(index=0, words_num=12)
return mnemonic
except Bip85Error as e:
logging.error(f"Failed to generate BIP-85 seed: {e}", exc_info=True)
print(colored(f"Error: Failed to generate BIP-85 seed: {e}", "red"))
sys.exit(1)
except Exception as e:
logging.error(f"Failed to generate BIP-85 seed: {e}", exc_info=True)
print(colored(f"Error: Failed to generate BIP-85 seed: {e}", "red"))
@@ -694,11 +671,7 @@ class PasswordManager:
# Prompt for password
password = prompt_for_password()
index_key = derive_index_key(
seed,
password,
self.encryption_mode,
)
index_key = derive_index_key(seed)
seed_key = derive_key_from_password(password)
self.encryption_manager = EncryptionManager(index_key, fingerprint_dir)
@@ -776,6 +749,9 @@ class PasswordManager:
)
config = self.config_manager.load_config()
relay_list = config.get("relays", list(DEFAULT_RELAYS))
self.inactivity_timeout = config.get(
"inactivity_timeout", INACTIVITY_TIMEOUT
)
self.nostr_client = NostrClient(
encryption_manager=self.encryption_manager,
@@ -793,12 +769,24 @@ class PasswordManager:
def sync_index_from_nostr_if_missing(self) -> None:
"""Retrieve the password database from Nostr if it doesn't exist locally."""
index_file = self.fingerprint_dir / "seedpass_passwords_db.json.enc"
index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc"
if index_file.exists():
return
try:
encrypted = self.nostr_client.retrieve_json_from_nostr_sync()
if encrypted:
result = asyncio.run(self.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(
self.nostr_client.fetch_deltas_since(version)
)
if deltas:
encrypted = deltas[-1]
except ValueError:
pass
self.vault.decrypt_and_save_index_from_nostr(encrypted)
logger.info("Initialized local database from Nostr.")
except Exception as e:
@@ -813,6 +801,7 @@ class PasswordManager:
username = input("Enter the username (optional): ").strip()
url = input("Enter the URL (optional): ").strip()
notes = input("Enter notes (optional): ").strip()
length_input = input(
f"Enter desired password length (default {DEFAULT_PASSWORD_LENGTH}): "
@@ -834,7 +823,12 @@ class PasswordManager:
# Add the entry to the index and get the assigned index
index = self.entry_manager.add_entry(
website_name, length, username, url, blacklisted=False
website_name,
length,
username,
url,
blacklisted=False,
notes=notes,
)
# Mark database as dirty for background sync
@@ -856,12 +850,8 @@ class PasswordManager:
# Automatically push the updated encrypted index to Nostr so the
# latest changes are backed up remotely.
try:
encrypted_data = self.get_encrypted_data()
if encrypted_data:
self.nostr_client.publish_json_to_nostr(encrypted_data)
logging.info(
"Encrypted index posted to Nostr after entry addition."
)
self.sync_vault()
logging.info("Encrypted index posted to Nostr after entry addition.")
except Exception as nostr_error:
logging.error(
f"Failed to post updated index to Nostr: {nostr_error}",
@@ -897,6 +887,10 @@ class PasswordManager:
username = entry.get("username")
url = entry.get("url")
blacklisted = entry.get("blacklisted")
notes = entry.get("notes", "")
notes = entry.get("notes", "")
notes = entry.get("notes", "")
notes = entry.get("notes", "")
print(
colored(
@@ -963,6 +957,7 @@ class PasswordManager:
username = entry.get("username")
url = entry.get("url")
blacklisted = entry.get("blacklisted")
notes = entry.get("notes", "")
# Display current values
print(
@@ -1012,9 +1007,20 @@ class PasswordManager:
)
new_blacklisted = blacklisted
new_notes = (
input(
f'Enter new notes (leave blank to keep "{notes or "N/A"}"): '
).strip()
or notes
)
# Update the entry
self.entry_manager.modify_entry(
index, new_username, new_url, new_blacklisted
index,
new_username,
new_url,
new_blacklisted,
new_notes,
)
# Mark database as dirty for background sync
@@ -1025,12 +1031,10 @@ class PasswordManager:
# Push the updated index to Nostr so changes are backed up.
try:
encrypted_data = self.get_encrypted_data()
if encrypted_data:
self.nostr_client.publish_json_to_nostr(encrypted_data)
logging.info(
"Encrypted index posted to Nostr after entry modification."
)
self.sync_vault()
logging.info(
"Encrypted index posted to Nostr after entry modification."
)
except Exception as nostr_error:
logging.error(
f"Failed to post updated index to Nostr: {nostr_error}",
@@ -1066,12 +1070,8 @@ class PasswordManager:
# Push updated index to Nostr after deletion
try:
encrypted_data = self.get_encrypted_data()
if encrypted_data:
self.nostr_client.publish_json_to_nostr(encrypted_data)
logging.info(
"Encrypted index posted to Nostr after entry deletion."
)
self.sync_vault()
logging.info("Encrypted index posted to Nostr after entry deletion.")
except Exception as nostr_error:
logging.error(
f"Failed to post updated index to Nostr: {nostr_error}",
@@ -1157,6 +1157,27 @@ class PasswordManager:
# Re-raise the exception to inform the calling function of the failure
raise
def sync_vault(self, alt_summary: str | None = None) -> str | None:
"""Publish the current vault contents to Nostr."""
try:
encrypted = self.get_encrypted_data()
if not encrypted:
return None
pub_snap = getattr(self.nostr_client, "publish_snapshot", None)
if callable(pub_snap):
if asyncio.iscoroutinefunction(pub_snap):
_, event_id = asyncio.run(pub_snap(encrypted))
else:
_, event_id = pub_snap(encrypted)
else:
# Fallback for tests using simplified stubs
event_id = self.nostr_client.publish_json_to_nostr(encrypted)
self.is_dirty = False
return event_id
except Exception as e:
logging.error(f"Failed to sync vault: {e}", exc_info=True)
return None
def backup_database(self) -> None:
"""
Creates a backup of the encrypted JSON index file.
@@ -1185,7 +1206,6 @@ class PasswordManager:
def handle_export_database(
self,
mode: "PortableMode" = PortableMode.SEED_ONLY,
dest: Path | None = None,
) -> Path | None:
"""Export the current database to an encrypted portable file."""
@@ -1193,7 +1213,6 @@ class PasswordManager:
path = export_backup(
self.vault,
self.backup_manager,
mode,
dest,
parent_seed=self.parent_seed,
)
@@ -1397,15 +1416,7 @@ class PasswordManager:
config_data = self.config_manager.load_config(require_pin=False)
# Create a new encryption manager with the new password
mode = getattr(self, "encryption_mode", DEFAULT_ENCRYPTION_MODE)
try:
new_key = derive_index_key(
self.parent_seed,
new_password,
mode,
)
except Exception:
new_key = derive_key_from_password(new_password)
new_key = derive_index_key(self.parent_seed)
seed_key = derive_key_from_password(new_password)
seed_mgr = EncryptionManager(seed_key, self.fingerprint_dir)
@@ -1436,13 +1447,8 @@ class PasswordManager:
# Push a fresh backup to Nostr so the newly encrypted index is
# stored remotely. Include a tag to mark the password change.
try:
encrypted_data = self.get_encrypted_data()
if encrypted_data:
summary = f"password-change-{int(time.time())}"
self.nostr_client.publish_json_to_nostr(
encrypted_data,
alt_summary=summary,
)
summary = f"password-change-{int(time.time())}"
self.sync_vault(alt_summary=summary)
except Exception as nostr_error:
logging.error(
f"Failed to post updated index to Nostr after password change: {nostr_error}"

View File

@@ -26,7 +26,20 @@ def _v0_to_v1(data: dict) -> dict:
return data
LATEST_VERSION = 1
@migration(1)
def _v1_to_v2(data: dict) -> dict:
passwords = data.pop("passwords", {})
entries = {}
for k, v in passwords.items():
v.setdefault("type", "password")
v.setdefault("notes", "")
entries[k] = v
data["entries"] = entries
data["schema_version"] = 2
return data
LATEST_VERSION = 2
def apply_migrations(data: dict) -> dict:

View File

@@ -19,6 +19,7 @@ import hashlib
import string
import random
import traceback
import base64
from typing import Optional
from termcolor import colored
from pathlib import Path
@@ -332,3 +333,19 @@ class PasswordGenerator:
logger.error(f"Error ensuring password complexity: {e}", exc_info=True)
print(colored(f"Error: Failed to ensure password complexity: {e}", "red"))
raise
def derive_totp_secret(bip85: BIP85, idx: int) -> str:
"""Derive a TOTP secret for the given index using BIP85."""
entropy = bip85.derive_entropy(index=idx, bytes_len=10, app_no=2)
return base64.b32encode(entropy).decode("utf-8")
def derive_ssh_key(bip85: BIP85, idx: int) -> bytes:
"""Derive 32 bytes of entropy suitable for an SSH key."""
return bip85.derive_entropy(index=idx, bytes_len=32, app_no=32)
def derive_seed_phrase(bip85: BIP85, idx: int, words: int = 24) -> str:
"""Derive a new BIP39 seed phrase using BIP85."""
return bip85.derive_mnemonic(index=idx, words_num=words)

View File

@@ -8,6 +8,7 @@ import json
import logging
import os
import time
import asyncio
from enum import Enum
from pathlib import Path
@@ -17,9 +18,7 @@ from nostr.client import NostrClient
from utils.key_derivation import (
derive_index_key,
EncryptionMode,
DEFAULT_ENCRYPTION_MODE,
)
from utils.password_prompt import prompt_existing_password
from password_manager.encryption import EncryptionManager
from utils.checksum import json_checksum, canonical_json_dumps
@@ -33,25 +32,17 @@ class PortableMode(Enum):
"""Encryption mode for portable exports."""
SEED_ONLY = EncryptionMode.SEED_ONLY.value
SEED_PLUS_PW = EncryptionMode.SEED_PLUS_PW.value
PW_ONLY = EncryptionMode.PW_ONLY.value
def _derive_export_key(
seed: str,
mode: PortableMode,
password: str | None = None,
) -> bytes:
def _derive_export_key(seed: str) -> bytes:
"""Derive the Fernet key for the export payload."""
enc_mode = EncryptionMode(mode.value)
return derive_index_key(seed, password, enc_mode)
return derive_index_key(seed)
def export_backup(
vault: Vault,
backup_manager: BackupManager,
mode: PortableMode = PortableMode.SEED_ONLY,
dest_path: Path | None = None,
*,
publish: bool = False,
@@ -71,11 +62,7 @@ def export_backup(
if parent_seed is not None
else vault.encryption_manager.decrypt_parent_seed()
)
password = None
if mode in (PortableMode.SEED_PLUS_PW, PortableMode.PW_ONLY):
password = prompt_existing_password("Enter your master password: ")
key = _derive_export_key(seed, mode, password)
key = _derive_export_key(seed)
enc_mgr = EncryptionManager(key, vault.fingerprint_dir)
canonical = canonical_json_dumps(index_data)
@@ -86,7 +73,7 @@ def export_backup(
"format_version": FORMAT_VERSION,
"created_at": int(time.time()),
"fingerprint": vault.fingerprint_dir.name,
"encryption_mode": mode.value,
"encryption_mode": PortableMode.SEED_ONLY.value,
"cipher": "fernet",
"checksum": checksum,
"payload": base64.b64encode(payload_bytes).decode("utf-8"),
@@ -103,7 +90,7 @@ def export_backup(
os.chmod(enc_file, 0o600)
try:
client = NostrClient(vault.encryption_manager, vault.fingerprint_dir.name)
client.publish_json_to_nostr(encrypted)
asyncio.run(client.publish_snapshot(encrypted))
except Exception:
logger.error("Failed to publish backup via Nostr", exc_info=True)
@@ -126,7 +113,8 @@ def import_backup(
if wrapper.get("format_version") != FORMAT_VERSION:
raise ValueError("Unsupported backup format")
mode = PortableMode(wrapper.get("encryption_mode", PortableMode.SEED_ONLY.value))
if wrapper.get("encryption_mode") != PortableMode.SEED_ONLY.value:
raise ValueError("Unsupported encryption mode")
payload = base64.b64decode(wrapper["payload"])
seed = (
@@ -134,11 +122,7 @@ def import_backup(
if parent_seed is not None
else vault.encryption_manager.decrypt_parent_seed()
)
password = None
if mode in (PortableMode.SEED_PLUS_PW, PortableMode.PW_ONLY):
password = prompt_existing_password("Enter your master password: ")
key = _derive_export_key(seed, mode, password)
key = _derive_export_key(seed)
enc_mgr = EncryptionManager(key, vault.fingerprint_dir)
index_bytes = enc_mgr.decrypt_data(payload)
index = json.loads(index_bytes.decode("utf-8"))

View File

@@ -10,7 +10,7 @@ from .encryption import EncryptionManager
class Vault:
"""Simple wrapper around :class:`EncryptionManager` for vault storage."""
INDEX_FILENAME = "seedpass_passwords_db.json.enc"
INDEX_FILENAME = "seedpass_entries_db.json.enc"
CONFIG_FILENAME = "seedpass_config.json.enc"
def __init__(

View File

@@ -7,7 +7,6 @@ coincurve>=18.0.0
mnemonic
aiohttp
bcrypt
bip85
pytest>=7.0
pytest-cov
pytest-xdist

View File

@@ -14,10 +14,17 @@ def pytest_addoption(parser: pytest.Parser) -> None:
default=False,
help="run stress tests",
)
parser.addoption(
"--desktop",
action="store_true",
default=False,
help="run desktop-only tests",
)
def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line("markers", "stress: long running stress tests")
config.addinivalue_line("markers", "desktop: desktop only tests")
def pytest_collection_modifyitems(
@@ -30,3 +37,9 @@ def pytest_collection_modifyitems(
for item in items:
if "stress" in item.keywords:
item.add_marker(skip_stress)
if not config.getoption("--desktop"):
skip_desktop = pytest.mark.skip(reason="need --desktop option to run")
for item in items:
if "desktop" in item.keywords:
item.add_marker(skip_desktop)

View File

@@ -8,7 +8,6 @@ from password_manager.encryption import EncryptionManager
from utils.key_derivation import (
derive_index_key,
derive_key_from_password,
EncryptionMode,
)
TEST_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
@@ -19,14 +18,238 @@ def create_vault(
dir_path: Path,
seed: str = TEST_SEED,
password: str = TEST_PASSWORD,
mode: EncryptionMode = EncryptionMode.SEED_ONLY,
) -> tuple[Vault, EncryptionManager]:
"""Create a Vault initialized for tests."""
seed_key = derive_key_from_password(password)
seed_mgr = EncryptionManager(seed_key, dir_path)
seed_mgr.encrypt_parent_seed(seed)
index_key = derive_index_key(seed, password, mode)
index_key = derive_index_key(seed)
enc_mgr = EncryptionManager(index_key, dir_path)
vault = Vault(enc_mgr, dir_path)
return vault, enc_mgr
import uuid
import asyncio
import pytest
from nostr.backup_models import (
KIND_MANIFEST,
KIND_SNAPSHOT_CHUNK,
KIND_DELTA,
)
class DummyEvent:
def __init__(self, kind: int, content: str, tags=None, event_id: str | None = None):
self.kind = kind
self._content = content
self.tags = tags or []
self.id = event_id or f"evt-{uuid.uuid4().hex}"
def content(self):
return self._content
class DummyUnsignedEvent:
def __init__(self, kind: int, content: str, tags: list[str]):
self.kind = kind
self.content = content
self.tags = tags
def sign_with_keys(self, _keys):
return DummyEvent(self.kind, self.content, self.tags)
class DummyBuilder:
def __init__(self, kind=None, content=""):
if hasattr(kind, "as_u16"):
self.kind = kind.as_u16()
elif hasattr(kind, "value"):
self.kind = kind.value
else:
self.kind = int(kind)
self.content = content
self._tags: list[str] = []
def tags(self, tags):
# store raw tag values
self._tags.extend(tags)
return self
def build(self, _pk):
return DummyUnsignedEvent(self.kind, self.content, self._tags)
class DummyTag:
@staticmethod
def identifier(value):
return value
@staticmethod
def event(value):
return value
@staticmethod
def alt(value):
return value
@staticmethod
def expiration(value):
return value
class DummyFilter:
def __init__(self):
self.kind_val: int | None = None
self.ids: list[str] = []
self.limit_val: int | None = None
self.since_val: int | None = None
def author(self, _pk):
return self
def kind(self, kind):
if hasattr(kind, "as_u16"):
self.kind_val = kind.as_u16()
elif hasattr(kind, "value"):
self.kind_val = kind.value
else:
self.kind_val = int(kind)
return self
def identifier(self, ident: str):
self.ids.append(ident)
return self
def limit(self, val: int):
self.limit_val = val
return self
def since(self, ts):
self.since_val = getattr(ts, "secs", ts)
return self
class DummyTimestamp:
def __init__(self, secs: int):
self.secs = secs
@staticmethod
def from_secs(secs: int) -> "DummyTimestamp":
return DummyTimestamp(secs)
class DummyEventId:
def __init__(self, val: str):
self.val = val
def to_hex(self) -> str:
return self.val
@staticmethod
def parse(val: str) -> str:
return val
class DummySendResult:
def __init__(self, event_id: str):
self.id = DummyEventId(event_id)
class DummyRelayClient:
def __init__(self):
self.counter = 0
self.manifests: list[DummyEvent] = []
self.chunks: dict[str, DummyEvent] = {}
self.deltas: list[DummyEvent] = []
async def add_relays(self, _relays):
pass
async def add_relay(self, _relay):
pass
async def connect(self):
pass
async def disconnect(self):
pass
async def send_event(self, event):
self.counter += 1
eid = str(self.counter)
if isinstance(event, DummyEvent):
event.id = eid
if event.kind == KIND_MANIFEST:
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
elif event.kind == KIND_DELTA:
self.deltas.append(event)
return DummySendResult(eid)
async def fetch_events(self, f, _timeout):
kind = getattr(f, "kind_val", None)
limit = getattr(f, "limit_val", None)
identifier = f.ids[0] if getattr(f, "ids", None) else None
since = getattr(f, "since_val", None)
events: list[DummyEvent] = []
if kind == KIND_MANIFEST:
events = list(reversed(self.manifests))
elif kind == KIND_SNAPSHOT_CHUNK and identifier is not None:
if identifier in self.chunks:
events = [self.chunks[identifier]]
elif kind == KIND_DELTA:
events = [d for d in self.deltas if since is None or int(d.id) > since]
if limit is not None:
events = events[:limit]
class Result:
def __init__(self, evs):
self._evs = evs
def to_vec(self):
return self._evs
return Result(events)
@pytest.fixture
def dummy_nostr_client(tmp_path, monkeypatch):
"""Return a NostrClient wired to a DummyRelayClient."""
from cryptography.fernet import Fernet
from nostr.client import NostrClient
relay = DummyRelayClient()
monkeypatch.setattr("nostr.client.Client", lambda signer: relay)
monkeypatch.setattr("nostr.client.EventBuilder", DummyBuilder)
monkeypatch.setattr("nostr.client.Filter", DummyFilter)
monkeypatch.setattr("nostr.client.Tag", DummyTag)
monkeypatch.setattr("nostr.client.Timestamp", DummyTimestamp)
monkeypatch.setattr("nostr.client.EventId", DummyEventId)
from nostr.backup_models import KIND_DELTA as KD
monkeypatch.setattr("nostr.client.KIND_DELTA", KD, raising=False)
monkeypatch.setattr(NostrClient, "initialize_client_pool", lambda self: None)
enc_mgr = EncryptionManager(Fernet.generate_key(), tmp_path)
class DummyKeys:
def private_key_hex(self):
return "1" * 64
def public_key_hex(self):
return "2" * 64
class DummyKeyManager:
def __init__(self, *a, **k):
self.keys = DummyKeys()
with pytest.MonkeyPatch().context() as mp:
mp.setattr("nostr.client.KeyManager", DummyKeyManager)
mp.setattr(enc_mgr, "decrypt_parent_seed", lambda: TEST_SEED)
client = NostrClient(enc_mgr, "fp")
return client, relay

View File

@@ -31,7 +31,7 @@ def test_auto_sync_triggers_post(monkeypatch):
called = True
monkeypatch.setattr(main, "handle_post_to_nostr", fake_post)
monkeypatch.setattr("builtins.input", lambda _: "5")
monkeypatch.setattr(main, "timed_input", lambda *_: "5")
with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=0.1)

View File

@@ -17,35 +17,45 @@ def test_backup_restore_workflow(monkeypatch):
vault, enc_mgr = create_vault(fp_dir, TEST_SEED, TEST_PASSWORD)
backup_mgr = BackupManager(fp_dir)
index_file = fp_dir / "seedpass_passwords_db.json.enc"
index_file = fp_dir / "seedpass_entries_db.json.enc"
data1 = {"passwords": {"0": {"website": "a", "length": 10}}}
data1 = {
"schema_version": 2,
"entries": {
"0": {"website": "a", "length": 10, "type": "password", "notes": ""}
},
}
vault.save_index(data1)
os.utime(index_file, (1, 1))
monkeypatch.setattr(time, "time", lambda: 1111)
backup_mgr.create_backup()
backup1 = fp_dir / "backups" / "passwords_db_backup_1111.json.enc"
backup1 = fp_dir / "backups" / "entries_db_backup_1111.json.enc"
assert backup1.exists()
assert backup1.stat().st_mode & 0o777 == 0o600
data2 = {"passwords": {"0": {"website": "b", "length": 12}}}
data2 = {
"schema_version": 2,
"entries": {
"0": {"website": "b", "length": 12, "type": "password", "notes": ""}
},
}
vault.save_index(data2)
os.utime(index_file, (2, 2))
monkeypatch.setattr(time, "time", lambda: 2222)
backup_mgr.create_backup()
backup2 = fp_dir / "backups" / "passwords_db_backup_2222.json.enc"
backup2 = fp_dir / "backups" / "entries_db_backup_2222.json.enc"
assert backup2.exists()
assert backup2.stat().st_mode & 0o777 == 0o600
vault.save_index({"passwords": {"temp": {}}})
vault.save_index({"schema_version": 2, "entries": {"temp": {}}})
backup_mgr.restore_latest_backup()
assert vault.load_index()["passwords"] == data2["passwords"]
assert vault.load_index()["entries"] == data2["entries"]
vault.save_index({"passwords": {}})
vault.save_index({"schema_version": 2, "entries": {}})
backup_mgr.restore_backup_by_timestamp(1111)
assert vault.load_index()["passwords"] == data1["passwords"]
assert vault.load_index()["entries"] == data1["entries"]
backup1.unlink()
current = vault.load_index()

View File

@@ -4,7 +4,12 @@ import pytest
sys.path.append(str(Path(__file__).resolve().parents[1]))
from local_bip85.bip85 import BIP85
from local_bip85.bip85 import BIP85, Bip85Error
from password_manager.password_generation import (
derive_totp_secret,
derive_ssh_key,
derive_seed_phrase,
)
MASTER_XPRV = "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb"
@@ -13,6 +18,8 @@ EXPECTED_12 = "girl mad pet galaxy egg matter matrix prison refuse sense ordinar
EXPECTED_24 = "puppy ocean match cereal symbol another shed magic wrap hammer bulb intact gadget divorce twin tonight reason outdoor destroy simple truth cigar social volcano"
EXPECTED_SYMM_KEY = "7040bb53104f27367f317558e78a994ada7296c6fde36a364e5baf206e502bb1"
EXPECTED_TOTP_SECRET = "OBALWUYQJ4TTM7ZR"
EXPECTED_SSH_KEY = "52405cd0dd21c5be78314a7c1a3c65ffd8d896536cc7dee3157db5824f0c92e2"
@pytest.fixture(scope="module")
@@ -32,8 +39,20 @@ def test_bip85_symmetric_key(bip85):
assert bip85.derive_symmetric_key(index=0).hex() == EXPECTED_SYMM_KEY
def test_derive_totp_secret(bip85):
assert derive_totp_secret(bip85, 0) == EXPECTED_TOTP_SECRET
def test_derive_ssh_key(bip85):
assert derive_ssh_key(bip85, 0).hex() == EXPECTED_SSH_KEY
def test_derive_seed_phrase(bip85):
assert derive_seed_phrase(bip85, 0) == EXPECTED_24
def test_invalid_params(bip85):
with pytest.raises(SystemExit):
with pytest.raises(Bip85Error):
bip85.derive_mnemonic(index=0, words_num=15)
with pytest.raises(SystemExit):
with pytest.raises(Bip85Error):
bip85.derive_mnemonic(index=-1, words_num=12)

View File

@@ -1,55 +0,0 @@
import sys
from pathlib import Path
import argparse
import pytest
sys.path.append(str(Path(__file__).resolve().parents[1]))
import main
from utils.key_derivation import EncryptionMode
from password_manager.manager import PasswordManager
def _get_mode(monkeypatch, args=None, cfg=None):
if args is None:
args = []
if cfg is None:
cfg = {}
monkeypatch.setattr(main, "load_global_config", lambda: cfg)
monkeypatch.setattr(sys, "argv", ["prog"] + args)
parser = argparse.ArgumentParser()
parser.add_argument(
"--encryption-mode",
choices=[m.value for m in EncryptionMode],
help="Select encryption mode",
)
parsed = parser.parse_args()
mode_value = cfg.get("encryption_mode", EncryptionMode.SEED_ONLY.value)
if parsed.encryption_mode:
mode_value = parsed.encryption_mode
return EncryptionMode(mode_value)
def test_default_mode_is_seed_only(monkeypatch):
mode = _get_mode(monkeypatch)
assert mode is EncryptionMode.SEED_ONLY
def test_cli_flag_overrides_config(monkeypatch):
cfg = {"encryption_mode": EncryptionMode.PW_ONLY.value}
mode = _get_mode(monkeypatch, ["--encryption-mode", "seed+pw"], cfg)
assert mode is EncryptionMode.SEED_PLUS_PW
def test_pw_only_emits_warning(monkeypatch, capsys):
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.fingerprint_manager = object()
pm.setup_existing_seed = lambda: None
pm.generate_new_seed = lambda: None
inputs = iter(["3", "1"])
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
pm.handle_new_seed_setup()
out = capsys.readouterr().out
assert "Password-only encryption is less secure" in out
assert pm.encryption_mode is EncryptionMode.PW_ONLY

View File

@@ -52,7 +52,7 @@ def test_empty_and_non_numeric_choice(monkeypatch, capsys):
called = {"add": False, "retrieve": False, "modify": False}
pm, _ = _make_pm(called)
inputs = iter(["", "abc", "5"])
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000)
out = capsys.readouterr().out
@@ -65,7 +65,7 @@ def test_out_of_range_menu(monkeypatch, capsys):
called = {"add": False, "retrieve": False, "modify": False}
pm, _ = _make_pm(called)
inputs = iter(["9", "5"])
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000)
out = capsys.readouterr().out
@@ -77,6 +77,7 @@ def test_invalid_add_entry_submenu(monkeypatch, capsys):
called = {"add": False, "retrieve": False, "modify": False}
pm, _ = _make_pm(called)
inputs = iter(["1", "3", "2", "5"])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000)
@@ -90,7 +91,7 @@ def test_inactivity_timeout_loop(monkeypatch, capsys):
pm, locked = _make_pm(called)
pm.last_activity = 0
monkeypatch.setattr(time, "time", lambda: 100.0)
monkeypatch.setattr("builtins.input", lambda *_: "5")
monkeypatch.setattr(main, "timed_input", lambda *_: "5")
with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1)
out = capsys.readouterr().out

View File

@@ -1,47 +0,0 @@
import sys
from pathlib import Path
import runpy
import pytest
sys.path.append(str(Path(__file__).resolve().parents[1]))
import main
from password_manager.portable_backup import PortableMode
from password_manager.manager import PasswordManager
def _run(argv, monkeypatch):
monkeypatch.setattr(sys, "argv", ["seedpass"] + argv)
monkeypatch.setattr(main, "load_global_config", lambda: {})
called = {}
def fake_init(self, encryption_mode):
called["init"] = True
def fake_export(self, mode, dest):
called["export"] = (mode, Path(dest))
def fake_import(self, src):
called["import"] = Path(src)
monkeypatch.setattr(PasswordManager, "__init__", fake_init)
monkeypatch.setattr(PasswordManager, "handle_export_database", fake_export)
monkeypatch.setattr(PasswordManager, "handle_import_database", fake_import)
with pytest.raises(SystemExit):
runpy.run_module("main", run_name="__main__")
return called
def test_export_command_invokes_handler(monkeypatch):
called = _run(["export", "--mode", "pw-only", "--file", "out.json"], monkeypatch)
assert called["export"] == (PortableMode.PW_ONLY, Path("out.json"))
assert "import" not in called
def test_import_command_invokes_handler(monkeypatch):
called = _run(["import", "--file", "backup.json"], monkeypatch)
assert called["import"] == Path("backup.json")
assert "export" not in called

View File

@@ -9,11 +9,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.encryption import EncryptionManager
from password_manager.vault import Vault
from password_manager.backup import BackupManager
from utils.key_derivation import (
derive_index_key,
derive_key_from_password,
EncryptionMode,
)
from utils.key_derivation import derive_index_key, derive_key_from_password
def _writer(index_key: bytes, dir_path: Path, loops: int, out: Queue) -> None:
@@ -50,7 +46,7 @@ def _backup(dir_path: Path, loops: int, out: Queue) -> None:
@pytest.mark.parametrize("loops", [5, pytest.param(20, marks=pytest.mark.stress)])
@pytest.mark.parametrize("_", range(3))
def test_concurrency_stress(tmp_path: Path, loops: int, _):
index_key = derive_index_key(TEST_SEED, TEST_PASSWORD, EncryptionMode.SEED_ONLY)
index_key = derive_index_key(TEST_SEED)
seed_key = derive_key_from_password(TEST_PASSWORD)
EncryptionManager(seed_key, tmp_path).encrypt_parent_seed(TEST_SEED)
enc = EncryptionManager(index_key, tmp_path)

View File

@@ -10,6 +10,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.config_manager import ConfigManager
from password_manager.vault import Vault
from nostr.client import DEFAULT_RELAYS
from constants import INACTIVITY_TIMEOUT
def test_config_defaults_and_round_trip():
@@ -80,6 +81,19 @@ def test_set_relays_requires_at_least_one():
cfg_mgr.set_relays([], require_pin=False)
def test_inactivity_timeout_round_trip():
with TemporaryDirectory() as tmpdir:
vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, Path(tmpdir))
cfg = cfg_mgr.load_config(require_pin=False)
assert cfg["inactivity_timeout"] == INACTIVITY_TIMEOUT
cfg_mgr.set_inactivity_timeout(123)
cfg2 = cfg_mgr.load_config(require_pin=False)
assert cfg2["inactivity_timeout"] == 123
def test_password_hash_migrates_from_file(tmp_path):
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)

View File

@@ -0,0 +1,29 @@
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).resolve().parents[1]))
from types import SimpleNamespace
from pathlib import Path
from password_manager.manager import PasswordManager
from utils.key_derivation import EncryptionMode
def test_default_encryption_mode(monkeypatch):
monkeypatch.setattr(
PasswordManager,
"initialize_fingerprint_manager",
lambda self: setattr(
self,
"fingerprint_manager",
SimpleNamespace(
get_current_fingerprint_dir=lambda: Path("./"),
list_fingerprints=lambda: [],
),
),
)
monkeypatch.setattr(PasswordManager, "setup_parent_seed", lambda self: None)
pm = PasswordManager()
assert pm.encryption_mode is EncryptionMode.SEED_ONLY

View File

@@ -21,8 +21,8 @@ def test_encryption_checksum_workflow():
manager.save_json_data(data)
manager.update_checksum()
enc_file = tmp_path / "seedpass_passwords_db.json.enc"
chk_file = tmp_path / "seedpass_passwords_db.json_checksum.txt"
enc_file = tmp_path / "seedpass_entries_db.json.enc"
chk_file = tmp_path / "seedpass_entries_db.json_checksum.txt"
checksum = chk_file.read_text().strip()
assert re.fullmatch(r"[0-9a-f]{64}", checksum)

View File

@@ -20,7 +20,7 @@ def test_json_save_and_load_round_trip():
loaded = manager.load_json_data()
assert loaded == data
file_path = Path(tmpdir) / "seedpass_passwords_db.json.enc"
file_path = Path(tmpdir) / "seedpass_entries_db.json.enc"
raw = file_path.read_bytes()
assert raw != json.dumps(data, indent=4).encode("utf-8")

View File

@@ -1,6 +1,7 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
@@ -23,8 +24,37 @@ def test_add_and_retrieve_entry():
"username": "user",
"url": "",
"blacklisted": False,
"type": "password",
"notes": "",
}
data = enc_mgr.load_json_data(entry_mgr.index_file)
assert str(index) in data.get("passwords", {})
assert data["passwords"][str(index)] == entry
assert str(index) in data.get("entries", {})
assert data["entries"][str(index)] == entry
@pytest.mark.parametrize(
"method, expected_type",
[
("add_entry", "password"),
("add_totp", "totp"),
("add_ssh_key", "ssh"),
("add_seed", "seed"),
],
)
def test_round_trip_entry_types(method, expected_type):
with TemporaryDirectory() as tmpdir:
vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
entry_mgr = EntryManager(vault, Path(tmpdir))
if method == "add_entry":
index = entry_mgr.add_entry("example.com", 8)
else:
with pytest.raises(NotImplementedError):
getattr(entry_mgr, method)()
index = 0
entry = entry_mgr.retrieve_entry(index)
assert entry["type"] == expected_type
data = enc_mgr.load_json_data(entry_mgr.index_file)
assert data["entries"][str(index)]["type"] == expected_type

View File

@@ -16,10 +16,10 @@ def test_update_checksum_writes_to_expected_path():
entry_mgr = EntryManager(vault, tmp_path)
# create an empty index file
vault.save_index({"passwords": {}})
vault.save_index({"entries": {}})
entry_mgr.update_checksum()
expected = tmp_path / "seedpass_passwords_db_checksum.txt"
expected = tmp_path / "seedpass_entries_db_checksum.txt"
assert expected.exists()
@@ -29,8 +29,8 @@ def test_backup_index_file_creates_backup_in_directory():
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
entry_mgr = EntryManager(vault, tmp_path)
vault.save_index({"passwords": {}})
vault.save_index({"entries": {}})
entry_mgr.backup_index_file()
backups = list(tmp_path.glob("passwords_db_backup_*.json.enc"))
backups = list(tmp_path.glob("entries_db_backup_*.json.enc"))
assert len(backups) == 1

View File

@@ -36,10 +36,54 @@ def test_inactivity_triggers_lock(monkeypatch):
unlock_vault=unlock_vault,
)
monkeypatch.setattr("builtins.input", lambda _: "5")
monkeypatch.setattr(main, "timed_input", lambda *_: "5")
with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1)
assert locked["locked"]
assert locked["unlocked"]
def test_input_timeout_triggers_lock(monkeypatch):
"""Ensure locking occurs if no input is provided before timeout."""
locked = {"locked": 0, "unlocked": 0}
def update_activity():
pm.last_activity = time.time()
def lock_vault():
locked["locked"] += 1
def unlock_vault():
locked["unlocked"] += 1
update_activity()
pm = SimpleNamespace(
is_dirty=False,
last_update=time.time(),
last_activity=time.time(),
nostr_client=SimpleNamespace(close_client_pool=lambda: None),
handle_add_password=lambda: None,
handle_retrieve_entry=lambda: None,
handle_modify_entry=lambda: None,
update_activity=update_activity,
lock_vault=lock_vault,
unlock_vault=unlock_vault,
)
responses = iter([TimeoutError(), "5"])
def fake_input(*_args, **_kwargs):
val = next(responses)
if isinstance(val, Exception):
raise val
return val
monkeypatch.setattr(main, "timed_input", fake_input)
with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1)
assert locked["locked"] == 1
assert locked["unlocked"] == 1

View File

@@ -9,50 +9,48 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.encryption import EncryptionManager
from password_manager.vault import Vault
from utils.key_derivation import (
derive_index_key,
derive_key_from_password,
EncryptionMode,
)
from utils.key_derivation import derive_index_key, derive_key_from_password
SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
PASSWORD = "passw0rd"
def setup_vault(tmp: Path, mode: EncryptionMode) -> Vault:
def setup_vault(tmp: Path) -> Vault:
seed_key = derive_key_from_password(PASSWORD)
seed_mgr = EncryptionManager(seed_key, tmp)
seed_mgr.encrypt_parent_seed(SEED)
key = derive_index_key(SEED, PASSWORD, mode)
key = derive_index_key(SEED)
enc_mgr = EncryptionManager(key, tmp)
return Vault(enc_mgr, tmp)
@pytest.mark.parametrize(
"mode",
[
EncryptionMode.SEED_ONLY,
EncryptionMode.SEED_PLUS_PW,
EncryptionMode.PW_ONLY,
],
)
def test_index_export_import_round_trip(mode):
def test_index_export_import_round_trip():
with TemporaryDirectory() as td:
tmp = Path(td)
vault = setup_vault(tmp, mode)
vault = setup_vault(tmp)
original = {"passwords": {"0": {"website": "example"}}}
original = {
"schema_version": 2,
"entries": {"0": {"website": "example", "type": "password", "notes": ""}},
}
vault.save_index(original)
encrypted = vault.get_encrypted_index()
assert isinstance(encrypted, bytes)
vault.save_index({"passwords": {"0": {"website": "changed"}}})
vault.save_index(
{
"schema_version": 2,
"entries": {
"0": {"website": "changed", "type": "password", "notes": ""}
},
}
)
vault.decrypt_and_save_index_from_nostr(encrypted)
loaded = vault.load_index()
assert loaded["passwords"] == original["passwords"]
assert loaded["entries"] == original["entries"]
def test_get_encrypted_index_missing_file(tmp_path):

View File

@@ -3,9 +3,7 @@ import pytest
from utils.key_derivation import (
derive_key_from_password,
derive_index_key_seed_only,
derive_index_key_seed_plus_pw,
derive_index_key,
EncryptionMode,
)
@@ -32,23 +30,6 @@ def test_seed_only_key_deterministic():
assert len(k1) == 44
def test_seed_plus_pw_differs_from_seed_only():
def test_derive_index_key_seed_only():
seed = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
pw = "hunter2"
k1 = derive_index_key_seed_only(seed)
k2 = derive_index_key_seed_plus_pw(seed, pw)
assert k1 != k2
def test_derive_index_key_modes():
seed = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
pw = "hunter2"
assert derive_index_key(
seed, pw, EncryptionMode.SEED_ONLY
) == derive_index_key_seed_only(seed)
assert derive_index_key(
seed, pw, EncryptionMode.SEED_PLUS_PW
) == derive_index_key_seed_plus_pw(seed, pw)
assert derive_index_key(
seed, pw, EncryptionMode.PW_ONLY
) == derive_key_from_password(pw)
assert derive_index_key(seed) == derive_index_key_seed_only(seed)

View File

@@ -3,7 +3,7 @@ from pathlib import Path
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.manager import PasswordManager
from password_manager.manager import PasswordManager, EncryptionMode
class FakeBackupManager:
@@ -19,6 +19,7 @@ class FakeBackupManager:
def _make_pm():
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
return pm

View File

@@ -8,7 +8,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager
from password_manager.vault import Vault
from password_manager.backup import BackupManager
from password_manager.manager import PasswordManager
from password_manager.manager import PasswordManager, EncryptionMode
class FakePasswordGenerator:
@@ -20,9 +20,9 @@ class FakeNostrClient:
def __init__(self, *args, **kwargs):
self.published = []
def publish_json_to_nostr(self, data: bytes):
def publish_snapshot(self, data: bytes):
self.published.append(data)
return True
return None, "abcd"
def test_manager_workflow(monkeypatch):
@@ -35,6 +35,7 @@ def test_manager_workflow(monkeypatch):
monkeypatch.setattr("password_manager.manager.NostrClient", FakeNostrClient)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = vault
pm.entry_manager = entry_mgr
@@ -50,20 +51,22 @@ def test_manager_workflow(monkeypatch):
"", # username
"", # url
"", # length (default)
"", # notes
"0", # retrieve index
"0", # modify index
"user", # new username
"", # new url
"", # blacklist status
"", # new notes
]
)
monkeypatch.setattr("builtins.input", lambda *args, **kwargs: next(inputs))
pm.handle_add_password()
assert pm.is_dirty is True
backups = list(tmp_path.glob("passwords_db_backup_*.json.enc"))
assert pm.is_dirty is False
backups = list(tmp_path.glob("entries_db_backup_*.json.enc"))
assert len(backups) == 1
checksum_file = tmp_path / "seedpass_passwords_db_checksum.txt"
checksum_file = tmp_path / "seedpass_entries_db_checksum.txt"
assert checksum_file.exists()
checksum_after_add = checksum_file.read_text()
first_post = pm.nostr_client.published[-1]
@@ -73,10 +76,10 @@ def test_manager_workflow(monkeypatch):
assert pm.is_dirty is False
pm.handle_modify_entry()
assert pm.is_dirty is True
assert pm.is_dirty is False
pm.backup_manager.create_backup()
backup_dir = tmp_path / "backups"
backups_mod = list(backup_dir.glob("passwords_db_backup_*.json.enc"))
backups_mod = list(backup_dir.glob("entries_db_backup_*.json.enc"))
assert backups_mod
checksum_after_modify = checksum_file.read_text()
assert checksum_after_modify != checksum_after_add

View File

@@ -13,18 +13,34 @@ def setup(tmp_path: Path):
return enc_mgr, vault
def test_migrate_v0_to_v1(tmp_path: Path):
def test_migrate_v0_to_v2(tmp_path: Path):
enc_mgr, vault = setup(tmp_path)
legacy = {"passwords": {"0": {"website": "a", "length": 8}}}
enc_mgr.save_json_data(legacy)
data = vault.load_index()
assert data["schema_version"] == LATEST_VERSION
assert data["passwords"] == legacy["passwords"]
expected_entry = {"website": "a", "length": 8, "type": "password", "notes": ""}
assert data["entries"]["0"] == expected_entry
def test_migrate_v1_to_v2(tmp_path: Path):
enc_mgr, vault = setup(tmp_path)
legacy = {"schema_version": 1, "passwords": {"0": {"website": "b", "length": 10}}}
enc_mgr.save_json_data(legacy)
data = vault.load_index()
assert data["schema_version"] == LATEST_VERSION
expected_entry = {
"website": "b",
"length": 10,
"type": "password",
"notes": "",
}
assert data["entries"]["0"] == expected_entry
def test_error_on_future_version(tmp_path: Path):
enc_mgr, vault = setup(tmp_path)
future = {"schema_version": LATEST_VERSION + 1, "passwords": {}}
future = {"schema_version": LATEST_VERSION + 1, "entries": {}}
enc_mgr.save_json_data(future)
with pytest.raises(ValueError):
vault.load_index()

View File

@@ -1,7 +1,8 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from unittest.mock import patch, AsyncMock
import asyncio
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
@@ -23,7 +24,8 @@ def test_backup_and_publish_to_nostr():
assert encrypted_index is not None
with patch(
"nostr.client.NostrClient.publish_json_to_nostr", return_value=True
"nostr.client.NostrClient.publish_snapshot",
AsyncMock(return_value=(None, "abcd")),
) as mock_publish, patch("nostr.client.ClientBuilder"), patch(
"nostr.client.KeyManager"
), patch.object(
@@ -33,7 +35,7 @@ def test_backup_and_publish_to_nostr():
):
nostr_client = NostrClient(enc_mgr, "fp")
entry_mgr.backup_index_file()
result = nostr_client.publish_json_to_nostr(encrypted_index)
result = asyncio.run(nostr_client.publish_snapshot(encrypted_index))
mock_publish.assert_called_with(encrypted_index)
assert result is True
mock_publish.assert_awaited_with(encrypted_index)
assert result == (None, "abcd")

View File

@@ -1,12 +1,14 @@
import sys
from pathlib import Path
from unittest.mock import patch
import asyncio
import gzip
from cryptography.fernet import Fernet
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.encryption import EncryptionManager
from nostr.client import NostrClient
from nostr.client import NostrClient, Manifest
class MockNostrServer:
@@ -17,6 +19,7 @@ class MockNostrServer:
class MockClient:
def __init__(self, server):
self.server = server
self.pos = -1
async def add_relays(self, relays):
pass
@@ -44,14 +47,17 @@ class MockClient:
return FakeOutput()
async def fetch_events(self, filter_obj, timeout):
ev = self.server.events[self.pos]
self.pos -= 1
class FakeEvents:
def __init__(self, events):
self._events = events
def __init__(self, event):
self._event = event
def to_vec(self):
return self._events
return [self._event]
return FakeEvents(self.server.events[-1:])
return FakeEvents(ev)
def setup_client(tmp_path, server):
@@ -72,5 +78,6 @@ def test_publish_and_retrieve(tmp_path):
server = MockNostrServer()
client = setup_client(tmp_path, server)
payload = b"contract-test"
assert client.publish_json_to_nostr(payload) is True
assert client.retrieve_json_from_nostr_sync() == payload
asyncio.run(client.publish_snapshot(payload))
manifest, chunks = asyncio.run(client.fetch_latest_snapshot())
assert gzip.decompress(b"".join(chunks)) == payload

View File

@@ -0,0 +1,50 @@
import asyncio
import gzip
import math
from helpers import create_vault, dummy_nostr_client
from password_manager.entry_management import EntryManager
from nostr.client import prepare_snapshot
def test_manifest_generation(tmp_path):
vault, enc_mgr = create_vault(tmp_path)
entry_mgr = EntryManager(vault, tmp_path)
entry_mgr.add_entry("example.com", 12)
entry_mgr.add_entry("test.com", 12)
encrypted = vault.get_encrypted_index()
assert encrypted
manifest, chunks = prepare_snapshot(encrypted, 100)
compressed = gzip.compress(encrypted)
expected = math.ceil(len(compressed) / 100)
assert len(chunks) == expected
assert len(manifest.chunks) == expected
for meta in manifest.chunks:
assert meta.id
assert meta.hash
def test_retrieve_multi_chunk_snapshot(dummy_nostr_client):
import os
client, relay = dummy_nostr_client
data = os.urandom(120000)
manifest, _ = asyncio.run(client.publish_snapshot(data, limit=50000))
assert len(manifest.chunks) > 1
fetched_manifest, chunk_bytes = asyncio.run(client.fetch_latest_snapshot())
assert len(chunk_bytes) == len(manifest.chunks)
joined = b"".join(chunk_bytes)
assert gzip.decompress(joined) == data
def test_publish_and_fetch_deltas(dummy_nostr_client):
client, relay = dummy_nostr_client
base = b"base"
manifest, _ = asyncio.run(client.publish_snapshot(base))
manifest_id = relay.manifests[-1].id
d1 = b"d1"
d2 = b"d2"
asyncio.run(client.publish_delta(d1, manifest_id))
asyncio.run(client.publish_delta(d2, manifest_id))
deltas = asyncio.run(client.fetch_deltas_since(0))
assert deltas == [d1, d2]

View File

@@ -0,0 +1,100 @@
import os
import time
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
import asyncio
import gzip
import sys
import uuid
import pytest
from cryptography.fernet import Fernet
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.encryption import EncryptionManager
from password_manager.entry_management import EntryManager
from password_manager.vault import Vault
from nostr.client import NostrClient, Kind, KindStandard
@pytest.mark.desktop
@pytest.mark.network
def test_nostr_index_size_limits():
"""Manually explore maximum index size for Nostr backups."""
seed = (
"abandon abandon abandon abandon abandon abandon abandon "
"abandon abandon abandon abandon about"
)
results = []
with TemporaryDirectory() as tmpdir:
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, Path(tmpdir))
with patch.object(enc_mgr, "decrypt_parent_seed", return_value=seed):
client = NostrClient(
enc_mgr,
f"size_test_{uuid.uuid4().hex}",
relays=["wss://relay.snort.social"],
)
npub = client.key_manager.get_npub()
vault = Vault(enc_mgr, tmpdir)
entry_mgr = EntryManager(vault, Path(tmpdir))
delay = float(os.getenv("NOSTR_TEST_DELAY", "5"))
size = 16
batch = 100
entry_count = 0
max_payload = 60 * 1024
try:
while True:
for _ in range(batch):
entry_mgr.add_entry(
website_name=f"site-{entry_count + 1}",
length=12,
username="u" * size,
url="https://example.com/" + "a" * size,
)
entry_count += 1
encrypted = vault.get_encrypted_index()
payload_size = len(encrypted) if encrypted else 0
asyncio.run(client.publish_snapshot(encrypted or b""))
time.sleep(delay)
result = asyncio.run(client.fetch_latest_snapshot())
retrieved = gzip.decompress(b"".join(result[1])) if result else None
retrieved_ok = retrieved == encrypted
if not retrieved_ok:
print(f"Initial retrieve failed: {client.last_error}")
result = asyncio.run(client.fetch_latest_snapshot())
retrieved = (
gzip.decompress(b"".join(result[1])) if result else None
)
retrieved_ok = retrieved == encrypted
if not retrieved_ok:
print("Trying alternate relay")
client.update_relays(["wss://relay.damus.io"])
result = asyncio.run(client.fetch_latest_snapshot())
retrieved = (
gzip.decompress(b"".join(result[1])) if result else None
)
retrieved_ok = retrieved == encrypted
results.append((entry_count, payload_size, True, retrieved_ok))
if not retrieved_ok or payload_size > max_payload:
break
size *= 2
except Exception:
results.append((entry_count + 1, None, False, False))
finally:
client.close_client_pool()
note_kind = Kind.from_std(KindStandard.TEXT_NOTE).as_u16()
print(f"\nNostr note Kind: {note_kind}")
print(f"Nostr account npub: {npub}")
print("Count | Payload Bytes | Published | Retrieved")
for cnt, payload, pub, ret in results:
print(f"{cnt:>5} | {payload:>13} | {pub} | {ret}")
synced = sum(1 for _, _, pub, ret in results if pub and ret)
print(f"Successfully synced entries: {synced}")

View File

@@ -4,6 +4,9 @@ import time
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
import asyncio
import gzip
import uuid
import pytest
from cryptography.fernet import Fernet
@@ -26,12 +29,13 @@ def test_nostr_publish_and_retrieve():
with patch.object(enc_mgr, "decrypt_parent_seed", return_value=seed):
client = NostrClient(
enc_mgr,
"test_fp_real",
f"test_fp_{uuid.uuid4().hex}",
relays=["wss://relay.snort.social"],
)
payload = b"seedpass"
assert client.publish_json_to_nostr(payload) is True
asyncio.run(client.publish_snapshot(payload))
time.sleep(2)
retrieved = client.retrieve_json_from_nostr_sync()
result = asyncio.run(client.fetch_latest_snapshot())
retrieved = gzip.decompress(b"".join(result[1])) if result else None
client.close_client_pool()
assert retrieved == payload

View File

@@ -0,0 +1,98 @@
import hashlib
import json
import gzip
from pathlib import Path
from tempfile import TemporaryDirectory
from cryptography.fernet import Fernet
import base64
import asyncio
from unittest.mock import patch
from nostr import prepare_snapshot, NostrClient
from password_manager.encryption import EncryptionManager
def test_prepare_snapshot_roundtrip():
data = b"a" * 70000
manifest, chunks = prepare_snapshot(data, 50000)
assert len(chunks) == len(manifest.chunks)
joined = b"".join(chunks)
assert len(joined) <= len(data)
assert hashlib.sha256(chunks[0]).hexdigest() == manifest.chunks[0].hash
assert manifest.chunks[0].id == "seedpass-chunk-0000"
assert data == gzip.decompress(joined)
class DummyEvent:
def __init__(self, content):
self._content = content
def content(self):
return self._content
class DummyClient:
def __init__(self, events):
self.events = events
self.pos = 0
async def add_relays(self, relays):
pass
async def add_relay(self, relay):
pass
async def connect(self):
pass
async def disconnect(self):
pass
async def send_event(self, event):
pass
async def fetch_events(self, f, timeout):
ev = self.events[self.pos]
self.pos += 1
class E:
def __init__(self, ev):
self._ev = ev
def to_vec(self):
return [self._ev]
return E(ev)
def test_fetch_latest_snapshot():
data = b"seedpass" * 1000
manifest, chunks = prepare_snapshot(data, 50000)
manifest_json = json.dumps(
{
"ver": manifest.ver,
"algo": manifest.algo,
"chunks": [c.__dict__ for c in manifest.chunks],
"delta_since": None,
}
)
events = [DummyEvent(manifest_json)] + [
DummyEvent(base64.b64encode(c).decode()) for c in chunks
]
client = DummyClient(events)
with TemporaryDirectory() as tmpdir:
enc_mgr = EncryptionManager(Fernet.generate_key(), Path(tmpdir))
with patch("nostr.client.Client", lambda signer: client), patch(
"nostr.client.KeyManager"
) as MockKM, patch.object(NostrClient, "initialize_client_pool"), patch.object(
enc_mgr, "decrypt_parent_seed", return_value="seed"
):
km = MockKM.return_value
km.keys.private_key_hex.return_value = "1" * 64
km.keys.public_key_hex.return_value = "2" * 64
nc = NostrClient(enc_mgr, "fp")
result_manifest, result_chunks = asyncio.run(nc.fetch_latest_snapshot())
assert manifest == result_manifest
assert result_chunks == chunks

View File

@@ -5,12 +5,13 @@ from types import SimpleNamespace
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.manager import PasswordManager
from password_manager.manager import PasswordManager, EncryptionMode
from constants import DEFAULT_SEED_BACKUP_FILENAME
def _make_pm(tmp_path: Path) -> PasswordManager:
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.parent_seed = "seed phrase"
pm.fingerprint_dir = tmp_path
pm.encryption_manager = SimpleNamespace(encrypt_and_save_file=lambda *a, **k: None)

View File

@@ -2,7 +2,7 @@ import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import patch, AsyncMock
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
@@ -11,7 +11,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager
from password_manager.config_manager import ConfigManager
from password_manager.vault import Vault
from password_manager.manager import PasswordManager
from password_manager.manager import PasswordManager, EncryptionMode
def test_change_password_triggers_nostr_backup(monkeypatch):
@@ -22,6 +22,7 @@ def test_change_password_triggers_nostr_backup(monkeypatch):
cfg_mgr = ConfigManager(vault, fp)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.entry_manager = entry_mgr
pm.config_manager = cfg_mgr
@@ -29,7 +30,7 @@ def test_change_password_triggers_nostr_backup(monkeypatch):
pm.password_generator = SimpleNamespace(encryption_manager=enc_mgr)
pm.fingerprint_dir = fp
pm.current_fingerprint = "fp"
pm.parent_seed = "seed"
pm.parent_seed = TEST_SEED
pm.store_hashed_password = lambda pw: None
pm.verify_password = lambda pw: True
@@ -42,6 +43,7 @@ def test_change_password_triggers_nostr_backup(monkeypatch):
with patch("password_manager.manager.NostrClient") as MockClient:
mock_instance = MockClient.return_value
mock_instance.publish_snapshot = AsyncMock(return_value=(None, "abcd"))
pm.nostr_client = mock_instance
pm.change_password()
mock_instance.publish_json_to_nostr.assert_called_once()
mock_instance.publish_snapshot.assert_called_once()

View File

@@ -1,11 +1,12 @@
import sys
import string
from pathlib import Path
from hypothesis import given, strategies as st
from hypothesis import given, strategies as st, settings
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.password_generation import PasswordGenerator
from password_manager.entry_types import EntryType
class DummyEnc:
@@ -29,10 +30,13 @@ def make_generator():
length=st.integers(min_value=8, max_value=64),
index=st.integers(min_value=0, max_value=1000),
)
@settings(deadline=None)
def test_password_properties(length, index):
pg = make_generator()
entry_type = EntryType.PASSWORD.value
pw1 = pg.generate_password(length=length, index=index)
pw2 = pg.generate_password(length=length, index=index)
assert entry_type == "password"
assert pw1 == pw2
assert len(pw1) == length

View File

@@ -24,7 +24,7 @@ def test_password_change_and_unlock(monkeypatch):
new_pw = "newpw"
# initial encryption setup
index_key = derive_index_key(SEED, old_pw, EncryptionMode.SEED_PLUS_PW)
index_key = derive_index_key(SEED)
seed_key = derive_key_from_password(old_pw)
enc_mgr = EncryptionManager(index_key, fp)
seed_mgr = EncryptionManager(seed_key, fp)
@@ -32,7 +32,7 @@ def test_password_change_and_unlock(monkeypatch):
entry_mgr = EntryManager(vault, fp)
cfg_mgr = ConfigManager(vault, fp)
vault.save_index({"passwords": {}})
vault.save_index({"entries": {}})
cfg_mgr.save_config(
{
"relays": [],
@@ -45,7 +45,7 @@ def test_password_change_and_unlock(monkeypatch):
seed_mgr.encrypt_parent_seed(SEED)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_PLUS_PW
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.entry_manager = entry_mgr
pm.config_manager = cfg_mgr
@@ -54,7 +54,9 @@ def test_password_change_and_unlock(monkeypatch):
pm.fingerprint_dir = fp
pm.current_fingerprint = "fp"
pm.parent_seed = SEED
pm.nostr_client = SimpleNamespace(publish_json_to_nostr=lambda *a, **k: None)
pm.nostr_client = SimpleNamespace(
publish_snapshot=lambda *a, **k: (None, "abcd")
)
monkeypatch.setattr(
"password_manager.manager.prompt_existing_password", lambda *_: old_pw
@@ -65,7 +67,7 @@ def test_password_change_and_unlock(monkeypatch):
monkeypatch.setattr(
"password_manager.manager.NostrClient",
lambda *a, **kw: SimpleNamespace(
publish_json_to_nostr=lambda *a, **k: None
publish_snapshot=lambda *a, **k: (None, "abcd")
),
)

View File

@@ -11,57 +11,39 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.encryption import EncryptionManager
from password_manager.vault import Vault
from password_manager.backup import BackupManager
from password_manager.portable_backup import (
PortableMode,
export_backup,
import_backup,
)
from utils.key_derivation import (
derive_index_key,
derive_key_from_password,
EncryptionMode,
)
from password_manager.portable_backup import export_backup, import_backup
from utils.key_derivation import derive_index_key, derive_key_from_password
SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
PASSWORD = "passw0rd"
def setup_vault(tmp: Path, mode: EncryptionMode = EncryptionMode.SEED_ONLY):
def setup_vault(tmp: Path):
seed_key = derive_key_from_password(PASSWORD)
seed_mgr = EncryptionManager(seed_key, tmp)
seed_mgr.encrypt_parent_seed(SEED)
index_key = derive_index_key(SEED, PASSWORD, mode)
index_key = derive_index_key(SEED)
enc_mgr = EncryptionManager(index_key, tmp)
vault = Vault(enc_mgr, tmp)
backup = BackupManager(tmp)
return vault, backup
def test_round_trip_across_modes(monkeypatch):
for pmode in [
PortableMode.SEED_ONLY,
PortableMode.SEED_PLUS_PW,
PortableMode.PW_ONLY,
]:
with TemporaryDirectory() as td:
tmp = Path(td)
vault, backup = setup_vault(tmp)
data = {"pw": 1}
vault.save_index(data)
def test_round_trip(monkeypatch):
with TemporaryDirectory() as td:
tmp = Path(td)
vault, backup = setup_vault(tmp)
data = {"pw": 1}
vault.save_index(data)
monkeypatch.setattr(
"password_manager.portable_backup.prompt_existing_password",
lambda *_a, **_k: PASSWORD,
)
path = export_backup(vault, backup, parent_seed=SEED)
assert path.exists()
path = export_backup(vault, backup, pmode, parent_seed=SEED)
assert path.exists()
vault.save_index({"pw": 0})
import_backup(vault, backup, path, parent_seed=SEED)
assert vault.load_index()["pw"] == data["pw"]
vault.save_index({"pw": 0})
import_backup(vault, backup, path, parent_seed=SEED)
assert vault.load_index()["pw"] == data["pw"]
from cryptography.fernet import InvalidToken
@@ -73,11 +55,7 @@ def test_corruption_detection(monkeypatch):
vault, backup = setup_vault(tmp)
vault.save_index({"a": 1})
monkeypatch.setattr(
"password_manager.portable_backup.prompt_existing_password",
lambda *_a, **_k: PASSWORD,
)
path = export_backup(vault, backup, PortableMode.SEED_ONLY, parent_seed=SEED)
path = export_backup(vault, backup, parent_seed=SEED)
content = json.loads(path.read_text())
payload = base64.b64decode(content["payload"])
@@ -89,42 +67,13 @@ def test_corruption_detection(monkeypatch):
import_backup(vault, backup, path, parent_seed=SEED)
def test_incorrect_credentials(monkeypatch):
with TemporaryDirectory() as td:
tmp = Path(td)
vault, backup = setup_vault(tmp)
vault.save_index({"a": 2})
monkeypatch.setattr(
"password_manager.portable_backup.prompt_existing_password",
lambda *_a, **_k: PASSWORD,
)
path = export_backup(
vault,
backup,
PortableMode.SEED_PLUS_PW,
parent_seed=SEED,
)
monkeypatch.setattr(
"password_manager.portable_backup.prompt_existing_password",
lambda *_a, **_k: "wrong",
)
with pytest.raises(Exception):
import_backup(vault, backup, path, parent_seed=SEED)
def test_import_over_existing(monkeypatch):
with TemporaryDirectory() as td:
tmp = Path(td)
vault, backup = setup_vault(tmp)
vault.save_index({"v": 1})
monkeypatch.setattr(
"password_manager.portable_backup.prompt_existing_password",
lambda *_a, **_k: PASSWORD,
)
path = export_backup(vault, backup, PortableMode.SEED_ONLY, parent_seed=SEED)
path = export_backup(vault, backup, parent_seed=SEED)
vault.save_index({"v": 2})
import_backup(vault, backup, path, parent_seed=SEED)
@@ -138,21 +87,11 @@ def test_checksum_mismatch_detection(monkeypatch):
vault, backup = setup_vault(tmp)
vault.save_index({"a": 1})
monkeypatch.setattr(
"password_manager.portable_backup.prompt_existing_password",
lambda *_a, **_k: PASSWORD,
)
path = export_backup(
vault,
backup,
PortableMode.SEED_ONLY,
parent_seed=SEED,
)
path = export_backup(vault, backup, parent_seed=SEED)
wrapper = json.loads(path.read_text())
payload = base64.b64decode(wrapper["payload"])
key = derive_index_key(SEED, PASSWORD, EncryptionMode.SEED_ONLY)
key = derive_index_key(SEED)
enc_mgr = EncryptionManager(key, tmp)
data = json.loads(enc_mgr.decrypt_data(payload).decode())
data["a"] = 2
@@ -165,23 +104,14 @@ def test_checksum_mismatch_detection(monkeypatch):
import_backup(vault, backup, path, parent_seed=SEED)
@pytest.mark.parametrize(
"pmode",
[PortableMode.SEED_ONLY, PortableMode.SEED_PLUS_PW],
)
def test_export_import_seed_encrypted_with_different_key(monkeypatch, pmode):
def test_export_import_seed_encrypted_with_different_key(monkeypatch):
"""Ensure backup round trip works when seed is encrypted with another key."""
with TemporaryDirectory() as td:
tmp = Path(td)
vault, backup = setup_vault(tmp)
vault.save_index({"v": 123})
monkeypatch.setattr(
"password_manager.portable_backup.prompt_existing_password",
lambda *_a, **_k: PASSWORD,
)
path = export_backup(vault, backup, pmode, parent_seed=SEED)
path = export_backup(vault, backup, parent_seed=SEED)
vault.save_index({"v": 0})
import_backup(vault, backup, path, parent_seed=SEED)
assert vault.load_index()["v"] == 123

View File

@@ -9,22 +9,17 @@ import main
def test_handle_post_success(capsys):
pm = SimpleNamespace(
get_encrypted_data=lambda: b"data",
nostr_client=SimpleNamespace(
publish_json_to_nostr=lambda data, alt_summary=None: True
),
sync_vault=lambda alt_summary=None: "abcd",
)
main.handle_post_to_nostr(pm)
out = capsys.readouterr().out
assert "✅ Sync complete." in out
assert "abcd" in out
def test_handle_post_failure(capsys):
pm = SimpleNamespace(
get_encrypted_data=lambda: b"data",
nostr_client=SimpleNamespace(
publish_json_to_nostr=lambda data, alt_summary=None: False
),
sync_vault=lambda alt_summary=None: None,
)
main.handle_post_to_nostr(pm)
out = capsys.readouterr().out

View File

@@ -14,6 +14,7 @@ import constants
import password_manager.manager as manager_module
from password_manager.vault import Vault
from password_manager.entry_management import EntryManager
from password_manager.manager import EncryptionMode
def test_add_and_delete_entry(monkeypatch):
@@ -25,6 +26,7 @@ def test_add_and_delete_entry(monkeypatch):
importlib.reload(manager_module)
pm = manager_module.PasswordManager.__new__(manager_module.PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.fingerprint_manager = FingerprintManager(constants.APP_DIR)
pm.current_fingerprint = None
pm.save_and_encrypt_seed = lambda seed, fingerprint_dir: None
@@ -56,13 +58,14 @@ def test_add_and_delete_entry(monkeypatch):
pm.entry_manager = entry_mgr
index = entry_mgr.add_entry("example.com", 12)
assert str(index) in vault.load_index()["passwords"]
assert str(index) in vault.load_index()["entries"]
published = []
pm.nostr_client = SimpleNamespace(
publish_json_to_nostr=lambda data, alt_summary=None: (
published.append(data) or True
)
publish_snapshot=lambda data, alt_summary=None: (
published.append(data),
(None, "abcd"),
)[1]
)
inputs = iter([str(index)])
@@ -70,5 +73,5 @@ def test_add_and_delete_entry(monkeypatch):
pm.delete_entry()
assert str(index) not in vault.load_index()["passwords"]
assert str(index) not in vault.load_index()["entries"]
assert published

View File

@@ -5,7 +5,7 @@ from tempfile import TemporaryDirectory
sys.path.append(str(Path(__file__).resolve().parents[1]))
from utils.fingerprint_manager import FingerprintManager
from password_manager.manager import PasswordManager
from password_manager.manager import PasswordManager, EncryptionMode
VALID_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
@@ -22,6 +22,7 @@ def test_add_and_switch_fingerprint(monkeypatch):
assert expected_dir.exists()
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.fingerprint_manager = fm
pm.encryption_manager = object()
pm.current_fingerprint = None

View File

@@ -2,12 +2,14 @@ import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
import asyncio
import pytest
from cryptography.fernet import Fernet
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.encryption import EncryptionManager
from nostr.client import NostrClient
from nostr.client import NostrClient, Manifest
def setup_client(tmp_path):
@@ -27,21 +29,34 @@ def setup_client(tmp_path):
class FakeEvent:
def __init__(self):
def __init__(self, content="evt"):
self._id = "id"
self._content = content
def id(self):
return self._id
def content(self):
return self._content
class FakeUnsignedEvent:
def __init__(self, content="evt"):
self._content = content
def sign_with_keys(self, _):
return FakeEvent()
return FakeEvent(self._content)
class FakeBuilder:
def __init__(self, _kind=None, content="evt"):
self._content = content
def tags(self, _tags):
return self
def build(self, _):
return FakeUnsignedEvent()
return FakeUnsignedEvent(self._content)
class FakeEventId:
@@ -54,22 +69,33 @@ class FakeSendEventOutput:
self.id = FakeEventId()
def test_publish_json_success():
def test_publish_snapshot_success():
with TemporaryDirectory() as tmpdir, patch(
"nostr.client.EventBuilder.text_note", return_value=FakeBuilder()
"nostr.client.EventBuilder", FakeBuilder
):
client = setup_client(Path(tmpdir))
async def fake_send(event):
return FakeSendEventOutput()
with patch.object(
client, "publish_event", return_value=FakeSendEventOutput()
) as mock_pub:
assert client.publish_json_to_nostr(b"data") is True
mock_pub.assert_called()
client.client, "send_event", side_effect=fake_send
) as mock_send:
manifest, event_id = asyncio.run(client.publish_snapshot(b"data"))
assert isinstance(manifest, Manifest)
assert event_id == "abcd"
assert mock_send.await_count >= 1
def test_publish_json_failure():
def test_publish_snapshot_failure():
with TemporaryDirectory() as tmpdir, patch(
"nostr.client.EventBuilder.text_note", return_value=FakeBuilder()
"nostr.client.EventBuilder", FakeBuilder
):
client = setup_client(Path(tmpdir))
with patch.object(client, "publish_event", side_effect=Exception("boom")):
assert client.publish_json_to_nostr(b"data") is False
async def boom(_):
raise Exception("boom")
with patch.object(client.client, "send_event", side_effect=boom):
with pytest.raises(Exception):
asyncio.run(client.publish_snapshot(b"data"))

View File

@@ -17,6 +17,7 @@ def setup_password_manager():
importlib.reload(manager_module)
pm = manager_module.PasswordManager.__new__(manager_module.PasswordManager)
pm.encryption_mode = manager_module.EncryptionMode.SEED_ONLY
pm.fingerprint_manager = manager_module.FingerprintManager(constants.APP_DIR)
pm.current_fingerprint = None
pm.save_and_encrypt_seed = lambda seed, fingerprint_dir: None

View File

@@ -8,7 +8,7 @@ from mnemonic import Mnemonic
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.encryption import EncryptionManager
from password_manager.manager import PasswordManager
from password_manager.manager import PasswordManager, EncryptionMode
def test_seed_encryption_round_trip():
@@ -22,4 +22,5 @@ def test_seed_encryption_round_trip():
assert decrypted == seed
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
assert pm.validate_bip85_seed(seed)

View File

@@ -33,7 +33,7 @@ def setup_pm(tmp_path, monkeypatch):
relays=list(DEFAULT_RELAYS),
close_client_pool=lambda: None,
initialize_client_pool=lambda: None,
publish_json_to_nostr=lambda data, alt_summary=None: None,
publish_snapshot=lambda data, alt_summary=None: (None, "abcd"),
key_manager=SimpleNamespace(get_npub=lambda: "npub"),
)

View File

@@ -21,6 +21,7 @@ try:
canonical_json_dumps,
)
from .password_prompt import prompt_for_password
from .input_utils import timed_input
if logger.isEnabledFor(logging.DEBUG):
logger.info("Modules imported successfully.")
@@ -41,4 +42,5 @@ __all__ = [
"exclusive_lock",
"shared_lock",
"prompt_for_password",
"timed_input",
]

19
src/utils/input_utils.py Normal file
View File

@@ -0,0 +1,19 @@
import sys
import select
import io
from typing import Optional
def timed_input(prompt: str, timeout: Optional[float]) -> str:
"""Read input from the user with a timeout."""
print(prompt, end="", flush=True)
if timeout is None or timeout <= 0:
return sys.stdin.readline().strip()
try:
sys.stdin.fileno()
except (AttributeError, io.UnsupportedOperation):
return input().strip()
ready, _, _ = select.select([sys.stdin], [], [], timeout)
if ready:
return sys.stdin.readline().strip()
raise TimeoutError("input timed out")

View File

@@ -23,12 +23,7 @@ import traceback
from enum import Enum
from typing import Optional, Union
from bip_utils import Bip39SeedGenerator
from local_bip85.bip85 import BIP85
try:
from monstr.encrypt import Keys
except ImportError: # Fall back to local coincurve implementation
from nostr.coincurve_keys import Keys
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.backends import default_backend
@@ -41,8 +36,6 @@ class EncryptionMode(Enum):
"""Supported key derivation modes for database encryption."""
SEED_ONLY = "seed-only"
SEED_PLUS_PW = "seed+pw"
PW_ONLY = "pw-only"
DEFAULT_ENCRYPTION_MODE = EncryptionMode.SEED_ONLY
@@ -142,43 +135,6 @@ def derive_key_from_parent_seed(parent_seed: str, fingerprint: str = None) -> by
raise
class KeyManager:
def __init__(self, parent_seed: str, fingerprint: str = None):
self.parent_seed = parent_seed
self.fingerprint = fingerprint
self.bip85 = self.initialize_bip85()
self.keys = self.generate_nostr_keys()
def initialize_bip85(self):
seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate()
bip85 = BIP85(seed_bytes)
return bip85
def generate_nostr_keys(self) -> Keys:
"""
Derives a unique Nostr key pair for the given fingerprint using BIP-85.
:return: An instance of Keys containing the Nostr key pair.
"""
# Use a derivation path that includes the fingerprint
# Convert fingerprint to an integer index (e.g., using a hash function)
index = (
int(hashlib.sha256(self.fingerprint.encode()).hexdigest(), 16) % (2**31)
if self.fingerprint
else 0
)
# Derive entropy for Nostr key (32 bytes)
entropy_bytes = self.bip85.derive_entropy(
app=BIP85.Applications.ENTROPY, index=index, size=32
)
# Generate Nostr key pair from entropy
private_key_hex = entropy_bytes.hex()
keys = Keys(priv_key=private_key_hex)
return keys
def derive_index_key_seed_only(seed: str) -> bytes:
"""Derive a deterministic Fernet key from only the BIP-39 seed."""
seed_bytes = Bip39SeedGenerator(seed).Generate()
@@ -193,35 +149,6 @@ def derive_index_key_seed_only(seed: str) -> bytes:
return base64.urlsafe_b64encode(key)
def derive_index_key_seed_plus_pw(seed: str, password: str) -> bytes:
"""Derive the index key from seed and password combined."""
seed_bytes = Bip39SeedGenerator(seed).Generate()
pw_bytes = unicodedata.normalize("NFKD", password).encode("utf-8")
hkdf = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=b"password-db",
backend=default_backend(),
)
key = hkdf.derive(seed_bytes + b"|" + pw_bytes)
return base64.urlsafe_b64encode(key)
def derive_index_key(
seed: str,
password: Optional[str] = None,
mode: EncryptionMode = DEFAULT_ENCRYPTION_MODE,
) -> bytes:
"""Derive the index encryption key based on the selected mode."""
if mode == EncryptionMode.SEED_ONLY:
return derive_index_key_seed_only(seed)
if mode == EncryptionMode.SEED_PLUS_PW:
if password is None:
raise ValueError("Password required for seed+pw mode")
return derive_index_key_seed_plus_pw(seed, password)
if mode == EncryptionMode.PW_ONLY:
if password is None:
raise ValueError("Password required for pw-only mode")
return derive_key_from_password(password)
raise ValueError(f"Unsupported encryption mode: {mode}")
def derive_index_key(seed: str) -> bytes:
"""Derive the index encryption key."""
return derive_index_key_seed_only(seed)

View File

@@ -29,6 +29,12 @@ colorama_init()
logger = logging.getLogger(__name__)
class PasswordPromptError(Exception):
"""Exception raised for password prompt errors."""
pass
def prompt_new_password() -> str:
"""
Prompts the user to enter and confirm a new password for encrypting the parent seed.
@@ -40,7 +46,7 @@ def prompt_new_password() -> str:
str: The confirmed password entered by the user.
Raises:
SystemExit: If the user fails to provide a valid password after multiple attempts.
PasswordPromptError: If the user fails to provide a valid password after multiple attempts.
"""
max_retries = 5
attempts = 0
@@ -87,7 +93,7 @@ def prompt_new_password() -> str:
except KeyboardInterrupt:
print(colored("\nOperation cancelled by user.", "yellow"))
logging.info("Password prompt interrupted by user.")
sys.exit(0)
raise PasswordPromptError("Operation cancelled by user")
except Exception as e:
logging.error(
f"Unexpected error during password prompt: {e}", exc_info=True
@@ -97,7 +103,7 @@ def prompt_new_password() -> str:
print(colored("Maximum password attempts exceeded. Exiting.", "red"))
logging.error("User failed to provide a valid password after multiple attempts.")
sys.exit(1)
raise PasswordPromptError("Maximum password attempts exceeded")
def prompt_existing_password(prompt_message: str = "Enter your password: ") -> str:
@@ -113,7 +119,7 @@ def prompt_existing_password(prompt_message: str = "Enter your password: ") -> s
str: The password entered by the user.
Raises:
SystemExit: If the user interrupts the operation.
PasswordPromptError: If the user interrupts the operation.
"""
try:
password = getpass.getpass(prompt=prompt_message).strip()
@@ -121,7 +127,7 @@ def prompt_existing_password(prompt_message: str = "Enter your password: ") -> s
if not password:
print(colored("Error: Password cannot be empty.", "red"))
logging.warning("User attempted to enter an empty password.")
sys.exit(1)
raise PasswordPromptError("Password cannot be empty")
# Normalize the password to NFKD form
normalized_password = unicodedata.normalize("NFKD", password)
@@ -131,13 +137,13 @@ def prompt_existing_password(prompt_message: str = "Enter your password: ") -> s
except KeyboardInterrupt:
print(colored("\nOperation cancelled by user.", "yellow"))
logging.info("Existing password prompt interrupted by user.")
sys.exit(0)
raise PasswordPromptError("Operation cancelled by user")
except Exception as e:
logging.error(
f"Unexpected error during existing password prompt: {e}", exc_info=True
)
print(colored(f"Error: {e}", "red"))
sys.exit(1)
raise PasswordPromptError(str(e))
def confirm_action(
@@ -154,7 +160,7 @@ def confirm_action(
bool: True if the user confirms the action, False otherwise.
Raises:
SystemExit: If the user interrupts the operation.
PasswordPromptError: If the user interrupts the operation.
"""
try:
while True:
@@ -171,13 +177,13 @@ def confirm_action(
except KeyboardInterrupt:
print(colored("\nOperation cancelled by user.", "yellow"))
logging.info("Action confirmation interrupted by user.")
sys.exit(0)
raise PasswordPromptError("Operation cancelled by user")
except Exception as e:
logging.error(
f"Unexpected error during action confirmation: {e}", exc_info=True
)
print(colored(f"Error: {e}", "red"))
sys.exit(1)
raise PasswordPromptError(str(e))
def prompt_for_password() -> str: