mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 07:18:47 +00:00
66
README.md
66
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||

|
||||
|
||||
**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 50 KB chunks. Each chunk is published as a parameterised replaceable event (`kind 30071`), with a manifest (`kind 30070`) describing the snapshot and deltas (`kind 30072`) capturing changes between snapshots. This allows secure password recovery across devices without exposing your data.
|
||||
|
||||
[Tip Jar](https://nostrtipjar.netlify.app/?n=npub16y70nhp56rwzljmr8jhrrzalsx5x495l4whlf8n8zsxww204k8eqrvamnp)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
**⚠️ Disclaimer**
|
||||
|
||||
This software was not developed by an experienced security expert and should be used with caution. There may be bugs and missing features. 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 50 KB and SeedPass periodically publishes a new snapshot to keep accumulated deltas small. The security of the program's memory management and logs has not been evaluated and may leak sensitive information.
|
||||
|
||||
---
|
||||
### 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 50 KB 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 50 KB, so the test gradually grows the vault to observe
|
||||
when a new snapshot is triggered. Use the `NOSTR_TEST_DELAY` environment
|
||||
variable to control the delay between publishes when experimenting with large vaults.
|
||||
|
||||
```bash
|
||||
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 50 KB and the client rotates snapshots after enough delta events accumulate. The security of memory management and logs has not been thoroughly evaluated and may pose risks of leaking sensitive information.
|
||||
- **Multiple Seeds Management:** While managing multiple seeds adds flexibility, it also increases the responsibility to secure each seed and its associated password.
|
||||
- **No PBKDF2 Salt Required:** SeedPass deliberately omits an explicit PBKDF2 salt. Every password is derived from a unique 512-bit BIP-85 child seed, which already provides stronger per-password uniqueness than a conventional 128-bit salt.
|
||||
|
||||
|
@@ -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)
|
||||
|
||||
---
|
||||
|
@@ -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
|
||||
├── ...
|
||||
```
|
||||
|
||||
|
@@ -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("."))
|
||||
|
@@ -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.
|
||||
|
@@ -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 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 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>
|
||||
|
@@ -14,7 +14,7 @@
|
||||
| ------------ | ------------------------------------------------------------------------------ |
|
||||
| **Core API** | `seedpass.api` – headless services consumed by CLI / GUI |
|
||||
| **Profile** | A fingerprint‑scoped vault: parent‑seed + 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 |
|
||||
|
||||
---
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
||||
|
@@ -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}")
|
||||
|
153
src/main.py
153
src/main.py
@@ -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"))
|
||||
|
@@ -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}'")
|
||||
|
26
src/nostr/backup_models.py
Normal file
26
src/nostr/backup_models.py
Normal 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
|
@@ -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:
|
||||
|
@@ -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}'")
|
||||
|
@@ -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,
|
||||
)
|
||||
|
@@ -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))
|
||||
|
@@ -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"))
|
||||
|
@@ -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"))
|
||||
|
13
src/password_manager/entry_types.py
Normal file
13
src/password_manager/entry_types.py
Normal 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"
|
@@ -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}"
|
||||
|
@@ -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:
|
||||
|
@@ -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)
|
||||
|
@@ -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"))
|
||||
|
@@ -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__(
|
||||
|
@@ -7,7 +7,6 @@ coincurve>=18.0.0
|
||||
mnemonic
|
||||
aiohttp
|
||||
bcrypt
|
||||
bip85
|
||||
pytest>=7.0
|
||||
pytest-cov
|
||||
pytest-xdist
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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()
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
@@ -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
|
||||
|
@@ -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
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
29
src/tests/test_default_encryption_mode.py
Normal file
29
src/tests/test_default_encryption_mode.py
Normal 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
|
@@ -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)
|
||||
|
@@ -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")
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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):
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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()
|
||||
|
@@ -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")
|
||||
|
@@ -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
|
||||
|
50
src/tests/test_nostr_dummy_client.py
Normal file
50
src/tests/test_nostr_dummy_client.py
Normal 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]
|
100
src/tests/test_nostr_index_size.py
Normal file
100
src/tests/test_nostr_index_size.py
Normal 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}")
|
@@ -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
|
||||
|
98
src/tests/test_nostr_snapshot.py
Normal file
98
src/tests/test_nostr_snapshot.py
Normal 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
|
@@ -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)
|
||||
|
@@ -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()
|
||||
|
@@ -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
|
||||
|
@@ -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")
|
||||
),
|
||||
)
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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"))
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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"),
|
||||
)
|
||||
|
||||
|
@@ -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
19
src/utils/input_utils.py
Normal 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")
|
@@ -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)
|
||||
|
@@ -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:
|
||||
|
Reference in New Issue
Block a user