Merge pull request #115 from PR0M3TH3AN/beta

Beta
This commit is contained in:
thePR0M3TH3AN
2025-07-01 19:16:55 -04:00
committed by GitHub
75 changed files with 2873 additions and 1452 deletions

View File

@@ -5,6 +5,8 @@ on:
branches: [ "**" ]
pull_request:
branches: [ "**" ]
schedule:
- cron: '0 3 * * *'
jobs:
build:
@@ -19,6 +21,8 @@ jobs:
- os: windows-latest
python-version: "3.10"
runs-on: ${{ matrix.os }}
env:
HYPOTHESIS_SEED: 123456
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
@@ -63,10 +67,22 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r src/requirements.txt
- name: Run pip-audit
run: |
pip install pip-audit
pip-audit -r requirements.lock
- name: Determine stress args
run: |
if [ "${{ github.event_name }}" = "schedule" ]; then
echo "STRESS_ARGS=--stress" >> $GITHUB_ENV
fi
- name: Enable Nostr network tests on main branch or nightly
if: github.ref == 'refs/heads/main' || github.event_name == 'schedule'
run: echo "NOSTR_E2E=1" >> $GITHUB_ENV
- name: Run tests with coverage
shell: bash
run: |
pytest --cov=src --cov-report=xml --cov-report=term-missing \
pytest ${STRESS_ARGS} --cov=src --cov-report=xml --cov-report=term-missing \
--cov-fail-under=20 src/tests
- name: Upload coverage report
uses: actions/upload-artifact@v4

3
.gitignore vendored
View File

@@ -29,3 +29,6 @@ Thumbs.db
# Coverage files
.coverage
coverage.xml
# Other
.hypothesis

22
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,22 @@
repos:
- repo: https://github.com/psf/black
rev: 23.7.0
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.11
hooks:
- id: ruff
args: ["--select", "RUF100,B"]
- repo: https://github.com/PyCQA/bandit
rev: 1.7.5
hooks:
- id: bandit
name: bandit
- repo: local
hooks:
- id: update-checksum
name: update-checksum
entry: python scripts/update_checksum.py
language: system
stages: [push]

View File

@@ -43,8 +43,8 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
- **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.
- **Checksum Verification:** Ensure the integrity of the script with checksum verification.
- **Multiple Seed Profiles:** Manage multiple seed profiles and switch between them seamlessly.
- **User-Friendly CLI:** Simple command-line interface for easy interaction.
- **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.
## Prerequisites
@@ -138,6 +138,30 @@ 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
SeedPass allows you to manage multiple seed profiles (previously referred to as "fingerprints"). Each seed profile has its own parent seed and associated data, enabling you to compartmentalize your passwords.
@@ -168,28 +192,57 @@ wss://nostr.oxtr.dev
wss://relay.primal.net
```
You can manage the relay list or change the PIN through the **Settings** menu:
You can manage your relays and sync with Nostr from the **Settings** menu:
1. From the main menu, choose option `4` (**Settings**).
1. From the main menu choose `4` (**Settings**).
2. Select `2` (**Nostr**) to open the Nostr submenu.
3. Choose `3` to view your current relays.
4. Select `4` to add a new relay URL.
5. Choose `5` to remove a relay by number.
6. Select `6` to reset to the default relay list.
7. Choose `7` to display your Nostr public key.
8. Select `8` to return to the Settings menu.
9. From the Settings menu you can select `3` to change the settings PIN.
10. Choose `4` to verify the script checksum or `5` to back up the parent seed.
3. Choose `1` to back up your encrypted index to Nostr.
4. Select `2` to restore the index from Nostr.
5. Choose `3` to view your current relays.
6. Select `4` to add a new relay URL.
7. Choose `5` to remove a relay by number.
8. Select `6` to reset to the default relay list.
9. Choose `7` to display your Nostr public key.
10. Select `8` to return to the Settings menu.
Back in the Settings menu you can:
* Select `3` to change your master password.
* Choose `4` to verify the script checksum.
* Choose `5` to back up the parent seed.
* Choose `6` to lock the vault and require re-entry of your password.
## Running Tests
SeedPass includes a small suite of unit 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:
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
```
### Automatically Updating the Script Checksum
SeedPass stores a SHA-256 checksum for the main program in `~/.seedpass/seedpass_script_checksum.txt`.
To keep this value in sync with the source code, install the prepush git hook:
```bash
pre-commit install -t pre-push
```
After running this command, every `git push` will execute `scripts/update_checksum.py`,
updating the checksum file automatically.
To run mutation tests locally, generate coverage data first and then execute `mutmut`:
```bash
pytest --cov=src src/tests
python -m mutmut run --paths-to-mutate src --tests-dir src/tests --runner "python -m pytest -q" --use-coverage --no-progress
python -m mutmut results
```
Mutation testing is disabled in the GitHub workflow due to reliability issues and should be run on a desktop environment instead.
## Security Considerations
**Important:** The password you use to encrypt your parent seed is also required to decrypt the seed index data retrieved from Nostr. **It is imperative to remember this password** and be sure to use it with the same seed, as losing it means you won't be able to access your stored index. Secure your 12-word seed **and** your master password.
@@ -201,6 +254,7 @@ pytest -vv
- **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.
- **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.
## Contributing

93
dev-plan.md Normal file
View File

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

View File

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

View File

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

View File

@@ -62,18 +62,12 @@
<div class="container">
<h2 class="section-title" id="features-heading">Features</h2>
<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-exchange-alt" aria-hidden="true"></i> Seed/Fingerprint switching for managing multiple profiles
</li>
<li><i class="fas fa-seedling" aria-hidden="true"></i> Bring your own seed or generate one on demand
</li>
<li><i class="fas fa-terminal" aria-hidden="true"></i> User-friendly command-line interface
</li>
<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-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>
</ul>
</div>
</section>
@@ -98,43 +92,20 @@
<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>
<h3 class="subsection-title">Bring Your Own Seed</h3>
<p>You can bring your own BIP-39 seed or generate a new one within SeedPass. This gives you flexibility and control over your master seed.</p>
<h3 class="subsection-title">Command-Line Interface</h3>
<p>Interact with SeedPass using a user-friendly CLI. Here's an example of the current interface:</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>
<p>Navigate through menus to manage entries and settings. Example:</p>
<pre class="code-block">
(venv) user@debian:~/SeedPass/src$ python main.py
Select an option:
1. Add Entry
2. Retrieve Entry
3. Modify an Existing Entry
4. Settings
5. Exit
Available Fingerprints:
1. 31DD880A523B9759
2. Add a new fingerprint
Select a fingerprint by number: 1
Enter your master password:
Fingerprint 31DD880A523B9759 selected and managers initialized.
Select an option:
1. Add Entry
2. Retrieve Entry
3. Modify an Existing Entry
4. Backup to Nostr
5. Restore from Nostr
6. Switch Fingerprint
7. Add a New Fingerprint
8. Remove an Existing Fingerprint
9. List All Fingerprints
10. Settings
11. Exit
Enter your choice (1-11): 1
Enter the website name: newsitename
Enter the username (optional):
Enter the URL (optional):
Enter desired password length (default 16):
[+] Entry added successfully at index 0.
[+] Password generated and indexed with ID 0.
Password for newsitename: 06~8Eo(~D8t+G7D}
</pre>
Enter your choice (1-5):
</pre>
</div>
</section>
<!-- Roadmap Section -->
@@ -238,45 +209,7 @@ Password for newsitename: 06~8Eo(~D8t+G7D}
<section class="disclaimer" id="disclaimer" aria-labelledby="disclaimer-heading">
<div class="container">
<h2 class="section-title" id="disclaimer-heading">Disclaimer</h2>
<p><strong>⚠️ Use with Caution:</strong> Please read the following terms carefully.</p>
<ul>
<li>
<i class="fas fa-info-circle" aria-hidden="true"></i>
<strong>No Guarantees:</strong>
<span> SeedPass is provided "as is" without any warranties, express or implied. We do not guarantee that the software is free from errors, bugs, or vulnerabilities.</span>
</li>
<li>
<i class="fas fa-info-circle" aria-hidden="true"></i>
<strong>Security Risks:</strong>
<span> While SeedPass employs robust security measures, no system can be completely secure. Use it at your own risk, and ensure you have proper backups of your data.</span>
</li>
<li>
<i class="fas fa-info-circle" aria-hidden="true"></i>
<strong>Not Professional Advice:</strong>
<span> SeedPass is not a substitute for professional security or cryptographic advice. Consult with a security expert before using it in critical or sensitive environments.</span>
</li>
<li>
<i class="fas fa-info-circle" aria-hidden="true"></i>
<strong>Liability Limitation:</strong>
<span> We are not liable for any damages, losses, or other liabilities arising from the use or inability to use SeedPass.</span>
</li>
<li>
<i class="fas fa-info-circle" aria-hidden="true"></i>
<strong>Regular Backups:</strong>
<span> Always maintain regular backups of your seeds and sensitive data to prevent loss in case of unforeseen issues.</span>
</li>
<li>
<i class="fas fa-info-circle" aria-hidden="true"></i>
<strong>User Responsibility:</strong>
<span> Users are responsible for the secure storage and management of their master seeds and any derived seeds. Unauthorized access to these seeds can compromise all associated passwords and accounts.</span>
</li>
<li>
<i class="fas fa-info-circle" aria-hidden="true"></i>
<strong>Updates and Maintenance:</strong>
<span> While we strive to keep SeedPass updated and secure, we cannot promise timely updates or maintenance. Users should stay informed about the latest security practices and updates.</span>
</li>
</ul>
<p>Please ensure you understand the risks involved and take appropriate measures to secure your data. By using SeedPass, you acknowledge and agree to these terms.</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. 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>
</div>
</section>
</main>
@@ -291,8 +224,10 @@ Password for newsitename: 06~8Eo(~D8t+G7D}
</svg> </a>
<!-- GitHub Link -->
<a href="https://github.com/PR0M3TH3AN/SeedPass" target="_blank" aria-label="GitHub Repository" rel="noopener noreferrer"> <i class="fab fa-github"></i> </a>
<!-- Leave a Tip Link -->
<a href="https://nostrtipjar.netlify.app/?n=npub16y70nhp56rwzljmr8jhrrzalsx5x495l4whlf8n8zsxww204k8eqrvamnp" target="_blank" aria-label="Leave a Tip" rel="noopener noreferrer">Leave a Tip</a>
</div>
<p>&copy; 2024 SeedPass. All rights reserved.</p>
<p>&copy; 2025 SeedPass</p>
</div>
</footer>
<!-- JavaScript -->

4
pyproject.toml Normal file
View File

@@ -0,0 +1,4 @@
[tool.mypy]
python_version = "3.11"
strict = true
mypy_path = "src"

View File

@@ -1,3 +1,9 @@
[pytest]
addopts = -n auto
log_cli = true
log_cli_level = INFO
log_cli_level = WARNING
log_level = WARNING
testpaths = src/tests
markers =
network: tests that require network connectivity
stress: long running stress tests

62
requirements.lock Normal file
View File

@@ -0,0 +1,62 @@
aiohappyeyeballs==2.6.1
aiohttp==3.12.13
aiosignal==1.3.2
attrs==25.3.0
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
charset-normalizer==3.4.2
click==8.2.1
coincurve==21.0.0
colorama==0.4.6
coverage==7.9.1
crcmod==1.7
cryptography==45.0.4
ecdsa==0.19.1
ed25519-blake2b==1.4.1
execnet==2.1.1
frozenlist==1.7.0
glob2==0.7
hypothesis==6.135.20
idna==3.10
iniconfig==2.1.0
ipaddress==1.0.23
junit-xml==1.9
mnemonic==0.21
monero==1.1.1
multidict==6.6.3
mutmut==2.4.4
nostr-sdk==0.42.1
packaging==25.0
parso==0.8.4
pluggy==1.6.0
pony==0.7.19
portalocker==3.2.0
propcache==0.3.2
py-sr25519-bindings==0.2.2
pycoin==0.92.20241201
pycparser==2.22
pycryptodome==3.23.0
pycryptodomex==3.23.0
Pygments==2.19.2
PyNaCl==1.5.0
PySocks==1.7.1
pytest==8.4.1
pytest-cov==6.2.1
pytest-xdist==3.8.0
requests==2.32.4
six==1.17.0
sortedcontainers==2.4.0
termcolor==3.1.0
toml==0.10.2
tomli==2.2.1
urllib3==2.5.0
varint==1.0.2
websocket-client==1.7.0
websockets==15.0.1
yarl==1.20.1

View File

@@ -0,0 +1,26 @@
import sys
from pathlib import Path
# Ensure src directory is in sys.path for imports
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SRC_DIR = PROJECT_ROOT / "src"
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
def main() -> None:
"""Calculate checksum for the main script and write it to SCRIPT_CHECKSUM_FILE."""
script_path = SRC_DIR / "password_manager" / "manager.py"
checksum = calculate_checksum(str(script_path))
if checksum is None:
raise SystemExit(f"Failed to calculate checksum for {script_path}")
SCRIPT_CHECKSUM_FILE.write_text(checksum)
print(f"Updated checksum written to {SCRIPT_CHECKSUM_FILE}")
if __name__ == "__main__":
main()

View File

@@ -21,17 +21,19 @@ try:
# -----------------------------------
APP_DIR = Path.home() / ".seedpass"
APP_DIR.mkdir(exist_ok=True, parents=True) # Ensure the directory exists
logging.info(f"Application directory created at {APP_DIR}")
if logger.isEnabledFor(logging.DEBUG):
logger.info(f"Application directory created at {APP_DIR}")
except Exception as e:
logging.error(f"Failed to create application directory: {e}")
logging.error(traceback.format_exc()) # Log full traceback
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
logging.info(f"Parent seed file path set to {PARENT_SEED_FILE}")
if logger.isEnabledFor(logging.DEBUG):
logger.info(f"Parent seed file path set to {PARENT_SEED_FILE}")
except Exception as e:
logging.error(f"Error setting file paths: {e}")
logging.error(traceback.format_exc()) # Log full traceback
if logger.isEnabledFor(logging.DEBUG):
logger.error(f"Error setting file paths: {e}", exc_info=True)
# -----------------------------------
# Checksum Files for Integrity
@@ -40,10 +42,11 @@ try:
SCRIPT_CHECKSUM_FILE = (
APP_DIR / "seedpass_script_checksum.txt"
) # Checksum for main script
logging.info(f"Checksum file path set: Script {SCRIPT_CHECKSUM_FILE}")
if logger.isEnabledFor(logging.DEBUG):
logger.info(f"Checksum file path set: Script {SCRIPT_CHECKSUM_FILE}")
except Exception as e:
logging.error(f"Error setting checksum file paths: {e}")
logging.error(traceback.format_exc()) # Log full traceback
if logger.isEnabledFor(logging.DEBUG):
logger.error(f"Error setting checksum file paths: {e}", exc_info=True)
# -----------------------------------
# Password Generation Constants
@@ -52,6 +55,9 @@ DEFAULT_PASSWORD_LENGTH = 16 # Default length for generated passwords
MIN_PASSWORD_LENGTH = 8 # Minimum allowed password length
MAX_PASSWORD_LENGTH = 128 # Maximum allowed password length
# Timeout in seconds before the vault locks due to inactivity
INACTIVITY_TIMEOUT = 15 * 60 # 15 minutes
# -----------------------------------
# Additional Constants (if any)
# -----------------------------------

View File

@@ -3,12 +3,15 @@
import logging
import traceback
logger = logging.getLogger(__name__)
try:
from .bip85 import BIP85
logging.info("BIP85 module imported successfully.")
if logger.isEnabledFor(logging.DEBUG):
logger.info("BIP85 module imported successfully.")
except Exception as e:
logging.error(f"Failed to import BIP85 module: {e}")
logging.error(traceback.format_exc()) # Log full traceback
if logger.isEnabledFor(logging.DEBUG):
logger.error(f"Failed to import BIP85 module: {e}", exc_info=True)
__all__ = ["BIP85"]

View File

@@ -32,17 +32,22 @@ logger = logging.getLogger(__name__)
class BIP85:
def __init__(self, seed_bytes: bytes):
def __init__(self, seed_bytes: bytes | str):
"""Initialize from BIP39 seed bytes or BIP32 xprv string."""
try:
self.bip32_ctx = Bip32Slip10Secp256k1.FromSeed(seed_bytes)
if isinstance(seed_bytes, (bytes, bytearray)):
self.bip32_ctx = Bip32Slip10Secp256k1.FromSeed(seed_bytes)
else:
self.bip32_ctx = Bip32Slip10Secp256k1.FromExtendedKey(seed_bytes)
logging.debug("BIP32 context initialized successfully.")
except Exception as e:
logging.error(f"Error initializing BIP32 context: {e}")
logging.error(traceback.format_exc()) # Log full traceback
logging.error(f"Error initializing BIP32 context: {e}", exc_info=True)
print(f"{Fore.RED}Error initializing BIP32 context: {e}")
sys.exit(1)
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 39) -> bytes:
def derive_entropy(
self, index: int, bytes_len: int, app_no: int = 39, words_len: int | None = None
) -> bytes:
"""
Derives entropy using BIP-85 HMAC-SHA512 method.
@@ -58,7 +63,9 @@ class BIP85:
SystemExit: If derivation fails or entropy length is invalid.
"""
if app_no == 39:
path = f"m/83696968'/{app_no}'/0'/{bytes_len}'/{index}'"
if words_len is None:
words_len = bytes_len
path = f"m/83696968'/{app_no}'/0'/{words_len}'/{index}'"
elif app_no == 32:
path = f"m/83696968'/{app_no}'/{index}'"
else:
@@ -88,8 +95,7 @@ class BIP85:
logging.debug(f"Derived entropy: {entropy.hex()}")
return entropy
except Exception as e:
logging.error(f"Error deriving entropy: {e}")
logging.error(traceback.format_exc()) # Log full traceback
logging.error(f"Error deriving entropy: {e}", exc_info=True)
print(f"{Fore.RED}Error deriving entropy: {e}")
sys.exit(1)
@@ -100,49 +106,27 @@ class BIP85:
print(f"{Fore.RED}Error: Unsupported number of words: {words_num}")
sys.exit(1)
entropy = self.derive_entropy(index=index, bytes_len=bytes_len, app_no=39)
entropy = self.derive_entropy(
index=index, bytes_len=bytes_len, app_no=39, words_len=words_num
)
try:
mnemonic = Bip39MnemonicGenerator(Bip39Languages.ENGLISH).FromEntropy(
entropy
)
logging.debug(f"Derived mnemonic: {mnemonic}")
return mnemonic
return mnemonic.ToStr()
except Exception as e:
logging.error(f"Error generating mnemonic: {e}")
logging.error(traceback.format_exc()) # Log full traceback
logging.error(f"Error generating mnemonic: {e}", exc_info=True)
print(f"{Fore.RED}Error generating mnemonic: {e}")
sys.exit(1)
def derive_symmetric_key(self, app_no: int = 48, index: int = 0) -> bytes:
"""
Derives a symmetric encryption key using BIP85.
Parameters:
app_no (int): Application number for key derivation (48 chosen arbitrarily).
index (int): Index for key derivation.
Returns:
bytes: Derived symmetric key (32 bytes for AES-256).
Raises:
SystemExit: If symmetric key derivation fails.
"""
entropy = self.derive_entropy(
app_no, language_code=0, words_num=24, index=index
)
def derive_symmetric_key(self, index: int = 0, app_no: int = 2) -> bytes:
"""Derive 32 bytes of entropy for symmetric key usage."""
try:
hkdf = HKDF(
algorithm=hashes.SHA256(),
length=32, # 256 bits for AES-256
salt=None,
info=b"seedos-encryption-key",
backend=default_backend(),
)
symmetric_key = hkdf.derive(entropy)
logging.debug(f"Derived symmetric key: {symmetric_key.hex()}")
return symmetric_key
key = self.derive_entropy(index=index, bytes_len=32, app_no=app_no)
logging.debug(f"Derived symmetric key: {key.hex()}")
return key
except Exception as e:
logging.error(f"Error deriving symmetric key: {e}")
logging.error(traceback.format_exc()) # Log full traceback
logging.error(f"Error deriving symmetric key: {e}", exc_info=True)
print(f"{Fore.RED}Error deriving symmetric key: {e}")
sys.exit(1)

View File

@@ -5,16 +5,34 @@ import sys
import logging
import signal
import getpass
import time
import argparse
import tomli
from colorama import init as colorama_init
from termcolor import colored
import traceback
from password_manager.manager import PasswordManager
from nostr.client import NostrClient
from constants import INACTIVITY_TIMEOUT
from utils.key_derivation import EncryptionMode
colorama_init()
def load_global_config() -> dict:
"""Load configuration from ~/.seedpass/config.toml if present."""
config_path = Path.home() / ".seedpass" / "config.toml"
if not config_path.exists():
return {}
try:
with open(config_path, "rb") as f:
return tomli.load(f)
except Exception as exc:
logging.warning(f"Failed to read {config_path}: {exc}")
return {}
def configure_logging():
logger = logging.getLogger()
logger.setLevel(logging.DEBUG) # Keep this as DEBUG to capture all logs
@@ -101,8 +119,7 @@ def handle_switch_fingerprint(password_manager: PasswordManager):
else:
print(colored("Failed to switch seed profile.", "red"))
except Exception as e:
logging.error(f"Error during fingerprint switch: {e}")
logging.error(traceback.format_exc())
logging.error(f"Error during fingerprint switch: {e}", exc_info=True)
print(colored(f"Error: Failed to switch seed profile: {e}", "red"))
@@ -115,8 +132,7 @@ def handle_add_new_fingerprint(password_manager: PasswordManager):
try:
password_manager.add_new_fingerprint()
except Exception as e:
logging.error(f"Error adding new seed profile: {e}")
logging.error(traceback.format_exc())
logging.error(f"Error adding new seed profile: {e}", exc_info=True)
print(colored(f"Error: Failed to add new seed profile: {e}", "red"))
@@ -160,8 +176,7 @@ def handle_remove_fingerprint(password_manager: PasswordManager):
else:
print(colored("Seed profile removal cancelled.", "yellow"))
except Exception as e:
logging.error(f"Error removing seed profile: {e}")
logging.error(traceback.format_exc())
logging.error(f"Error removing seed profile: {e}", exc_info=True)
print(colored(f"Error: Failed to remove seed profile: {e}", "red"))
@@ -181,8 +196,7 @@ def handle_list_fingerprints(password_manager: PasswordManager):
for fp in fingerprints:
print(colored(f"- {fp}", "cyan"))
except Exception as e:
logging.error(f"Error listing seed profiles: {e}")
logging.error(traceback.format_exc())
logging.error(f"Error listing seed profiles: {e}", exc_info=True)
print(colored(f"Error: Failed to list seed profiles: {e}", "red"))
@@ -199,12 +213,13 @@ def handle_display_npub(password_manager: PasswordManager):
print(colored("Nostr public key not available.", "red"))
logging.error("Nostr public key not available.")
except Exception as e:
logging.error(f"Failed to display npub: {e}")
logging.error(traceback.format_exc())
logging.error(f"Failed to display npub: {e}", exc_info=True)
print(colored(f"Error: Failed to display npub: {e}", "red"))
def handle_post_to_nostr(password_manager: PasswordManager):
def handle_post_to_nostr(
password_manager: PasswordManager, alt_summary: str | None = None
):
"""
Handles the action of posting the encrypted password index to Nostr.
"""
@@ -213,15 +228,21 @@ def handle_post_to_nostr(password_manager: PasswordManager):
encrypted_data = password_manager.get_encrypted_data()
if encrypted_data:
# Post to Nostr
password_manager.nostr_client.publish_json_to_nostr(encrypted_data)
print(colored("Encrypted index posted to Nostr successfully.", "green"))
logging.info("Encrypted index posted to Nostr successfully.")
success = password_manager.nostr_client.publish_json_to_nostr(
encrypted_data,
alt_summary=alt_summary,
)
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.")
else:
print(colored("No data available to post.", "yellow"))
logging.warning("No data available to post to Nostr.")
except Exception as e:
logging.error(f"Failed to post to Nostr: {e}")
logging.error(traceback.format_exc())
logging.error(f"Failed to post to Nostr: {e}", exc_info=True)
print(colored(f"Error: Failed to post to Nostr: {e}", "red"))
@@ -243,8 +264,7 @@ def handle_retrieve_from_nostr(password_manager: PasswordManager):
print(colored("Failed to retrieve data from Nostr.", "red"))
logging.error("Failed to retrieve data from Nostr.")
except Exception as e:
logging.error(f"Failed to retrieve from Nostr: {e}")
logging.error(traceback.format_exc())
logging.error(f"Failed to retrieve from Nostr: {e}", exc_info=True)
print(colored(f"Error: Failed to retrieve from Nostr: {e}", "red"))
@@ -368,6 +388,7 @@ def handle_profiles_menu(password_manager: PasswordManager) -> None:
print("4. List All Seed Profiles")
print("5. Back")
choice = input("Select an option: ").strip()
password_manager.update_activity()
if choice == "1":
if not password_manager.handle_switch_fingerprint():
print(colored("Failed to switch seed profile.", "red"))
@@ -406,6 +427,7 @@ def handle_nostr_menu(password_manager: PasswordManager) -> None:
print("7. Display Nostr Public Key")
print("8. Back")
choice = input("Select an option: ").strip()
password_manager.update_activity()
if choice == "1":
handle_post_to_nostr(password_manager)
elif choice == "2":
@@ -435,7 +457,8 @@ def handle_settings(password_manager: PasswordManager) -> None:
print("3. Change password")
print("4. Verify Script Checksum")
print("5. Backup Parent Seed")
print("6. Back")
print("6. Lock Vault")
print("7. Back")
choice = input("Select an option: ").strip()
if choice == "1":
handle_profiles_menu(password_manager)
@@ -448,12 +471,20 @@ def handle_settings(password_manager: PasswordManager) -> None:
elif choice == "5":
password_manager.handle_backup_reveal_parent_seed()
elif choice == "6":
password_manager.lock_vault()
print(colored("Vault locked. Please re-enter your password.", "yellow"))
password_manager.unlock_vault()
elif choice == "7":
break
else:
print(colored("Invalid choice.", "red"))
def display_menu(password_manager: PasswordManager):
def display_menu(
password_manager: PasswordManager,
sync_interval: float = 60.0,
inactivity_timeout: float = INACTIVITY_TIMEOUT,
):
"""
Displays the interactive menu and handles user input to perform various actions.
"""
@@ -466,11 +497,25 @@ def display_menu(password_manager: PasswordManager):
5. Exit
"""
while True:
if time.time() - password_manager.last_activity > inactivity_timeout:
print(colored("Session timed out. Vault locked.", "yellow"))
password_manager.lock_vault()
password_manager.unlock_vault()
continue
# Periodically push updates to Nostr
if (
password_manager.is_dirty
and time.time() - password_manager.last_update >= sync_interval
):
handle_post_to_nostr(password_manager)
password_manager.is_dirty = False
# Flush logging handlers
for handler in logging.getLogger().handlers:
handler.flush()
print(colored(menu, "cyan"))
choice = input("Enter your choice (1-5): ").strip()
password_manager.update_activity()
if not choice:
print(
colored(
@@ -485,6 +530,7 @@ def display_menu(password_manager: PasswordManager):
print("1. Password")
print("2. Back")
sub_choice = input("Select entry type: ").strip()
password_manager.update_activity()
if sub_choice == "1":
password_manager.handle_add_password()
break
@@ -493,10 +539,13 @@ def display_menu(password_manager: PasswordManager):
else:
print(colored("Invalid choice.", "red"))
elif choice == "2":
password_manager.update_activity()
password_manager.handle_retrieve_entry()
elif choice == "3":
password_manager.update_activity()
password_manager.handle_modify_entry()
elif choice == "4":
password_manager.update_activity()
handle_settings(password_manager)
elif choice == "5":
logging.info("Exiting the program.")
@@ -513,13 +562,32 @@ if __name__ == "__main__":
logger = logging.getLogger(__name__)
logger.info("Starting SeedPass Password Manager")
# Load config from disk and parse command-line arguments
cfg = load_global_config()
parser = argparse.ArgumentParser()
parser.add_argument(
"--encryption-mode",
choices=[m.value for m in EncryptionMode],
help="Select encryption mode",
)
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()
password_manager = PasswordManager(encryption_mode=enc_mode)
logger.info("PasswordManager initialized successfully.")
except Exception as e:
logger.error(f"Failed to initialize PasswordManager: {e}")
logger.error(traceback.format_exc()) # Log full traceback
logger.error(f"Failed to initialize PasswordManager: {e}", exc_info=True)
print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red"))
sys.exit(1)
@@ -556,8 +624,7 @@ if __name__ == "__main__":
print(colored(f"Error during shutdown: {e}", "red"))
sys.exit(0)
except Exception as e:
logger.error(f"An unexpected error occurred: {e}")
logger.error(traceback.format_exc()) # Log full traceback
logger.error(f"An unexpected error occurred: {e}", exc_info=True)
print(colored(f"Error: An unexpected error occurred: {e}", "red"))
try:
password_manager.nostr_client.close_client_pool() # Attempt to close the ClientPool

View File

@@ -1,21 +1,16 @@
# nostr/__init__.py
import logging
import traceback
from .client import NostrClient
"""Nostr package exposing :class:`NostrClient` lazily."""
from importlib import import_module
import logging
# Instantiate the logger
logger = logging.getLogger(__name__)
# Initialize the logger for this module
logger = logging.getLogger(__name__) # Correct logger initialization
try:
from .client import NostrClient
logger.info("NostrClient module imported successfully.")
except Exception as e:
logger.error(f"Failed to import NostrClient module: {e}")
logger.error(traceback.format_exc()) # Log full traceback
__all__ = ["NostrClient"]
def __getattr__(name: str):
if name == "NostrClient":
return import_module(".client", __name__).NostrClient
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")

View File

@@ -1,56 +1,34 @@
import os
import sys
import logging
import traceback
import json
import time
# src/nostr/client.py
import base64
import json
import logging
from typing import List, Optional
import hashlib
import asyncio
import concurrent.futures
from typing import List, Optional, Callable
from pathlib import Path
try:
from monstr.client.client import ClientPool
from monstr.encrypt import Keys, NIP4Encrypt
from monstr.event.event import Event
except ImportError: # Fallback placeholders when monstr is unavailable
NIP4Encrypt = None
Event = None
# Imports from the nostr-sdk library
from nostr_sdk import (
Client,
Keys,
NostrSigner,
EventBuilder,
Filter,
Kind,
KindStandard,
Tag,
)
from datetime import timedelta
class ClientPool: # minimal stub for tests when monstr is absent
def __init__(self, relays):
self.relays = relays
self.connected = True
async def run(self):
pass
def publish(self, event):
pass
def subscribe(self, handlers=None, filters=None, sub_id=None):
pass
def unsubscribe(self, sub_id):
pass
from .coincurve_keys import Keys
import threading
import uuid
from .key_manager import KeyManager
from .encryption_manager import EncryptionManager
from .event_handler import EventHandler
from constants import APP_DIR
from .key_manager import KeyManager as SeedPassKeyManager
from password_manager.encryption import EncryptionManager
from utils.file_lock import exclusive_lock
# Get the logger for this module
logger = logging.getLogger(__name__)
# Backwards compatibility for tests that patch these symbols
KeyManager = SeedPassKeyManager
ClientBuilder = Client
# Set the logging level to WARNING or ERROR to suppress debug logs
logger = logging.getLogger(__name__)
logger.setLevel(logging.WARNING)
DEFAULT_RELAYS = [
@@ -59,660 +37,140 @@ DEFAULT_RELAYS = [
"wss://relay.primal.net",
]
# nostr/client.py
# src/nostr/client.py
class NostrClient:
"""
NostrClient Class
Handles interactions with the Nostr network, including publishing and retrieving encrypted events.
Utilizes deterministic key derivation via BIP-85 and integrates with the monstr library for protocol operations.
"""
"""Interact with the Nostr network using nostr-sdk."""
def __init__(
self,
encryption_manager: EncryptionManager,
fingerprint: str,
relays: Optional[List[str]] = None,
):
"""
Initializes the NostrClient with an EncryptionManager, connects to specified relays,
and sets up the KeyManager with the given fingerprint.
parent_seed: Optional[str] = None,
) -> None:
self.encryption_manager = encryption_manager
self.fingerprint = fingerprint
self.fingerprint_dir = self.encryption_manager.fingerprint_dir
:param encryption_manager: An instance of EncryptionManager for handling encryption/decryption.
:param fingerprint: The fingerprint to differentiate key derivations for unique identities.
:param relays: (Optional) A list of relay URLs to connect to. Defaults to predefined relays.
if parent_seed is None:
parent_seed = self.encryption_manager.decrypt_parent_seed()
# Use our project's KeyManager to derive the private key
self.key_manager = KeyManager(parent_seed, fingerprint)
# Create a nostr-sdk Keys object from our derived private key
private_key_hex = self.key_manager.keys.private_key_hex()
if not isinstance(private_key_hex, str):
private_key_hex = "0" * 64
try:
self.keys = Keys.parse(private_key_hex)
except Exception:
self.keys = Keys.generate()
self.relays = relays if relays else DEFAULT_RELAYS
# Configure and initialize the nostr-sdk Client
signer = NostrSigner.keys(self.keys)
self.client = Client(signer)
self.initialize_client_pool()
def initialize_client_pool(self) -> None:
"""Add relays to the client and connect."""
asyncio.run(self._initialize_client_pool())
async def _initialize_client_pool(self) -> None:
if hasattr(self.client, "add_relays"):
await self.client.add_relays(self.relays)
else:
for relay in self.relays:
await self.client.add_relay(relay)
await self.client.connect()
logger.info(f"NostrClient connected to relays: {self.relays}")
def publish_json_to_nostr(
self,
encrypted_json: bytes,
to_pubkey: str | None = None,
alt_summary: str | None = None,
) -> bool:
"""Builds and publishes a Kind 1 text note or direct message.
Parameters
----------
encrypted_json : bytes
The encrypted index data to publish.
to_pubkey : str | None, optional
If provided, send as a direct message to this public key.
alt_summary : str | None, optional
If provided, include an ``alt`` tag so uploads can be
associated with a specific event like a password change.
"""
try:
# Assign the encryption manager and fingerprint
self.encryption_manager = encryption_manager
self.fingerprint = fingerprint # Track the fingerprint
self.fingerprint_dir = (
self.encryption_manager.fingerprint_dir
) # If needed to manage directories
# Initialize KeyManager with the decrypted parent seed and the provided fingerprint
self.key_manager = KeyManager(
self.encryption_manager.decrypt_parent_seed(), self.fingerprint
)
# Initialize event handler and client pool
self.event_handler = EventHandler()
self.relays = relays if relays else DEFAULT_RELAYS
self.client_pool = ClientPool(self.relays)
self.subscriptions = {}
# Initialize client pool and mark NostrClient as running
self.initialize_client_pool()
logger.info("NostrClient initialized successfully.")
# For shutdown handling
self.is_shutting_down = False
self._shutdown_event = asyncio.Event()
except Exception as e:
logger.error(f"Initialization failed: {e}")
logger.error(traceback.format_exc())
print(f"Error: Initialization failed: {e}", file=sys.stderr)
sys.exit(1)
def initialize_client_pool(self):
"""
Initializes the ClientPool with the specified relays in a separate thread.
"""
try:
logger.debug("Initializing ClientPool with relays.")
if ClientPool is None:
raise ImportError("monstr library is required for ClientPool")
self.client_pool = ClientPool(self.relays)
# Start the ClientPool in a separate thread
self.loop_thread = threading.Thread(target=self.run_event_loop, daemon=True)
self.loop_thread.start()
# Wait until the ClientPool is connected to all relays
self.wait_for_connection()
logger.info("ClientPool connected to all relays.")
except Exception as e:
logger.error(f"Failed to initialize ClientPool: {e}")
logger.error(traceback.format_exc())
print(f"Error: Failed to initialize ClientPool: {e}", file=sys.stderr)
sys.exit(1)
def run_event_loop(self):
"""
Runs the event loop for the ClientPool in a separate thread.
"""
try:
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self.loop.create_task(self.client_pool.run())
self.loop.run_forever()
except asyncio.CancelledError:
logger.debug("Event loop received cancellation.")
except Exception as e:
logger.error(f"Error running event loop in thread: {e}")
logger.error(traceback.format_exc())
print(
f"Error: Event loop in ClientPool thread encountered an issue: {e}",
file=sys.stderr,
)
finally:
if not self.loop.is_closed():
logger.debug("Closing the event loop.")
self.loop.close()
def wait_for_connection(self):
"""
Waits until the ClientPool is connected to all relays.
"""
try:
while not self.client_pool.connected:
time.sleep(0.1)
except Exception as e:
logger.error(f"Error while waiting for ClientPool to connect: {e}")
logger.error(traceback.format_exc())
async def publish_event_async(self, event: Event):
"""
Publishes a signed event to all connected relays using ClientPool.
:param event: The signed Event object to publish.
"""
try:
logger.debug(f"Publishing event: {event.serialize()}")
self.client_pool.publish(event)
logger.info(f"Event published with ID: {event.id}")
logger.debug(f"Finished publishing event: {event.id}")
except Exception as e:
logger.error(f"Failed to publish event: {e}")
logger.error(traceback.format_exc())
def publish_event(self, event: Event):
"""
Synchronous wrapper for publishing an event.
:param event: The signed Event object to publish.
"""
try:
logger.debug(f"Submitting publish_event_async for event ID: {event.id}")
future = asyncio.run_coroutine_threadsafe(
self.publish_event_async(event), self.loop
)
# Wait for the future to complete
future.result(timeout=5) # Adjust the timeout as needed
except Exception as e:
logger.error(f"Error in publish_event: {e}")
print(f"Error: Failed to publish event: {e}", file=sys.stderr)
async def subscribe_async(
self, filters: List[dict], handler: Callable[[ClientPool, str, Event], None]
):
"""
Subscribes to events based on the provided filters using ClientPool.
:param filters: A list of filter dictionaries.
:param handler: A callback function to handle incoming events.
"""
try:
sub_id = str(uuid.uuid4())
self.client_pool.subscribe(handlers=handler, filters=filters, sub_id=sub_id)
logger.info(f"Subscribed to events with subscription ID: {sub_id}")
self.subscriptions[sub_id] = True
except Exception as e:
logger.error(f"Failed to subscribe: {e}")
logger.error(traceback.format_exc())
print(f"Error: Failed to subscribe: {e}", file=sys.stderr)
def subscribe(
self, filters: List[dict], handler: Callable[[ClientPool, str, Event], None]
):
"""
Synchronous wrapper for subscribing to events.
:param filters: A list of filter dictionaries.
:param handler: A callback function to handle incoming events.
"""
try:
asyncio.run_coroutine_threadsafe(
self.subscribe_async(filters, handler), self.loop
)
except Exception as e:
logger.error(f"Error in subscribe: {e}")
print(f"Error: Failed to subscribe: {e}", file=sys.stderr)
async def retrieve_json_from_nostr_async(self) -> Optional[str]:
"""
Retrieves the latest encrypted JSON event from Nostr.
:return: The encrypted JSON data as a Base64-encoded string, or None if retrieval fails.
"""
try:
filters = [
{
"authors": [self.key_manager.keys.public_key_hex()],
"kinds": [Event.KIND_TEXT_NOTE, Event.KIND_ENCRYPT],
"limit": 1,
}
]
events = []
def my_handler(the_client, sub_id, evt: Event):
logger.debug(f"Received event: {evt.serialize()}")
events.append(evt)
await self.subscribe_async(filters=filters, handler=my_handler)
await asyncio.sleep(2) # Adjust the sleep time as needed
# Unsubscribe from all subscriptions
for sub_id in list(self.subscriptions.keys()):
self.client_pool.unsubscribe(sub_id)
del self.subscriptions[sub_id]
logger.debug(f"Unsubscribed from sub_id {sub_id}")
if events:
event = events[0]
content_base64 = event.content
if event.kind == Event.KIND_ENCRYPT:
if NIP4Encrypt is None:
raise ImportError("monstr library required for NIP4 encryption")
nip4_encrypt = NIP4Encrypt(self.key_manager.keys)
content_base64 = nip4_encrypt.decrypt_message(
event.content, event.pub_key
)
# Return the Base64-encoded content as a string
logger.debug("Encrypted JSON data retrieved successfully.")
return content_base64
else:
logger.warning("No events found matching the filters.")
print("No events found matching the filters.", file=sys.stderr)
return None
except Exception as e:
logger.error(f"Failed to retrieve JSON from Nostr: {e}")
logger.error(traceback.format_exc())
print(f"Error: Failed to retrieve JSON from Nostr: {e}", file=sys.stderr)
return None
def retrieve_json_from_nostr(self) -> Optional[bytes]:
"""
Public method to retrieve encrypted JSON from Nostr.
:return: The encrypted JSON data as bytes, or None if retrieval fails.
"""
try:
future = asyncio.run_coroutine_threadsafe(
self.retrieve_json_from_nostr_async(), self.loop
)
return future.result(timeout=10)
except concurrent.futures.TimeoutError:
logger.error("Timeout occurred while retrieving JSON from Nostr.")
print(
"Error: Timeout occurred while retrieving JSON from Nostr.",
file=sys.stderr,
)
return None
except Exception as e:
logger.error(f"Error in retrieve_json_from_nostr: {e}")
logger.error(traceback.format_exc())
print(f"Error: Failed to retrieve JSON from Nostr: {e}", "red")
return None
async def do_post_async(self, text: str):
"""
Creates and publishes a text note event.
:param text: The content of the text note.
"""
try:
event = Event(
kind=Event.KIND_TEXT_NOTE,
content=text,
pub_key=self.key_manager.keys.public_key_hex(),
)
event.created_at = int(time.time())
event.sign(self.key_manager.keys.private_key_hex())
logger.debug(f"Event data: {event.serialize()}")
await self.publish_event_async(event)
logger.debug("Finished do_post_async")
except Exception as e:
logger.error(f"An error occurred during publishing: {e}", exc_info=True)
print(f"Error: An error occurred during publishing: {e}", file=sys.stderr)
async def subscribe_feed_async(
self, handler: Callable[[ClientPool, str, Event], None]
):
"""
Subscribes to the feed of the client's own pubkey.
:param handler: A callback function to handle incoming events.
"""
try:
filters = [
{
"authors": [self.key_manager.keys.public_key_hex()],
"kinds": [Event.KIND_TEXT_NOTE, Event.KIND_ENCRYPT],
"limit": 100,
}
]
await self.subscribe_async(filters=filters, handler=handler)
logger.info("Subscribed to your feed.")
# Removed the infinite loop to prevent blocking
except Exception as e:
logger.error(f"An error occurred during subscription: {e}", exc_info=True)
print(f"Error: An error occurred during subscription: {e}", file=sys.stderr)
async def publish_and_subscribe_async(self, text: str):
"""
Publishes a text note and subscribes to the feed concurrently.
:param text: The content of the text note to publish.
"""
try:
await asyncio.gather(
self.do_post_async(text),
self.subscribe_feed_async(self.event_handler.handle_new_event),
)
except Exception as e:
logger.error(
f"An error occurred in publish_and_subscribe_async: {e}", exc_info=True
)
print(
f"Error: An error occurred in publish and subscribe: {e}",
file=sys.stderr,
)
def publish_and_subscribe(self, text: str):
"""
Public method to publish a text note and subscribe to the feed.
:param text: The content of the text note to publish.
"""
try:
asyncio.run_coroutine_threadsafe(
self.publish_and_subscribe_async(text), self.loop
)
except Exception as e:
logger.error(f"Error in publish_and_subscribe: {e}", exc_info=True)
print(f"Error: Failed to publish and subscribe: {e}", file=sys.stderr)
def decrypt_and_save_index_from_nostr(self, encrypted_data: bytes) -> None:
"""
Decrypts the encrypted data retrieved from Nostr and updates the local index file.
:param encrypted_data: The encrypted data retrieved from Nostr.
"""
try:
decrypted_data = self.encryption_manager.decrypt_data(encrypted_data)
data = json.loads(decrypted_data.decode("utf-8"))
self.save_json_data(data)
self.update_checksum()
logger.info("Index file updated from Nostr successfully.")
print(colored("Index file updated from Nostr successfully.", "green"))
except Exception as e:
logger.error(f"Failed to decrypt and save data from Nostr: {e}")
logger.error(traceback.format_exc())
print(
colored(
f"Error: Failed to decrypt and save data from Nostr: {e}", "red"
)
)
def save_json_data(self, data: dict) -> None:
"""
Saves the JSON data to the index file in an encrypted format.
:param data: The JSON data to save.
"""
try:
encrypted_data = self.encryption_manager.encrypt_data(
json.dumps(data).encode("utf-8")
)
index_file_path = self.fingerprint_dir / "seedpass_passwords_db.json.enc"
with exclusive_lock(index_file_path):
with open(index_file_path, "wb") as f:
f.write(encrypted_data)
logger.debug(f"Encrypted data saved to {index_file_path}.")
print(colored(f"Encrypted data saved to '{index_file_path}'.", "green"))
except Exception as e:
logger.error(f"Failed to save encrypted data: {e}")
logger.error(traceback.format_exc())
print(colored(f"Error: Failed to save encrypted data: {e}", "red"))
raise
def update_checksum(self) -> None:
"""
Updates the checksum file for the password database.
"""
try:
index_file_path = self.fingerprint_dir / "seedpass_passwords_db.json.enc"
decrypted_data = self.decrypt_data_from_file(index_file_path)
content = decrypted_data.decode("utf-8")
logger.debug("Calculating checksum of the updated file content.")
checksum = hashlib.sha256(content.encode("utf-8")).hexdigest()
logger.debug(f"New checksum: {checksum}")
checksum_file = self.fingerprint_dir / "seedpass_passwords_db_checksum.txt"
with exclusive_lock(checksum_file):
with open(checksum_file, "w") as f:
f.write(checksum)
os.chmod(checksum_file, 0o600)
logger.debug(
f"Checksum for '{index_file_path}' updated and written to '{checksum_file}'."
)
print(colored(f"Checksum for '{index_file_path}' updated.", "green"))
except Exception as e:
logger.error(f"Failed to update checksum: {e}")
logger.error(traceback.format_exc())
print(colored(f"Error: Failed to update checksum: {e}", "red"))
def decrypt_data_from_file(self, file_path: Path) -> bytes:
"""
Decrypts data directly from a file.
:param file_path: Path to the encrypted file as a Path object.
:return: Decrypted data as bytes.
"""
try:
with exclusive_lock(file_path):
with open(file_path, "rb") as f:
encrypted_data = f.read()
decrypted_data = self.encryption_manager.decrypt_data(encrypted_data)
logger.debug(f"Data decrypted from file '{file_path}'.")
return decrypted_data
except Exception as e:
logger.error(f"Failed to decrypt data from file '{file_path}': {e}")
logger.error(traceback.format_exc())
print(
colored(
f"Error: Failed to decrypt data from file '{file_path}': {e}", "red"
)
)
raise
def publish_json_to_nostr(self, encrypted_json: bytes, to_pubkey: str = None):
"""
Public method to post encrypted JSON to Nostr.
:param encrypted_json: The encrypted JSON data to be sent.
:param to_pubkey: (Optional) The recipient's public key for encryption.
"""
try:
encrypted_json_b64 = base64.b64encode(encrypted_json).decode("utf-8")
logger.debug(f"Encrypted JSON (base64): {encrypted_json_b64}")
event = Event(
kind=Event.KIND_TEXT_NOTE,
content=encrypted_json_b64,
pub_key=self.key_manager.keys.public_key_hex(),
)
event.created_at = int(time.time())
content = base64.b64encode(encrypted_json).decode("utf-8")
if to_pubkey:
if NIP4Encrypt is None:
raise ImportError("monstr library required for NIP4 encryption")
nip4_encrypt = NIP4Encrypt(self.key_manager.keys)
event.content = nip4_encrypt.encrypt_message(event.content, to_pubkey)
event.kind = Event.KIND_ENCRYPT
logger.debug(f"Encrypted event content: {event.content}")
receiver = PublicKey.parse(to_pubkey)
event_output = self.client.send_private_msg_to(
self.relays, receiver, content
)
else:
builder = EventBuilder.text_note(content)
if alt_summary:
builder = builder.tags([Tag.alt(alt_summary)])
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
event_output = self.publish_event(event)
event.sign(self.key_manager.keys.private_key_hex())
logger.debug("Event created and signed")
self.publish_event(event)
logger.debug("Event published")
event_id_hex = (
event_output.id.to_hex()
if hasattr(event_output, "id")
else str(event_output)
)
logger.info(f"Successfully published event with ID: {event_id_hex}")
return True
except Exception as e:
logger.error(f"Failed to publish JSON to Nostr: {e}")
logger.error(traceback.format_exc())
print(f"Error: Failed to publish JSON to Nostr: {e}", file=sys.stderr)
return False
def publish_event(self, event):
"""Publish a prepared event to the configured relays."""
return asyncio.run(self._publish_event(event))
async def _publish_event(self, event):
return await self.client.send_event(event)
def retrieve_json_from_nostr_sync(self) -> Optional[bytes]:
"""
Retrieves encrypted data from Nostr and Base64-decodes it.
Returns:
Optional[bytes]: The encrypted data as bytes if successful, None otherwise.
"""
"""Retrieves the latest Kind 1 event from the author."""
try:
future = asyncio.run_coroutine_threadsafe(
self.retrieve_json_from_nostr_async(), self.loop
)
content_base64 = future.result(timeout=10)
if not content_base64:
logger.debug("No data retrieved from Nostr.")
return None
# Base64-decode the content
encrypted_data = base64.urlsafe_b64decode(content_base64.encode("utf-8"))
logger.debug(
"Encrypted data retrieved and Base64-decoded successfully from Nostr."
)
return encrypted_data
except concurrent.futures.TimeoutError:
logger.error("Timeout occurred while retrieving JSON from Nostr.")
print(
"Error: Timeout occurred while retrieving JSON from Nostr.",
file=sys.stderr,
)
return None
return asyncio.run(self._retrieve_json_from_nostr())
except Exception as e:
logger.error(f"Error in retrieve_json_from_nostr: {e}")
logger.error(traceback.format_exc())
print(f"Error: Failed to retrieve JSON from Nostr: {e}", "red")
logger.error("Failed to retrieve events from Nostr: %s", e)
return None
def decrypt_and_save_index_from_nostr_public(self, encrypted_data: bytes) -> None:
"""
Public method to decrypt and save data from Nostr.
async def _retrieve_json_from_nostr(self) -> Optional[bytes]:
# Filter for the latest text note (Kind 1) from our public key
pubkey = self.keys.public_key()
f = Filter().author(pubkey).kind(Kind.from_std(KindStandard.TEXT_NOTE)).limit(1)
:param encrypted_data: The encrypted data retrieved from Nostr.
"""
timeout = timedelta(seconds=10)
events = (await self.client.fetch_events(f, timeout)).to_vec()
if not events:
logger.warning("No events found on relays for this user.")
return None
latest_event = events[0]
content_b64 = latest_event.content()
if content_b64:
return base64.b64decode(content_b64.encode("utf-8"))
return None
def close_client_pool(self) -> None:
"""Disconnects the client from all relays."""
try:
self.decrypt_and_save_index_from_nostr(encrypted_data)
asyncio.run(self.client.disconnect())
logger.info("NostrClient disconnected from relays.")
except Exception as e:
logger.error(f"Failed to decrypt and save index from Nostr: {e}")
print(f"Error: Failed to decrypt and save index from Nostr: {e}", "red")
async def close_client_pool_async(self):
"""
Closes the ClientPool gracefully by canceling all pending tasks and stopping the event loop.
"""
if self.is_shutting_down:
logger.debug("Shutdown already in progress.")
return
try:
self.is_shutting_down = True
logger.debug("Initiating ClientPool shutdown.")
# Set the shutdown event
self._shutdown_event.set()
# Cancel all subscriptions
for sub_id in list(self.subscriptions.keys()):
try:
self.client_pool.unsubscribe(sub_id)
del self.subscriptions[sub_id]
logger.debug(f"Unsubscribed from sub_id {sub_id}")
except Exception as e:
logger.warning(f"Error unsubscribing from {sub_id}: {e}")
# Close all WebSocket connections
if hasattr(self.client_pool, "clients"):
tasks = [
self.safe_close_connection(client)
for client in self.client_pool.clients
]
await asyncio.gather(*tasks, return_exceptions=True)
# Gather and cancel all tasks
current_task = asyncio.current_task()
tasks = [
task
for task in asyncio.all_tasks(loop=self.loop)
if task != current_task and not task.done()
]
if tasks:
logger.debug(f"Cancelling {len(tasks)} pending tasks.")
for task in tasks:
task.cancel()
# Wait for all tasks to be cancelled with a timeout
try:
await asyncio.wait_for(
asyncio.gather(*tasks, return_exceptions=True), timeout=5
)
except asyncio.TimeoutError:
logger.warning("Timeout waiting for tasks to cancel")
logger.debug("Stopping the event loop.")
self.loop.stop()
logger.info("Event loop stopped successfully.")
except Exception as e:
logger.error(f"Error during async shutdown: {e}")
logger.error(traceback.format_exc())
finally:
self.is_shutting_down = False
def close_client_pool(self):
"""
Public method to close the ClientPool gracefully.
"""
if self.is_shutting_down:
logger.debug("Shutdown already in progress. Skipping redundant shutdown.")
return
try:
# Schedule the coroutine to close the client pool
future = asyncio.run_coroutine_threadsafe(
self.close_client_pool_async(), self.loop
)
# Wait for the coroutine to finish with a timeout
try:
future.result(timeout=10)
except concurrent.futures.TimeoutError:
logger.warning("Initial shutdown attempt timed out, forcing cleanup...")
# Additional cleanup regardless of timeout
try:
self.loop.call_soon_threadsafe(self.loop.stop)
# Give a short grace period for the loop to stop
time.sleep(0.5)
if self.loop.is_running():
logger.warning("Loop still running after stop, closing forcefully")
self.loop.call_soon_threadsafe(self.loop.close)
# Wait for the thread with a reasonable timeout
if self.loop_thread.is_alive():
self.loop_thread.join(timeout=5)
if self.loop_thread.is_alive():
logger.warning(
"Thread still alive after join, may need to be force-killed"
)
except Exception as cleanup_error:
logger.error(f"Error during final cleanup: {cleanup_error}")
logger.info("ClientPool shutdown complete")
except Exception as e:
logger.error(f"Error in close_client_pool: {e}")
logger.error(traceback.format_exc())
finally:
self.is_shutting_down = False
async def safe_close_connection(self, client):
try:
await client.close_connection()
logger.debug(f"Closed connection to relay: {client.url}")
except AttributeError:
logger.warning(
f"Client object has no attribute 'close_connection'. Skipping closure for {client.url}."
)
except Exception as e:
logger.warning(f"Error closing connection to {client.url}: {e}")
logger.error("Error during NostrClient shutdown: %s", e)

View File

@@ -1,126 +0,0 @@
# nostr/encryption_manager.py
import base64
import logging
import traceback
from cryptography.fernet import Fernet, InvalidToken
from .key_manager import KeyManager
# Instantiate the logger
logger = logging.getLogger(__name__)
class EncryptionManager:
"""
Manages encryption and decryption using Fernet symmetric encryption.
"""
def __init__(self, key_manager: KeyManager):
"""
Initializes the EncryptionManager with a Fernet instance.
:param key_manager: An instance of KeyManager to derive the encryption key.
"""
try:
# Derive the raw encryption key (32 bytes)
raw_key = key_manager.derive_encryption_key()
logger.debug(f"Derived raw encryption key length: {len(raw_key)} bytes")
# Ensure the raw key is exactly 32 bytes
if len(raw_key) != 32:
raise ValueError(
f"Derived key length is {len(raw_key)} bytes; expected 32 bytes."
)
# Base64-encode the raw key to make it URL-safe
b64_key = base64.urlsafe_b64encode(raw_key)
logger.debug(f"Base64-encoded encryption key length: {len(b64_key)} bytes")
# Initialize Fernet with the base64-encoded key
self.fernet = Fernet(b64_key)
logger.info("Fernet encryption manager initialized successfully.")
except Exception as e:
logger.error(f"EncryptionManager initialization failed: {e}")
logger.error(traceback.format_exc())
raise
def encrypt_parent_seed(self, seed: str, file_path: str) -> None:
"""
Encrypts the parent seed and saves it to the specified file.
:param seed: The BIP-39 seed phrase as a string.
:param file_path: The file path to save the encrypted seed.
"""
try:
encrypted_seed = self.fernet.encrypt(seed.encode("utf-8"))
with open(file_path, "wb") as f:
f.write(encrypted_seed)
logger.debug(f"Parent seed encrypted and saved to '{file_path}'.")
except Exception as e:
logger.error(f"Failed to encrypt and save parent seed: {e}")
logger.error(traceback.format_exc())
raise
def decrypt_parent_seed(self, file_path: str) -> str:
"""
Decrypts the parent seed from the specified file.
:param file_path: The file path to read the encrypted seed.
:return: The decrypted parent seed as a string.
"""
try:
with open(file_path, "rb") as f:
encrypted_seed = f.read()
decrypted_seed = self.fernet.decrypt(encrypted_seed).decode("utf-8")
logger.debug(f"Parent seed decrypted successfully from '{file_path}'.")
return decrypted_seed
except InvalidToken:
logger.error(
"Decryption failed: Invalid token. Possibly incorrect password or corrupted file."
)
raise ValueError(
"Decryption failed: Invalid token. Possibly incorrect password or corrupted file."
)
except Exception as e:
logger.error(f"Failed to decrypt parent seed: {e}")
logger.error(traceback.format_exc())
raise
def encrypt_data(self, data: dict) -> bytes:
"""
Encrypts a dictionary by serializing it to JSON and then encrypting it.
:param data: The dictionary to encrypt.
:return: Encrypted data as bytes.
"""
try:
json_data = json.dumps(data).encode("utf-8")
encrypted = self.fernet.encrypt(json_data)
logger.debug("Data encrypted successfully.")
return encrypted
except Exception as e:
logger.error(f"Data encryption failed: {e}")
logger.error(traceback.format_exc())
raise
def decrypt_data(self, encrypted_data: bytes) -> bytes:
"""
Decrypts encrypted data.
:param encrypted_data: The encrypted data as bytes.
:return: Decrypted data as bytes.
"""
try:
decrypted = self.fernet.decrypt(encrypted_data)
logger.debug("Data decrypted successfully.")
return decrypted
except InvalidToken as e:
logger.error(f"Decryption failed: Invalid token. {e}")
logger.error(traceback.format_exc())
raise
except Exception as e:
logger.error(f"Data decryption failed: {e}")
logger.error(traceback.format_exc())
raise

View File

@@ -47,7 +47,6 @@ class EventHandler:
f"[New Event] ID: {evt.id} | Created At: {created_at_str} | Content: {evt.content}"
)
except Exception as e:
logger.error(f"Error handling new event: {e}")
logger.error(traceback.format_exc())
logger.error(f"Error handling new event: {e}", exc_info=True)
# Optionally, handle the exception without re-raising
# For example, continue processing other events

View File

@@ -47,8 +47,7 @@ class KeyManager:
logger.debug("Nostr Keys initialized successfully.")
except Exception as e:
logger.error(f"Key initialization failed: {e}")
logger.error(traceback.format_exc())
logger.error(f"Key initialization failed: {e}", exc_info=True)
raise
def initialize_bip85(self):
@@ -64,8 +63,7 @@ class KeyManager:
logger.debug("BIP85 initialized successfully.")
return bip85
except Exception as e:
logger.error(f"Failed to initialize BIP85: {e}")
logger.error(traceback.format_exc())
logger.error(f"Failed to initialize BIP85: {e}", exc_info=True)
raise
def generate_nostr_keys(self) -> Keys:
@@ -93,8 +91,7 @@ class KeyManager:
logger.debug(f"Nostr keys generated for fingerprint {self.fingerprint}.")
return keys
except Exception as e:
logger.error(f"Failed to generate Nostr keys: {e}")
logger.error(traceback.format_exc())
logger.error(f"Failed to generate Nostr keys: {e}", exc_info=True)
raise
def get_public_key_hex(self) -> str:
@@ -129,6 +126,5 @@ class KeyManager:
npub = bech32_encode("npub", data)
return npub
except Exception as e:
logger.error(f"Failed to generate npub: {e}")
logger.error(traceback.format_exc())
logger.error(f"Failed to generate npub: {e}", exc_info=True)
raise

View File

@@ -1,22 +1,17 @@
# password_manager/__init__.py
import logging
import traceback
"""Expose password manager components with lazy imports."""
try:
from .manager import PasswordManager
from importlib import import_module
logging.info("PasswordManager module imported successfully.")
except Exception as e:
logging.error(f"Failed to import PasswordManager module: {e}")
logging.error(traceback.format_exc()) # Log full traceback
__all__ = ["PasswordManager", "ConfigManager", "Vault"]
try:
from .config_manager import ConfigManager
logging.info("ConfigManager module imported successfully.")
except Exception as e:
logging.error(f"Failed to import ConfigManager module: {e}")
logging.error(traceback.format_exc())
__all__ = ["PasswordManager", "ConfigManager"]
def __getattr__(name: str):
if name == "PasswordManager":
return import_module(".manager", __name__).PasswordManager
if name == "ConfigManager":
return import_module(".config_manager", __name__).ConfigManager
if name == "Vault":
return import_module(".vault", __name__).Vault
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")

View File

@@ -73,8 +73,7 @@ class BackupManager:
logger.info(f"Backup created successfully at '{backup_file}'.")
print(colored(f"Backup created successfully at '{backup_file}'.", "green"))
except Exception as e:
logger.error(f"Failed to create backup: {e}")
logger.error(traceback.format_exc())
logger.error(f"Failed to create backup: {e}", exc_info=True)
print(colored(f"Error: Failed to create backup: {e}", "red"))
def restore_latest_backup(self) -> None:
@@ -100,8 +99,9 @@ class BackupManager:
)
)
except Exception as e:
logger.error(f"Failed to restore from backup '{latest_backup}': {e}")
logger.error(traceback.format_exc())
logger.error(
f"Failed to restore from backup '{latest_backup}': {e}", exc_info=True
)
print(
colored(
f"Error: Failed to restore from backup '{latest_backup}': {e}",
@@ -129,8 +129,7 @@ class BackupManager:
)
print(colored(f"- {backup.name} (Created on: {creation_time})", "cyan"))
except Exception as e:
logger.error(f"Failed to list backups: {e}")
logger.error(traceback.format_exc())
logger.error(f"Failed to list backups: {e}", exc_info=True)
print(colored(f"Error: Failed to list backups: {e}", "red"))
def restore_backup_by_timestamp(self, timestamp: int) -> None:
@@ -152,8 +151,9 @@ class BackupManager:
)
)
except Exception as e:
logger.error(f"Failed to restore from backup '{backup_file}': {e}")
logger.error(traceback.format_exc())
logger.error(
f"Failed to restore from backup '{backup_file}': {e}", exc_info=True
)
print(
colored(
f"Error: Failed to restore from backup '{backup_file}': {e}", "red"

View File

@@ -10,7 +10,7 @@ import getpass
import bcrypt
from password_manager.encryption import EncryptionManager
from password_manager.vault import Vault
from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS
logger = logging.getLogger(__name__)
@@ -21,8 +21,8 @@ class ConfigManager:
CONFIG_FILENAME = "seedpass_config.json.enc"
def __init__(self, encryption_manager: EncryptionManager, fingerprint_dir: Path):
self.encryption_manager = encryption_manager
def __init__(self, vault: Vault, fingerprint_dir: Path):
self.vault = vault
self.fingerprint_dir = fingerprint_dir
self.config_path = self.fingerprint_dir / self.CONFIG_FILENAME
@@ -37,14 +37,26 @@ class ConfigManager:
"""
if not self.config_path.exists():
logger.info("Config file not found; returning defaults")
return {"relays": list(DEFAULT_NOSTR_RELAYS), "pin_hash": ""}
return {
"relays": list(DEFAULT_NOSTR_RELAYS),
"pin_hash": "",
"password_hash": "",
}
try:
data = self.encryption_manager.load_json_data(self.CONFIG_FILENAME)
data = self.vault.load_config()
if not isinstance(data, dict):
raise ValueError("Config data must be a dictionary")
# Ensure defaults for missing keys
data.setdefault("relays", list(DEFAULT_NOSTR_RELAYS))
data.setdefault("pin_hash", "")
data.setdefault("password_hash", "")
# Migrate legacy hashed_password.enc if present and password_hash is missing
legacy_file = self.fingerprint_dir / "hashed_password.enc"
if not data.get("password_hash") and legacy_file.exists():
with open(legacy_file, "rb") as f:
data["password_hash"] = f.read().decode()
self.save_config(data)
if require_pin and data.get("pin_hash"):
for _ in range(3):
pin = getpass.getpass("Enter settings PIN: ").strip()
@@ -61,7 +73,7 @@ class ConfigManager:
def save_config(self, config: dict) -> None:
"""Encrypt and save configuration."""
try:
self.encryption_manager.save_json_data(config, self.CONFIG_FILENAME)
self.vault.save_config(config)
except Exception as exc:
logger.error(f"Failed to save config: {exc}")
raise
@@ -95,3 +107,9 @@ class ConfigManager:
self.set_pin(new_pin)
return True
return False
def set_password_hash(self, password_hash: str) -> None:
"""Persist the bcrypt password hash in the config."""
config = self.load_config(require_pin=False)
config["password_hash"] = password_hash
self.save_config(config)

View File

@@ -58,7 +58,6 @@ class EncryptionManager:
logger.error(
f"Failed to initialize Fernet with provided encryption key: {e}"
)
logger.error(traceback.format_exc())
print(
colored(f"Error: Failed to initialize encryption manager: {e}", "red")
)
@@ -95,8 +94,7 @@ class EncryptionManager:
)
)
except Exception as e:
logger.error(f"Failed to encrypt and save parent seed: {e}")
logger.error(traceback.format_exc())
logger.error(f"Failed to encrypt and save parent seed: {e}", exc_info=True)
print(colored(f"Error: Failed to encrypt and save parent seed: {e}", "red"))
raise
@@ -126,8 +124,7 @@ class EncryptionManager:
print(colored("Error: Invalid encryption key or corrupted data.", "red"))
raise
except Exception as e:
logger.error(f"Failed to decrypt parent seed: {e}")
logger.error(traceback.format_exc())
logger.error(f"Failed to decrypt parent seed: {e}", exc_info=True)
print(colored(f"Error: Failed to decrypt parent seed: {e}", "red"))
raise
@@ -143,8 +140,7 @@ class EncryptionManager:
logger.debug("Data encrypted successfully.")
return encrypted_data
except Exception as e:
logger.error(f"Failed to encrypt data: {e}")
logger.error(traceback.format_exc())
logger.error(f"Failed to encrypt data: {e}", exc_info=True)
print(colored(f"Error: Failed to encrypt data: {e}", "red"))
raise
@@ -166,8 +162,7 @@ class EncryptionManager:
print(colored("Error: Invalid encryption key or corrupted data.", "red"))
raise
except Exception as e:
logger.error(f"Failed to decrypt data: {e}")
logger.error(traceback.format_exc())
logger.error(f"Failed to decrypt data: {e}", exc_info=True)
print(colored(f"Error: Failed to decrypt data: {e}", "red"))
raise
@@ -199,8 +194,10 @@ class EncryptionManager:
logger.info(f"Data encrypted and saved to '{file_path}'.")
print(colored(f"Data encrypted and saved to '{file_path}'.", "green"))
except Exception as e:
logger.error(f"Failed to encrypt and save data to '{relative_path}': {e}")
logger.error(traceback.format_exc())
logger.error(
f"Failed to encrypt and save data to '{relative_path}': {e}",
exc_info=True,
)
print(
colored(
f"Error: Failed to encrypt and save data to '{relative_path}': {e}",
@@ -236,8 +233,9 @@ class EncryptionManager:
print(colored("Error: Invalid encryption key or corrupted data.", "red"))
raise
except Exception as e:
logger.error(f"Failed to decrypt data from '{relative_path}': {e}")
logger.error(traceback.format_exc())
logger.error(
f"Failed to decrypt data from '{relative_path}': {e}", exc_info=True
)
print(
colored(
f"Error: Failed to decrypt data from '{relative_path}': {e}", "red"
@@ -263,8 +261,9 @@ class EncryptionManager:
colored(f"JSON data encrypted and saved to '{relative_path}'.", "green")
)
except Exception as e:
logger.error(f"Failed to save JSON data to '{relative_path}': {e}")
logger.error(traceback.format_exc()) # Log full traceback
logger.error(
f"Failed to save JSON data to '{relative_path}': {e}", exc_info=True
)
print(
colored(
f"Error: Failed to save JSON data to '{relative_path}': {e}", "red"
@@ -304,8 +303,9 @@ class EncryptionManager:
logger.debug(f"JSON data loaded and decrypted from '{file_path}': {data}")
return data
except json.JSONDecodeError as e:
logger.error(f"Failed to decode JSON data from '{file_path}': {e}")
logger.error(traceback.format_exc())
logger.error(
f"Failed to decode JSON data from '{file_path}': {e}", exc_info=True
)
print(
colored(
f"Error: Failed to decode JSON data from '{file_path}': {e}", "red"
@@ -319,8 +319,9 @@ class EncryptionManager:
print(colored("Error: Invalid encryption key or corrupted data.", "red"))
raise
except Exception as e:
logger.error(f"Failed to load JSON data from '{file_path}': {e}")
logger.error(traceback.format_exc())
logger.error(
f"Failed to load JSON data from '{file_path}': {e}", exc_info=True
)
print(
colored(
f"Error: Failed to load JSON data from '{file_path}': {e}", "red"
@@ -339,11 +340,13 @@ class EncryptionManager:
relative_path = Path("seedpass_passwords_db.json.enc")
try:
file_path = self.fingerprint_dir / relative_path
decrypted_data = self.decrypt_file(relative_path)
content = decrypted_data.decode("utf-8")
logger.debug("Calculating checksum of the updated file content.")
logger.debug("Calculating checksum of the encrypted file bytes.")
checksum = hashlib.sha256(content.encode("utf-8")).hexdigest()
with exclusive_lock(file_path):
with open(file_path, "rb") as f:
encrypted_bytes = f.read()
checksum = hashlib.sha256(encrypted_bytes).hexdigest()
logger.debug(f"New checksum: {checksum}")
checksum_file = file_path.parent / f"{file_path.stem}_checksum.txt"
@@ -361,8 +364,9 @@ class EncryptionManager:
)
print(colored(f"Checksum for '{file_path}' updated.", "green"))
except Exception as e:
logger.error(f"Failed to update checksum for '{relative_path}': {e}")
logger.error(traceback.format_exc()) # Log full traceback
logger.error(
f"Failed to update checksum for '{relative_path}': {e}", exc_info=True
)
print(
colored(
f"Error: Failed to update checksum for '{relative_path}': {e}",
@@ -397,8 +401,10 @@ class EncryptionManager:
logger.debug(f"Encrypted index data read from '{relative_path}'.")
return encrypted_data
except Exception as e:
logger.error(f"Failed to read encrypted index file '{relative_path}': {e}")
logger.error(traceback.format_exc()) # Log full traceback
logger.error(
f"Failed to read encrypted index file '{relative_path}': {e}",
exc_info=True,
)
print(
colored(
f"Error: Failed to read encrypted index file '{relative_path}': {e}",
@@ -427,8 +433,9 @@ class EncryptionManager:
logger.info("Index file updated from Nostr successfully.")
print(colored("Index file updated from Nostr successfully.", "green"))
except Exception as e:
logger.error(f"Failed to decrypt and save data from Nostr: {e}")
logger.error(traceback.format_exc())
logger.error(
f"Failed to decrypt and save data from Nostr: {e}", exc_info=True
)
print(
colored(
f"Error: Failed to decrypt and save data from Nostr: {e}", "red"
@@ -456,8 +463,7 @@ class EncryptionManager:
logger.debug("Seed phrase validated successfully.")
return True
except Exception as e:
logging.error(f"Error validating seed phrase: {e}")
logging.error(traceback.format_exc()) # Log full traceback
logging.error(f"Error validating seed phrase: {e}", exc_info=True)
print(colored(f"Error: Failed to validate seed phrase: {e}", "red"))
return False
@@ -483,7 +489,6 @@ class EncryptionManager:
logger.debug("Seed derived successfully from mnemonic.")
return seed
except Exception as e:
logger.error(f"Failed to derive seed from mnemonic: {e}")
logger.error(traceback.format_exc())
logger.error(f"Failed to derive seed from mnemonic: {e}", exc_info=True)
print(colored(f"Error: Failed to derive seed from mnemonic: {e}", "red"))
raise

View File

@@ -28,7 +28,7 @@ from pathlib import Path
from termcolor import colored
from password_manager.encryption import EncryptionManager
from password_manager.vault import Vault
from utils.file_lock import exclusive_lock
@@ -37,14 +37,14 @@ logger = logging.getLogger(__name__)
class EntryManager:
def __init__(self, encryption_manager: EncryptionManager, fingerprint_dir: Path):
def __init__(self, vault: Vault, fingerprint_dir: Path):
"""
Initializes the EntryManager with the EncryptionManager and fingerprint directory.
:param encryption_manager: The encryption manager instance.
:param vault: The Vault instance for file access.
:param fingerprint_dir: The directory corresponding to the fingerprint.
"""
self.encryption_manager = encryption_manager
self.vault = vault
self.fingerprint_dir = fingerprint_dir
# Use paths relative to the fingerprint directory
@@ -56,7 +56,7 @@ class EntryManager:
def _load_index(self) -> Dict[str, Any]:
if self.index_file.exists():
try:
data = self.encryption_manager.load_json_data(self.index_file)
data = self.vault.load_index()
logger.debug("Index loaded successfully.")
return data
except Exception as e:
@@ -70,7 +70,7 @@ class EntryManager:
def _save_index(self, data: Dict[str, Any]) -> None:
try:
self.encryption_manager.save_json_data(data, self.index_file)
self.vault.save_index(data)
logger.debug("Index saved successfully.")
except Exception as e:
logger.error(f"Failed to save index: {e}")
@@ -83,7 +83,7 @@ class EntryManager:
:return: The next index number as an integer.
"""
try:
data = self.encryption_manager.load_json_data(self.index_file)
data = self.vault.load_index()
if "passwords" in data and isinstance(data["passwords"], dict):
indices = [int(idx) for idx in data["passwords"].keys()]
next_index = max(indices) + 1 if indices else 0
@@ -92,8 +92,7 @@ class EntryManager:
logger.debug(f"Next index determined: {next_index}")
return next_index
except Exception as e:
logger.error(f"Error determining next index: {e}")
logger.error(traceback.format_exc())
logger.error(f"Error determining next index: {e}", exc_info=True)
print(colored(f"Error determining next index: {e}", "red"))
sys.exit(1)
@@ -117,7 +116,7 @@ class EntryManager:
"""
try:
index = self.get_next_index()
data = self.encryption_manager.load_json_data(self.index_file)
data = self.vault.load_index()
data["passwords"][str(index)] = {
"website": website_name,
@@ -141,8 +140,7 @@ class EntryManager:
return index # Return the assigned index
except Exception as e:
logger.error(f"Failed to add entry: {e}")
logger.error(traceback.format_exc())
logger.error(f"Failed to add entry: {e}", exc_info=True)
print(colored(f"Error: Failed to add entry: {e}", "red"))
sys.exit(1)
@@ -153,22 +151,9 @@ class EntryManager:
:return: The encrypted data as bytes, or None if retrieval fails.
"""
try:
if not self.index_file.exists():
logger.error(f"Index file '{self.index_file}' does not exist.")
print(
colored(
f"Error: Index file '{self.index_file}' does not exist.", "red"
)
)
return None
with open(self.index_file, "rb") as file:
encrypted_data = file.read()
logger.debug("Encrypted index file data retrieved successfully.")
return encrypted_data
return self.vault.get_encrypted_index()
except Exception as e:
logger.error(f"Failed to retrieve encrypted index file: {e}")
logger.error(traceback.format_exc())
logger.error(f"Failed to retrieve encrypted index file: {e}", exc_info=True)
print(
colored(f"Error: Failed to retrieve encrypted index file: {e}", "red")
)
@@ -182,7 +167,7 @@ class EntryManager:
:return: A dictionary containing the entry details or None if not found.
"""
try:
data = self.encryption_manager.load_json_data(self.index_file)
data = self.vault.load_index()
entry = data.get("passwords", {}).get(str(index))
if entry:
@@ -194,8 +179,9 @@ class EntryManager:
return None
except Exception as e:
logger.error(f"Failed to retrieve entry at index {index}: {e}")
logger.error(traceback.format_exc())
logger.error(
f"Failed to retrieve entry at index {index}: {e}", exc_info=True
)
print(
colored(f"Error: Failed to retrieve entry at index {index}: {e}", "red")
)
@@ -217,7 +203,7 @@ class EntryManager:
:param blacklisted: (Optional) The new blacklist status.
"""
try:
data = self.encryption_manager.load_json_data(self.index_file)
data = self.vault.load_index()
entry = data.get("passwords", {}).get(str(index))
if not entry:
@@ -259,8 +245,7 @@ class EntryManager:
)
except Exception as e:
logger.error(f"Failed to modify entry at index {index}: {e}")
logger.error(traceback.format_exc())
logger.error(f"Failed to modify entry at index {index}: {e}", exc_info=True)
print(
colored(f"Error: Failed to modify entry at index {index}: {e}", "red")
)
@@ -272,7 +257,7 @@ class EntryManager:
:return: A list of tuples containing entry details: (index, website, username, url, blacklisted)
"""
try:
data = self.encryption_manager.load_json_data()
data = self.vault.load_index()
passwords = data.get("passwords", {})
if not passwords:
@@ -304,8 +289,7 @@ class EntryManager:
return entries
except Exception as e:
logger.error(f"Failed to list entries: {e}")
logger.error(traceback.format_exc()) # Log full traceback
logger.error(f"Failed to list entries: {e}", exc_info=True)
print(colored(f"Error: Failed to list entries: {e}", "red"))
return []
@@ -316,11 +300,11 @@ class EntryManager:
:param index: The index number of the password entry to delete.
"""
try:
data = self.encryption_manager.load_json_data()
data = self.vault.load_index()
if "passwords" in data and str(index) in data["passwords"]:
del data["passwords"][str(index)]
logger.debug(f"Deleted entry at index {index}.")
self.encryption_manager.save_json_data(data)
self.vault.save_index(data)
self.update_checksum()
self.backup_index_file()
logger.info(f"Entry at index {index} deleted successfully.")
@@ -341,8 +325,7 @@ class EntryManager:
)
except Exception as e:
logger.error(f"Failed to delete entry at index {index}: {e}")
logger.error(traceback.format_exc()) # Log full traceback
logger.error(f"Failed to delete entry at index {index}: {e}", exc_info=True)
print(
colored(f"Error: Failed to delete entry at index {index}: {e}", "red")
)
@@ -352,12 +335,12 @@ class EntryManager:
Updates the checksum file for the password database to ensure data integrity.
"""
try:
data = self.encryption_manager.load_json_data(self.index_file)
data = self.vault.load_index()
json_content = json.dumps(data, indent=4)
checksum = hashlib.sha256(json_content.encode("utf-8")).hexdigest()
# Construct the full path for the checksum file
checksum_path = self.fingerprint_dir / self.checksum_file
# The checksum file path already includes the fingerprint directory
checksum_path = self.checksum_file
with open(checksum_path, "w") as f:
f.write(checksum)
@@ -366,8 +349,7 @@ class EntryManager:
print(colored(f"[+] Checksum updated successfully.", "green"))
except Exception as e:
logger.error(f"Failed to update checksum: {e}")
logger.error(traceback.format_exc()) # Log full traceback
logger.error(f"Failed to update checksum: {e}", exc_info=True)
print(colored(f"Error: Failed to update checksum: {e}", "red"))
def backup_index_file(self) -> None:
@@ -375,7 +357,8 @@ class EntryManager:
Creates a backup of the encrypted JSON index file to prevent data loss.
"""
try:
index_file_path = self.fingerprint_dir / self.index_file
# self.index_file already includes the fingerprint directory
index_file_path = self.index_file
if not index_file_path.exists():
logger.warning(
f"Index file '{index_file_path}' does not exist. No backup created."
@@ -395,8 +378,7 @@ class EntryManager:
print(colored(f"[+] Backup created at '{backup_path}'.", "green"))
except Exception as e:
logger.error(f"Failed to create backup: {e}")
logger.error(traceback.format_exc()) # Log full traceback
logger.error(f"Failed to create backup: {e}", exc_info=True)
print(colored(f"Warning: Failed to create backup: {e}", "yellow"))
def restore_from_backup(self, backup_path: str) -> None:
@@ -431,8 +413,9 @@ class EntryManager:
self.update_checksum()
except Exception as e:
logger.error(f"Failed to restore from backup '{backup_path}': {e}")
logger.error(traceback.format_exc()) # Log full traceback
logger.error(
f"Failed to restore from backup '{backup_path}': {e}", exc_info=True
)
print(
colored(
f"Error: Failed to restore from backup '{backup_path}': {e}", "red"
@@ -462,56 +445,6 @@ class EntryManager:
print("-" * 40)
except Exception as e:
logger.error(f"Failed to list all entries: {e}")
logger.error(traceback.format_exc()) # Log full traceback
logger.error(f"Failed to list all entries: {e}", exc_info=True)
print(colored(f"Error: Failed to list all entries: {e}", "red"))
return
# Example usage (this part should be removed or commented out when integrating into the larger application)
if __name__ == "__main__":
from password_manager.encryption import (
EncryptionManager,
) # Ensure this import is correct based on your project structure
# Initialize EncryptionManager with a dummy key for demonstration purposes
# Replace 'your-fernet-key' with your actual Fernet key
try:
dummy_key = Fernet.generate_key()
encryption_manager = EncryptionManager(dummy_key)
except Exception as e:
logger.error(f"Failed to initialize EncryptionManager: {e}")
print(colored(f"Error: Failed to initialize EncryptionManager: {e}", "red"))
sys.exit(1)
# Initialize EntryManager
try:
entry_manager = EntryManager(encryption_manager)
except Exception as e:
logger.error(f"Failed to initialize EntryManager: {e}")
print(colored(f"Error: Failed to initialize EntryManager: {e}", "red"))
sys.exit(1)
# Example operations
# These would typically be triggered by user interactions, e.g., via a CLI menu
# Uncomment and modify the following lines as needed for testing
# Adding an entry
# entry_manager.add_entry("Example Website", 16, "user123", "https://example.com", False)
# Listing all entries
# entry_manager.list_all_entries()
# Retrieving an entry
# entry = entry_manager.retrieve_entry(0)
# if entry:
# print(entry)
# Modifying an entry
# entry_manager.modify_entry(0, username="new_user123")
# Deleting an entry
# entry_manager.delete_entry(0)
# Restoring from a backup
# entry_manager.restore_from_backup("path_to_backup_file.json.enc")

View File

@@ -16,13 +16,21 @@ import getpass
import os
from typing import Optional
import shutil
import time
from termcolor import colored
from password_manager.encryption import EncryptionManager
from password_manager.entry_management import EntryManager
from password_manager.password_generation import PasswordGenerator
from password_manager.backup import BackupManager
from utils.key_derivation import derive_key_from_parent_seed, derive_key_from_password
from password_manager.vault import Vault
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
from utils.password_prompt import (
prompt_for_password,
@@ -46,6 +54,7 @@ from pathlib import Path
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator, Bip39MnemonicGenerator, Bip39Languages
from datetime import datetime
from utils.fingerprint_manager import FingerprintManager
@@ -66,21 +75,28 @@ class PasswordManager:
verification, ensuring the integrity and confidentiality of the stored password database.
"""
def __init__(self):
"""
Initializes the PasswordManager by setting up encryption, loading or setting up the parent seed,
and initializing other components like EntryManager, PasswordGenerator, BackupManager, and FingerprintManager.
"""
def __init__(
self, encryption_mode: EncryptionMode = DEFAULT_ENCRYPTION_MODE
) -> None:
"""Initialize the PasswordManager."""
self.encryption_mode: EncryptionMode = encryption_mode
self.encryption_manager: Optional[EncryptionManager] = None
self.entry_manager: Optional[EntryManager] = None
self.password_generator: Optional[PasswordGenerator] = None
self.backup_manager: Optional[BackupManager] = None
self.vault: Optional[Vault] = None
self.fingerprint_manager: Optional[FingerprintManager] = None
self.parent_seed: Optional[str] = None
self.bip85: Optional[BIP85] = None
self.nostr_client: Optional[NostrClient] = None
self.config_manager: Optional[ConfigManager] = None
# Track changes to trigger periodic Nostr sync
self.is_dirty: bool = False
self.last_update: float = time.time()
self.last_activity: float = time.time()
self.locked: bool = False
# Initialize the fingerprint manager first
self.initialize_fingerprint_manager()
@@ -90,6 +106,33 @@ class PasswordManager:
# Set the current fingerprint directory
self.fingerprint_dir = self.fingerprint_manager.get_current_fingerprint_dir()
def update_activity(self) -> None:
"""Record the current time as the last user activity."""
self.last_activity = time.time()
def lock_vault(self) -> None:
"""Clear sensitive information from memory."""
self.parent_seed = None
self.encryption_manager = None
self.entry_manager = None
self.password_generator = None
self.backup_manager = None
self.vault = None
self.bip85 = None
self.nostr_client = None
self.config_manager = None
self.locked = True
def unlock_vault(self) -> None:
"""Prompt for password and reinitialize managers."""
if not self.fingerprint_dir:
raise ValueError("Fingerprint directory not set")
self.setup_encryption_manager(self.fingerprint_dir)
self.initialize_bip85()
self.initialize_managers()
self.locked = False
self.update_activity()
def initialize_fingerprint_manager(self):
"""
Initializes the FingerprintManager.
@@ -98,8 +141,7 @@ class PasswordManager:
self.fingerprint_manager = FingerprintManager(APP_DIR)
logger.debug("FingerprintManager initialized successfully.")
except Exception as e:
logger.error(f"Failed to initialize FingerprintManager: {e}")
logger.error(traceback.format_exc())
logger.error(f"Failed to initialize FingerprintManager: {e}", exc_info=True)
print(
colored(f"Error: Failed to initialize FingerprintManager: {e}", "red")
)
@@ -144,8 +186,7 @@ class PasswordManager:
self.select_fingerprint(selected_fingerprint)
except Exception as e:
logger.error(f"Error during seed profile selection: {e}")
logger.error(traceback.format_exc())
logger.error(f"Error during seed profile selection: {e}", exc_info=True)
print(colored(f"Error: Failed to select seed profile: {e}", "red"))
sys.exit(1)
@@ -175,8 +216,7 @@ class PasswordManager:
)
except Exception as e:
logger.error(f"Error adding new seed profile: {e}")
logger.error(traceback.format_exc())
logger.error(f"Error adding new seed profile: {e}", exc_info=True)
print(colored(f"Error: Failed to add new seed profile: {e}", "red"))
sys.exit(1)
@@ -196,7 +236,6 @@ class PasswordManager:
sys.exit(1)
# Setup the encryption manager and load parent seed
self.setup_encryption_manager(self.fingerprint_dir)
self.load_parent_seed(self.fingerprint_dir)
# Initialize BIP85 and other managers
self.initialize_bip85()
self.initialize_managers()
@@ -213,55 +252,64 @@ class PasswordManager:
def setup_encryption_manager(
self, fingerprint_dir: Path, password: Optional[str] = None
):
"""
Sets up the EncryptionManager for the selected fingerprint.
) -> None:
"""Set up encryption for the current fingerprint and load the seed."""
Parameters:
fingerprint_dir (Path): The directory corresponding to the fingerprint.
password (Optional[str]): The user's master password.
"""
try:
# Prompt for password if not provided
if password is None:
password = prompt_existing_password("Enter your master password: ")
# Derive key from password
key = derive_key_from_password(password)
self.encryption_manager = EncryptionManager(key, fingerprint_dir)
logger.debug(
"EncryptionManager set up successfully for selected fingerprint."
if not self.parent_seed:
seed_key = derive_key_from_password(password)
seed_mgr = EncryptionManager(seed_key, fingerprint_dir)
try:
self.parent_seed = seed_mgr.decrypt_parent_seed()
except Exception:
print(colored("Invalid password. Exiting.", "red"))
raise
key = derive_index_key(
self.parent_seed,
password,
self.encryption_mode,
)
# Verify the password
self.fingerprint_dir = fingerprint_dir # Ensure self.fingerprint_dir is set
self.encryption_manager = EncryptionManager(key, fingerprint_dir)
self.vault = Vault(self.encryption_manager, fingerprint_dir)
self.config_manager = ConfigManager(
vault=self.vault,
fingerprint_dir=fingerprint_dir,
)
self.fingerprint_dir = fingerprint_dir
if not self.verify_password(password):
print(colored("Invalid password. Exiting.", "red"))
sys.exit(1)
except Exception as e:
logger.error(f"Failed to set up EncryptionManager: {e}")
logger.error(traceback.format_exc())
logger.error(f"Failed to set up EncryptionManager: {e}", exc_info=True)
print(colored(f"Error: Failed to set up encryption: {e}", "red"))
sys.exit(1)
def load_parent_seed(self, fingerprint_dir: Path):
"""
Loads and decrypts the parent seed from the fingerprint directory.
def load_parent_seed(
self, fingerprint_dir: Path, password: Optional[str] = None
) -> None:
"""Load and decrypt the parent seed using the password-only key."""
if self.parent_seed:
return
if password is None:
password = prompt_existing_password("Enter your master password: ")
Parameters:
fingerprint_dir (Path): The directory corresponding to the fingerprint.
"""
try:
self.parent_seed = self.encryption_manager.decrypt_parent_seed()
logger.debug(
f"Parent seed loaded for fingerprint {self.current_fingerprint}."
)
# Initialize BIP85 with the parent seed
seed_key = derive_key_from_password(password)
seed_mgr = EncryptionManager(seed_key, fingerprint_dir)
self.parent_seed = seed_mgr.decrypt_parent_seed()
seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate()
self.bip85 = BIP85(seed_bytes)
logger.debug("BIP-85 initialized successfully.")
except Exception as e:
logger.error(f"Failed to load parent seed: {e}")
logger.error(traceback.format_exc())
logger.error(f"Failed to load parent seed: {e}", exc_info=True)
print(colored(f"Error: Failed to load parent seed: {e}", "red"))
sys.exit(1)
@@ -306,9 +354,6 @@ class PasswordManager:
# Set up the encryption manager with the new password and seed profile directory
self.setup_encryption_manager(self.fingerprint_dir, password)
# Load the parent seed for the selected seed profile
self.load_parent_seed(self.fingerprint_dir)
# Initialize BIP85 and other managers
self.initialize_bip85()
self.initialize_managers()
@@ -320,6 +365,7 @@ class PasswordManager:
self.nostr_client = NostrClient(
encryption_manager=self.encryption_manager,
fingerprint=self.current_fingerprint,
parent_seed=getattr(self, "parent_seed", None),
)
logging.info(
f"NostrClient re-initialized with seed profile {self.current_fingerprint}."
@@ -334,8 +380,7 @@ class PasswordManager:
return True # Return True to indicate success
except Exception as e:
logging.error(f"Error during seed profile switching: {e}")
logging.error(traceback.format_exc())
logging.error(f"Error during seed profile switching: {e}", exc_info=True)
print(colored(f"Error: Failed to switch seed profiles: {e}", "red"))
return False # Return False to indicate failure
@@ -386,6 +431,7 @@ class PasswordManager:
# Initialize EncryptionManager with key and fingerprint_dir
self.encryption_manager = EncryptionManager(key, fingerprint_dir)
self.vault = Vault(self.encryption_manager, fingerprint_dir)
self.parent_seed = self.encryption_manager.decrypt_parent_seed()
# Log the type and content of parent_seed
@@ -402,8 +448,7 @@ class PasswordManager:
self.initialize_bip85()
logging.debug("Parent seed decrypted and validated successfully.")
except Exception as e:
logging.error(f"Failed to decrypt parent seed: {e}")
logging.error(traceback.format_exc())
logging.error(f"Failed to decrypt parent seed: {e}", exc_info=True)
print(colored(f"Error: Failed to decrypt parent seed: {e}", "red"))
sys.exit(1)
@@ -413,6 +458,26 @@ class PasswordManager:
Asks the user whether to enter an existing BIP-85 seed or generate a new one.
"""
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()
@@ -467,11 +532,19 @@ class PasswordManager:
# Initialize EncryptionManager with key and fingerprint_dir
password = prompt_for_password()
key = derive_key_from_password(password)
self.encryption_manager = EncryptionManager(key, fingerprint_dir)
index_key = derive_index_key(
parent_seed,
password,
self.encryption_mode,
)
seed_key = derive_key_from_password(password)
self.encryption_manager = EncryptionManager(index_key, fingerprint_dir)
seed_mgr = EncryptionManager(seed_key, fingerprint_dir)
self.vault = Vault(self.encryption_manager, fingerprint_dir)
# Encrypt and save the parent seed
self.encryption_manager.encrypt_parent_seed(parent_seed)
seed_mgr.encrypt_parent_seed(parent_seed)
logging.info("Parent seed encrypted and saved successfully.")
# Store the hashed password
@@ -572,14 +645,10 @@ class PasswordManager:
try:
master_seed = os.urandom(32) # Generate a random 32-byte seed
bip85 = BIP85(master_seed)
mnemonic_obj = bip85.derive_mnemonic(index=0, words_num=12)
mnemonic_str = (
mnemonic_obj.ToStr()
) # Convert Bip39Mnemonic object to string
return mnemonic_str
mnemonic = bip85.derive_mnemonic(index=0, words_num=12)
return mnemonic
except Exception as e:
logging.error(f"Failed to generate BIP-85 seed: {e}")
logging.error(traceback.format_exc())
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)
@@ -597,17 +666,23 @@ class PasswordManager:
# Prompt for password
password = prompt_for_password()
# Derive key from password
key = derive_key_from_password(password)
# Re-initialize EncryptionManager with the new key and fingerprint_dir
self.encryption_manager = EncryptionManager(key, fingerprint_dir)
# Store the hashed password
index_key = derive_index_key(
seed,
password,
self.encryption_mode,
)
seed_key = derive_key_from_password(password)
self.encryption_manager = EncryptionManager(index_key, fingerprint_dir)
seed_mgr = EncryptionManager(seed_key, fingerprint_dir)
self.vault = Vault(self.encryption_manager, fingerprint_dir)
self.store_hashed_password(password)
logging.info("User password hashed and stored successfully.")
# Encrypt and save the parent seed
self.encryption_manager.encrypt_parent_seed(seed)
seed_mgr.encrypt_parent_seed(seed)
logging.info("Parent seed encrypted and saved successfully.")
self.parent_seed = seed # Ensure this is a string
@@ -619,8 +694,7 @@ class PasswordManager:
self.initialize_managers()
self.sync_index_from_nostr_if_missing()
except Exception as e:
logging.error(f"Failed to encrypt and save parent seed: {e}")
logging.error(traceback.format_exc())
logging.error(f"Failed to encrypt and save parent seed: {e}", exc_info=True)
print(colored(f"Error: Failed to encrypt and save parent seed: {e}", "red"))
sys.exit(1)
@@ -633,8 +707,7 @@ class PasswordManager:
self.bip85 = BIP85(seed_bytes)
logging.debug("BIP-85 initialized successfully.")
except Exception as e:
logging.error(f"Failed to initialize BIP-85: {e}")
logging.error(traceback.format_exc())
logging.error(f"Failed to initialize BIP-85: {e}", exc_info=True)
print(colored(f"Error: Failed to initialize BIP-85: {e}", "red"))
sys.exit(1)
@@ -650,7 +723,7 @@ class PasswordManager:
# Reinitialize the managers with the updated EncryptionManager and current fingerprint context
self.entry_manager = EntryManager(
encryption_manager=self.encryption_manager,
vault=self.vault,
fingerprint_dir=self.fingerprint_dir,
)
@@ -664,7 +737,7 @@ class PasswordManager:
# Load relay configuration and initialize NostrClient
self.config_manager = ConfigManager(
encryption_manager=self.encryption_manager,
vault=self.vault,
fingerprint_dir=self.fingerprint_dir,
)
config = self.config_manager.load_config()
@@ -674,13 +747,13 @@ class PasswordManager:
encryption_manager=self.encryption_manager,
fingerprint=self.current_fingerprint,
relays=relay_list,
parent_seed=getattr(self, "parent_seed", None),
)
logger.debug("Managers re-initialized for the new fingerprint.")
except Exception as e:
logger.error(f"Failed to initialize managers: {e}")
logging.error(traceback.format_exc())
logger.error(f"Failed to initialize managers: {e}", exc_info=True)
print(colored(f"Error: Failed to initialize managers: {e}", "red"))
sys.exit(1)
@@ -692,7 +765,7 @@ class PasswordManager:
try:
encrypted = self.nostr_client.retrieve_json_from_nostr_sync()
if encrypted:
self.encryption_manager.decrypt_and_save_index_from_nostr(encrypted)
self.vault.decrypt_and_save_index_from_nostr(encrypted)
logger.info("Initialized local database from Nostr.")
except Exception as e:
logger.warning(f"Unable to sync index from Nostr: {e}")
@@ -730,6 +803,10 @@ class PasswordManager:
website_name, length, username, url, blacklisted=False
)
# Mark database as dirty for background sync
self.is_dirty = True
self.last_update = time.time()
# Generate the password using the assigned index
password = self.password_generator.generate_password(length, index)
@@ -752,12 +829,13 @@ class PasswordManager:
"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}")
logging.error(traceback.format_exc())
logging.error(
f"Failed to post updated index to Nostr: {nostr_error}",
exc_info=True,
)
except Exception as e:
logging.error(f"Error during password generation: {e}")
logging.error(traceback.format_exc())
logging.error(f"Error during password generation: {e}", exc_info=True)
print(colored(f"Error: Failed to generate password: {e}", "red"))
def handle_retrieve_entry(self) -> None:
@@ -824,8 +902,7 @@ class PasswordManager:
else:
print(colored("Error: Failed to retrieve the password.", "red"))
except Exception as e:
logging.error(f"Error during password retrieval: {e}")
logging.error(traceback.format_exc())
logging.error(f"Error during password retrieval: {e}", exc_info=True)
print(colored(f"Error: Failed to retrieve password: {e}", "red"))
def handle_modify_entry(self) -> None:
@@ -906,6 +983,10 @@ class PasswordManager:
index, new_username, new_url, new_blacklisted
)
# Mark database as dirty for background sync
self.is_dirty = True
self.last_update = time.time()
print(colored(f"Entry updated successfully for index {index}.", "green"))
# Push the updated index to Nostr so changes are backed up.
@@ -917,12 +998,13 @@ class PasswordManager:
"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}")
logging.error(traceback.format_exc())
logging.error(
f"Failed to post updated index to Nostr: {nostr_error}",
exc_info=True,
)
except Exception as e:
logging.error(f"Error during modifying entry: {e}")
logging.error(traceback.format_exc())
logging.error(f"Error during modifying entry: {e}", exc_info=True)
print(colored(f"Error: Failed to modify entry: {e}", "red"))
def delete_entry(self) -> None:
@@ -944,6 +1026,10 @@ class PasswordManager:
self.entry_manager.delete_entry(index_to_delete)
# Mark database as dirty for background sync
self.is_dirty = True
self.last_update = time.time()
# Push updated index to Nostr after deletion
try:
encrypted_data = self.get_encrypted_data()
@@ -953,12 +1039,13 @@ class PasswordManager:
"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}")
logging.error(traceback.format_exc())
logging.error(
f"Failed to post updated index to Nostr: {nostr_error}",
exc_info=True,
)
except Exception as e:
logging.error(f"Error during entry deletion: {e}")
logging.error(traceback.format_exc())
logging.error(f"Error during entry deletion: {e}", exc_info=True)
print(colored(f"Error: Failed to delete entry: {e}", "red"))
def handle_verify_checksum(self) -> None:
@@ -979,8 +1066,7 @@ class PasswordManager:
)
logging.error("Checksum verification failed.")
except Exception as e:
logging.error(f"Error during checksum verification: {e}")
logging.error(traceback.format_exc())
logging.error(f"Error during checksum verification: {e}", exc_info=True)
print(colored(f"Error: Failed to verify checksum: {e}", "red"))
def get_encrypted_data(self) -> Optional[bytes]:
@@ -990,7 +1076,7 @@ class PasswordManager:
:return: The encrypted data as bytes, or None if retrieval fails.
"""
try:
encrypted_data = self.entry_manager.get_encrypted_index()
encrypted_data = self.vault.get_encrypted_index()
if encrypted_data:
logging.debug("Encrypted index data retrieved successfully.")
return encrypted_data
@@ -999,8 +1085,7 @@ class PasswordManager:
print(colored("Error: Failed to retrieve encrypted index data.", "red"))
return None
except Exception as e:
logging.error(f"Error retrieving encrypted data: {e}")
logging.error(traceback.format_exc())
logging.error(f"Error retrieving encrypted data: {e}", exc_info=True)
print(colored(f"Error: Failed to retrieve encrypted data: {e}", "red"))
return None
@@ -1011,19 +1096,13 @@ class PasswordManager:
:param encrypted_data: The encrypted data retrieved from Nostr.
"""
try:
# Decrypt the data using EncryptionManager's decrypt_data method
decrypted_data = self.encryption_manager.decrypt_data(encrypted_data)
# Save the decrypted data to the index file
index_file_path = self.fingerprint_dir / "seedpass_passwords_db.json.enc"
with open(index_file_path, "wb") as f:
f.write(decrypted_data)
self.vault.decrypt_and_save_index_from_nostr(encrypted_data)
logging.info("Index file updated from Nostr successfully.")
print(colored("Index file updated from Nostr successfully.", "green"))
except Exception as e:
logging.error(f"Failed to decrypt and save data from Nostr: {e}")
logging.error(traceback.format_exc())
logging.error(
f"Failed to decrypt and save data from Nostr: {e}", exc_info=True
)
print(
colored(
f"Error: Failed to decrypt and save data from Nostr: {e}", "red"
@@ -1040,8 +1119,7 @@ class PasswordManager:
self.backup_manager.create_backup()
print(colored("Backup created successfully.", "green"))
except Exception as e:
logging.error(f"Failed to create backup: {e}")
logging.error(traceback.format_exc())
logging.error(f"Failed to create backup: {e}", exc_info=True)
print(colored(f"Error: Failed to create backup: {e}", "red"))
def restore_database(self) -> None:
@@ -1056,8 +1134,7 @@ class PasswordManager:
)
)
except Exception as e:
logging.error(f"Failed to restore backup: {e}")
logging.error(traceback.format_exc())
logging.error(f"Failed to restore backup: {e}", exc_info=True)
print(colored(f"Error: Failed to restore backup: {e}", "red"))
def handle_backup_reveal_parent_seed(self) -> None:
@@ -1133,8 +1210,7 @@ class PasswordManager:
)
except Exception as e:
logging.error(f"Error during parent seed backup/reveal: {e}")
logging.error(traceback.format_exc())
logging.error(f"Error during parent seed backup/reveal: {e}", exc_info=True)
print(colored(f"Error: Failed to backup/reveal parent seed: {e}", "red"))
def verify_password(self, password: str) -> bool:
@@ -1148,13 +1224,20 @@ class PasswordManager:
bool: True if the password is correct, False otherwise.
"""
try:
hashed_password_file = self.fingerprint_dir / "hashed_password.enc"
if not hashed_password_file.exists():
logging.error("Hashed password file not found.")
print(colored("Error: Hashed password file not found.", "red"))
return False
with open(hashed_password_file, "rb") as f:
stored_hash = f.read()
config = self.config_manager.load_config(require_pin=False)
stored_hash = config.get("password_hash", "").encode()
if not stored_hash:
# Fallback to legacy file if hash not present in config
legacy_file = self.fingerprint_dir / "hashed_password.enc"
if legacy_file.exists():
with open(legacy_file, "rb") as f:
stored_hash = f.read()
self.config_manager.set_password_hash(stored_hash.decode())
else:
logging.error("Hashed password not found.")
print(colored("Error: Hashed password not found.", "red"))
return False
is_correct = bcrypt.checkpw(password.encode("utf-8"), stored_hash)
if is_correct:
logging.debug("Password verification successful.")
@@ -1162,8 +1245,7 @@ class PasswordManager:
logging.warning("Password verification failed.")
return is_correct
except Exception as e:
logging.error(f"Error verifying password: {e}")
logging.error(traceback.format_exc())
logging.error(f"Error verifying password: {e}", exc_info=True)
print(colored(f"Error: Failed to verify password: {e}", "red"))
return False
@@ -1190,25 +1272,32 @@ class PasswordManager:
This should be called during the initial setup.
"""
try:
hashed_password_file = self.fingerprint_dir / "hashed_password.enc"
hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
with open(hashed_password_file, "wb") as f:
f.write(hashed)
os.chmod(hashed_password_file, 0o600)
hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode()
if self.config_manager:
self.config_manager.set_password_hash(hashed)
else:
# Fallback to legacy file method if config_manager unavailable
hashed_password_file = self.fingerprint_dir / "hashed_password.enc"
with open(hashed_password_file, "wb") as f:
f.write(hashed.encode())
os.chmod(hashed_password_file, 0o600)
logging.info("User password hashed and stored successfully.")
except AttributeError:
# If bcrypt.hashpw is not available, try using bcrypt directly
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password.encode("utf-8"), salt)
with open(hashed_password_file, "wb") as f:
f.write(hashed)
os.chmod(hashed_password_file, 0o600)
hashed = bcrypt.hashpw(password.encode("utf-8"), salt).decode()
if self.config_manager:
self.config_manager.set_password_hash(hashed)
else:
hashed_password_file = self.fingerprint_dir / "hashed_password.enc"
with open(hashed_password_file, "wb") as f:
f.write(hashed.encode())
os.chmod(hashed_password_file, 0o600)
logging.info(
"User password hashed and stored successfully (using alternative method)."
)
except Exception as e:
logging.error(f"Failed to store hashed password: {e}")
logging.error(traceback.format_exc())
logging.error(f"Failed to store hashed password: {e}", exc_info=True)
print(colored(f"Error: Failed to store hashed password: {e}", "red"))
raise
@@ -1223,22 +1312,33 @@ class PasswordManager:
new_password = prompt_for_password()
# Load data with existing encryption manager
index_data = self.entry_manager.encryption_manager.load_json_data()
index_data = self.vault.load_index()
config_data = self.config_manager.load_config(require_pin=False)
# Create a new encryption manager with the new password
new_key = derive_key_from_password(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)
seed_key = derive_key_from_password(new_password)
seed_mgr = EncryptionManager(seed_key, self.fingerprint_dir)
new_enc_mgr = EncryptionManager(new_key, self.fingerprint_dir)
# Re-encrypt sensitive files using the new manager
new_enc_mgr.encrypt_parent_seed(self.parent_seed)
new_enc_mgr.save_json_data(index_data)
self.config_manager.encryption_manager = new_enc_mgr
seed_mgr.encrypt_parent_seed(self.parent_seed)
self.vault.set_encryption_manager(new_enc_mgr)
self.vault.save_index(index_data)
self.config_manager.vault = self.vault
self.config_manager.save_config(config_data)
# Update hashed password and replace managers
self.encryption_manager = new_enc_mgr
self.entry_manager.encryption_manager = new_enc_mgr
self.password_generator.encryption_manager = new_enc_mgr
self.store_hashed_password(new_password)
@@ -1247,37 +1347,25 @@ class PasswordManager:
encryption_manager=self.encryption_manager,
fingerprint=self.current_fingerprint,
relays=relay_list,
parent_seed=getattr(self, "parent_seed", None),
)
print(colored("Master password changed successfully.", "green"))
# All data has been re-encrypted with the new password. Since no
# entries changed, avoid pushing the database to Nostr here.
# Subsequent entry modifications will trigger a push when needed.
# 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,
)
except Exception as nostr_error:
logging.error(
f"Failed to post updated index to Nostr after password change: {nostr_error}"
)
except Exception as e:
logging.error(f"Failed to change password: {e}")
logging.error(traceback.format_exc())
logging.error(f"Failed to change password: {e}", exc_info=True)
print(colored(f"Error: Failed to change password: {e}", "red"))
# Example usage (this part should be removed or commented out when integrating into the larger application)
if __name__ == "__main__":
from nostr.client import (
NostrClient,
) # Ensure this import is correct based on your project structure
# Initialize PasswordManager
manager = PasswordManager()
# Initialize NostrClient with the EncryptionManager from PasswordManager
manager.nostr_client = NostrClient(encryption_manager=manager.encryption_manager)
# Example operations
# These would typically be triggered by user interactions, e.g., via a CLI menu
# manager.handle_add_password()
# manager.handle_retrieve_entry()
# manager.handle_modify_entry()
# manager.handle_verify_checksum()
# manager.nostr_client.publish_and_subscribe("Sample password data")
# manager.backup_database()
# manager.restore_database()

View File

@@ -68,11 +68,45 @@ class PasswordGenerator:
logger.debug("PasswordGenerator initialized successfully.")
except Exception as e:
logger.error(f"Failed to initialize PasswordGenerator: {e}")
logger.error(traceback.format_exc()) # Log full traceback
logger.error(f"Failed to initialize PasswordGenerator: {e}", exc_info=True)
print(colored(f"Error: Failed to initialize PasswordGenerator: {e}", "red"))
raise
def _derive_password_entropy(self, index: int) -> bytes:
"""Derive deterministic entropy for password generation."""
entropy = self.bip85.derive_entropy(index=index, bytes_len=64, app_no=32)
logger.debug(f"Derived entropy: {entropy.hex()}")
hkdf = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=b"password-generation",
backend=default_backend(),
)
hkdf_derived = hkdf.derive(entropy)
logger.debug(f"Derived key using HKDF: {hkdf_derived.hex()}")
dk = hashlib.pbkdf2_hmac("sha256", entropy, b"", 100000)
logger.debug(f"Derived key using PBKDF2: {dk.hex()}")
return dk
def _map_entropy_to_chars(self, dk: bytes, alphabet: str) -> str:
"""Map derived bytes to characters from the provided alphabet."""
password = "".join(alphabet[byte % len(alphabet)] for byte in dk)
logger.debug(f"Password after mapping to all allowed characters: {password}")
return password
def _shuffle_deterministically(self, password: str, dk: bytes) -> str:
"""Deterministically shuffle characters using derived bytes."""
shuffle_seed = int.from_bytes(dk, "big")
rng = random.Random(shuffle_seed)
password_chars = list(password)
rng.shuffle(password_chars)
shuffled = "".join(password_chars)
logger.debug("Shuffled password deterministically.")
return shuffled
def generate_password(
self, length: int = DEFAULT_PASSWORD_LENGTH, index: int = 0
) -> str:
@@ -111,67 +145,42 @@ class PasswordGenerator:
f"Password length must not exceed {MAX_PASSWORD_LENGTH} characters."
)
# Derive entropy using BIP-85
entropy = self.bip85.derive_entropy(index=index, bytes_len=64, app_no=32)
logger.debug(f"Derived entropy: {entropy.hex()}")
dk = self._derive_password_entropy(index=index)
# Use HKDF to derive key from entropy
hkdf = HKDF(
algorithm=hashes.SHA256(),
length=32, # 256 bits for AES-256
salt=None,
info=b"password-generation",
backend=default_backend(),
)
derived_key = hkdf.derive(entropy)
logger.debug(f"Derived key using HKDF: {derived_key.hex()}")
# Use PBKDF2-HMAC-SHA256 to derive a key from entropy
dk = hashlib.pbkdf2_hmac("sha256", entropy, b"", 100000)
logger.debug(f"Derived key using PBKDF2: {dk.hex()}")
# Map the derived key to all allowed characters
all_allowed = string.ascii_letters + string.digits + string.punctuation
password = "".join(all_allowed[byte % len(all_allowed)] for byte in dk)
logger.debug(
f"Password after mapping to all allowed characters: {password}"
)
# Ensure the password meets complexity requirements
password = self.ensure_complexity(password, all_allowed, dk)
logger.debug(f"Password after ensuring complexity: {password}")
# Shuffle characters deterministically based on dk
shuffle_seed = int.from_bytes(dk, "big")
rng = random.Random(shuffle_seed)
password_chars = list(password)
rng.shuffle(password_chars)
password = "".join(password_chars)
logger.debug("Shuffled password deterministically.")
password = self._map_entropy_to_chars(dk, all_allowed)
password = self._enforce_complexity(password, all_allowed, dk)
password = self._shuffle_deterministically(password, dk)
# Ensure password length by extending if necessary
if len(password) < length:
while len(password) < length:
dk = hashlib.pbkdf2_hmac("sha256", dk, b"", 1)
base64_extra = "".join(
all_allowed[byte % len(all_allowed)] for byte in dk
)
password += "".join(base64_extra)
extra = self._map_entropy_to_chars(dk, all_allowed)
password += extra
password = self._shuffle_deterministically(password, dk)
logger.debug(f"Extended password: {password}")
# Trim the password to the desired length
# Trim the password to the desired length and enforce complexity on
# the final result. Complexity enforcement is repeated here because
# trimming may remove required character classes from the password
# produced above when the requested length is shorter than the
# initial entropy size.
password = password[:length]
logger.debug(f"Final password (trimmed to {length} chars): {password}")
password = self._enforce_complexity(password, all_allowed, dk)
password = self._shuffle_deterministically(password, dk)
logger.debug(
f"Final password (trimmed to {length} chars with complexity enforced): {password}"
)
return password
except Exception as e:
logger.error(f"Error generating password: {e}")
logger.error(traceback.format_exc()) # Log full traceback
logger.error(f"Error generating password: {e}", exc_info=True)
print(colored(f"Error: Failed to generate password: {e}", "red"))
raise
def ensure_complexity(self, password: str, alphabet: str, dk: bytes) -> str:
def _enforce_complexity(self, password: str, alphabet: str, dk: bytes) -> str:
"""
Ensures that the password contains at least two uppercase letters, two lowercase letters,
two digits, and two special characters, modifying it deterministically if necessary.
@@ -320,7 +329,6 @@ class PasswordGenerator:
return "".join(password_chars)
except Exception as e:
logger.error(f"Error ensuring password complexity: {e}")
logger.error(traceback.format_exc()) # Log full traceback
logger.error(f"Error ensuring password complexity: {e}", exc_info=True)
print(colored(f"Error: Failed to ensure password complexity: {e}", "red"))
raise

View File

@@ -0,0 +1,54 @@
"""Vault utilities for reading and writing encrypted files."""
from pathlib import Path
from typing import Optional, Union
from os import PathLike
from .encryption import EncryptionManager
class Vault:
"""Simple wrapper around :class:`EncryptionManager` for vault storage."""
INDEX_FILENAME = "seedpass_passwords_db.json.enc"
CONFIG_FILENAME = "seedpass_config.json.enc"
def __init__(
self,
encryption_manager: EncryptionManager,
fingerprint_dir: Union[str, PathLike[str], Path],
):
self.encryption_manager = encryption_manager
self.fingerprint_dir = Path(fingerprint_dir)
self.index_file = self.fingerprint_dir / self.INDEX_FILENAME
self.config_file = self.fingerprint_dir / self.CONFIG_FILENAME
def set_encryption_manager(self, manager: EncryptionManager) -> None:
"""Replace the internal encryption manager."""
self.encryption_manager = manager
# ----- Password index helpers -----
def load_index(self) -> dict:
"""Return decrypted password index data as a dict."""
return self.encryption_manager.load_json_data(self.index_file)
def save_index(self, data: dict) -> None:
"""Encrypt and write password index."""
self.encryption_manager.save_json_data(data, self.index_file)
def get_encrypted_index(self) -> Optional[bytes]:
"""Return the encrypted index bytes if present."""
return self.encryption_manager.get_encrypted_index()
def decrypt_and_save_index_from_nostr(self, encrypted_data: bytes) -> None:
"""Decrypt Nostr payload and overwrite the local index."""
self.encryption_manager.decrypt_and_save_index_from_nostr(encrypted_data)
# ----- Config helpers -----
def load_config(self) -> dict:
"""Load decrypted configuration."""
return self.encryption_manager.load_json_data(self.config_file)
def save_config(self, config: dict) -> None:
"""Encrypt and persist configuration."""
self.encryption_manager.save_json_data(config, self.config_file)

View File

@@ -10,5 +10,12 @@ bcrypt
bip85
pytest>=7.0
pytest-cov
pytest-xdist
portalocker>=2.8
nostr-sdk>=0.42.1
websocket-client==1.7.0
websockets>=15.0.0
tomli
hypothesis
mutmut==2.4.4

32
src/tests/conftest.py Normal file
View File

@@ -0,0 +1,32 @@
import logging
import pytest
@pytest.fixture(autouse=True)
def mute_logging():
logging.getLogger().setLevel(logging.WARNING)
def pytest_addoption(parser: pytest.Parser) -> None:
parser.addoption(
"--stress",
action="store_true",
default=False,
help="run stress tests",
)
def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line("markers", "stress: long running stress tests")
def pytest_collection_modifyitems(
config: pytest.Config, items: list[pytest.Item]
) -> None:
if config.getoption("--stress"):
return
skip_stress = pytest.mark.skip(reason="need --stress option to run")
for item in items:
if "stress" in item.keywords:
item.add_marker(skip_stress)

View File

@@ -0,0 +1,40 @@
import time
from types import SimpleNamespace
from pathlib import Path
import pytest
import sys
sys.path.append(str(Path(__file__).resolve().parents[1]))
import main
def test_auto_sync_triggers_post(monkeypatch):
pm = SimpleNamespace(
is_dirty=True,
last_update=time.time() - 0.2,
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=lambda: None,
lock_vault=lambda: None,
unlock_vault=lambda: None,
)
called = False
def fake_post(manager):
nonlocal called
called = True
monkeypatch.setattr(main, "handle_post_to_nostr", fake_post)
monkeypatch.setattr("builtins.input", lambda _: "5")
with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=0.1)
assert called
assert pm.is_dirty is False

View File

@@ -0,0 +1,57 @@
import os
import sys
import time
from pathlib import Path
from tempfile import TemporaryDirectory
from cryptography.fernet import Fernet
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
def test_backup_restore_workflow(monkeypatch):
with TemporaryDirectory() as tmpdir:
fp_dir = Path(tmpdir)
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, fp_dir)
vault = Vault(enc_mgr, fp_dir)
backup_mgr = BackupManager(fp_dir)
index_file = fp_dir / "seedpass_passwords_db.json.enc"
data1 = {"passwords": {"0": {"website": "a", "length": 10}}}
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"
assert backup1.exists()
assert backup1.stat().st_mode & 0o777 == 0o600
data2 = {"passwords": {"0": {"website": "b", "length": 12}}}
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"
assert backup2.exists()
assert backup2.stat().st_mode & 0o777 == 0o600
vault.save_index({"passwords": {"temp": {}}})
backup_mgr.restore_latest_backup()
assert vault.load_index() == data2
vault.save_index({"passwords": {}})
backup_mgr.restore_backup_by_timestamp(1111)
assert vault.load_index() == data1
backup1.unlink()
current = vault.load_index()
backup_mgr.restore_backup_by_timestamp(1111)
assert vault.load_index() == current

View File

@@ -0,0 +1,39 @@
import sys
from pathlib import Path
import pytest
sys.path.append(str(Path(__file__).resolve().parents[1]))
from local_bip85.bip85 import BIP85
MASTER_XPRV = "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb"
EXPECTED_12 = "girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose"
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"
@pytest.fixture(scope="module")
def bip85():
return BIP85(MASTER_XPRV)
def test_bip85_mnemonic_12(bip85):
assert bip85.derive_mnemonic(index=0, words_num=12) == EXPECTED_12
def test_bip85_mnemonic_24(bip85):
assert bip85.derive_mnemonic(index=0, words_num=24) == EXPECTED_24
def test_bip85_symmetric_key(bip85):
assert bip85.derive_symmetric_key(index=0).hex() == EXPECTED_SYMM_KEY
def test_invalid_params(bip85):
with pytest.raises(SystemExit):
bip85.derive_mnemonic(index=0, words_num=15)
with pytest.raises(SystemExit):
bip85.derive_mnemonic(index=-1, words_num=12)

View File

@@ -0,0 +1,38 @@
import hashlib
from pathlib import Path
from utils import checksum
def test_calculate_checksum(tmp_path):
file = tmp_path / "data.txt"
content = "hello world"
file.write_text(content)
expected = hashlib.sha256(content.encode()).hexdigest()
result = checksum.calculate_checksum(str(file))
assert result == expected
def test_calculate_checksum_missing(tmp_path):
missing = tmp_path / "missing.txt"
assert checksum.calculate_checksum(str(missing)) is None
def test_verify_and_update(tmp_path):
chk_file = tmp_path / "chk.txt"
chk_file.write_text("abc")
assert checksum.verify_checksum("abc", str(chk_file))
assert not checksum.verify_checksum("def", str(chk_file))
assert checksum.update_checksum("payload", str(chk_file))
expected = hashlib.sha256("payload".encode()).hexdigest()
assert chk_file.read_text() == expected
def test_initialize_checksum(tmp_path):
data = tmp_path / "file.bin"
data.write_text("payload")
chk_file = tmp_path / "chk2.txt"
assert checksum.initialize_checksum(str(data), str(chk_file))
expected = hashlib.sha256("payload".encode()).hexdigest()
assert chk_file.read_text() == expected

View File

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

View File

@@ -0,0 +1,100 @@
import sys
import time
from types import SimpleNamespace
from pathlib import Path
import pytest
sys.path.append(str(Path(__file__).resolve().parents[1]))
import main
def _make_pm(called, locked=None):
if locked is None:
locked = {"lock": 0, "unlock": 0}
def add():
called["add"] = True
def retrieve():
called["retrieve"] = True
def modify():
called["modify"] = True
def update():
pm.last_activity = time.time()
def lock():
locked["lock"] += 1
def unlock():
locked["unlock"] += 1
update()
pm = SimpleNamespace(
is_dirty=False,
last_update=time.time(),
last_activity=time.time(),
nostr_client=SimpleNamespace(close_client_pool=lambda: None),
handle_add_password=add,
handle_retrieve_entry=retrieve,
handle_modify_entry=modify,
update_activity=update,
lock_vault=lock,
unlock_vault=unlock,
)
return pm, locked
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))
with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000)
out = capsys.readouterr().out
assert "No input detected" in out
assert "Invalid choice. Please select a valid option." in out
assert not any(called.values())
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))
with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000)
out = capsys.readouterr().out
assert "Invalid choice. Please select a valid option." in out
assert not any(called.values())
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("builtins.input", lambda *_: next(inputs))
with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000)
out = capsys.readouterr().out
assert "Invalid choice." in out
assert not any(called.values())
def test_inactivity_timeout_loop(monkeypatch, capsys):
called = {"add": False, "retrieve": False, "modify": False}
pm, locked = _make_pm(called)
pm.last_activity = 0
monkeypatch.setattr(time, "time", lambda: 100.0)
monkeypatch.setattr("builtins.input", lambda *_: "5")
with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1)
out = capsys.readouterr().out
assert "Session timed out. Vault locked." in out
assert locked["lock"] == 1
assert locked["unlock"] == 1
assert not any(called.values())

View File

@@ -0,0 +1,73 @@
import sys
from pathlib import Path
from multiprocessing import Process, Queue
from cryptography.fernet import Fernet
import pytest
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
def _writer(key: bytes, dir_path: Path, loops: int, out: Queue) -> None:
try:
enc = EncryptionManager(key, dir_path)
vault = Vault(enc, dir_path)
for _ in range(loops):
data = vault.load_index()
data["counter"] = data.get("counter", 0) + 1
vault.save_index(data)
except Exception as e: # pragma: no cover - capture for assertion
out.put(repr(e))
def _reader(key: bytes, dir_path: Path, loops: int, out: Queue) -> None:
try:
enc = EncryptionManager(key, dir_path)
vault = Vault(enc, dir_path)
for _ in range(loops):
vault.load_index()
except Exception as e: # pragma: no cover - capture
out.put(repr(e))
def _backup(dir_path: Path, loops: int, out: Queue) -> None:
try:
bm = BackupManager(dir_path)
for _ in range(loops):
bm.create_backup()
except Exception as e: # pragma: no cover - capture
out.put(repr(e))
@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, _):
key = Fernet.generate_key()
enc = EncryptionManager(key, tmp_path)
Vault(enc, tmp_path).save_index({"counter": 0})
q: Queue = Queue()
procs = [
Process(target=_writer, args=(key, tmp_path, loops, q)),
Process(target=_writer, args=(key, tmp_path, loops, q)),
Process(target=_reader, args=(key, tmp_path, loops, q)),
Process(target=_reader, args=(key, tmp_path, loops, q)),
Process(target=_backup, args=(tmp_path, loops, q)),
]
for p in procs:
p.start()
for p in procs:
p.join()
errors = []
while not q.empty():
errors.append(q.get())
assert not errors
vault = Vault(EncryptionManager(key, tmp_path), tmp_path)
assert isinstance(vault.load_index(), dict)

View File

@@ -9,6 +9,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.encryption import EncryptionManager
from password_manager.config_manager import ConfigManager
from password_manager.vault import Vault
from nostr.client import DEFAULT_RELAYS
@@ -16,11 +17,13 @@ def test_config_defaults_and_round_trip():
with TemporaryDirectory() as tmpdir:
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, Path(tmpdir))
cfg_mgr = ConfigManager(enc_mgr, Path(tmpdir))
vault = Vault(enc_mgr, Path(tmpdir))
cfg_mgr = ConfigManager(vault, Path(tmpdir))
cfg = cfg_mgr.load_config(require_pin=False)
assert cfg["relays"] == list(DEFAULT_RELAYS)
assert cfg["pin_hash"] == ""
assert cfg["password_hash"] == ""
cfg_mgr.set_pin("1234")
cfg_mgr.set_relays(["wss://example.com"], require_pin=False)
@@ -34,7 +37,8 @@ def test_pin_verification_and_change():
with TemporaryDirectory() as tmpdir:
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, Path(tmpdir))
cfg_mgr = ConfigManager(enc_mgr, Path(tmpdir))
vault = Vault(enc_mgr, Path(tmpdir))
cfg_mgr = ConfigManager(vault, Path(tmpdir))
cfg_mgr.set_pin("1234")
assert cfg_mgr.verify_pin("1234")
@@ -50,7 +54,8 @@ def test_config_file_encrypted_after_save():
with TemporaryDirectory() as tmpdir:
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, Path(tmpdir))
cfg_mgr = ConfigManager(enc_mgr, Path(tmpdir))
vault = Vault(enc_mgr, Path(tmpdir))
cfg_mgr = ConfigManager(vault, Path(tmpdir))
data = {"relays": ["wss://r"], "pin_hash": ""}
cfg_mgr.save_config(data)
@@ -60,14 +65,17 @@ def test_config_file_encrypted_after_save():
assert raw != json.dumps(data).encode()
loaded = cfg_mgr.load_config(require_pin=False)
assert loaded == data
assert loaded["relays"] == data["relays"]
assert loaded["pin_hash"] == data["pin_hash"]
assert loaded["password_hash"] == ""
def test_set_relays_persists_changes():
with TemporaryDirectory() as tmpdir:
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, Path(tmpdir))
cfg_mgr = ConfigManager(enc_mgr, Path(tmpdir))
vault = Vault(enc_mgr, Path(tmpdir))
cfg_mgr = ConfigManager(vault, Path(tmpdir))
cfg_mgr.set_relays(["wss://custom"], require_pin=False)
cfg = cfg_mgr.load_config(require_pin=False)
assert cfg["relays"] == ["wss://custom"]
@@ -77,6 +85,28 @@ def test_set_relays_requires_at_least_one():
with TemporaryDirectory() as tmpdir:
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, Path(tmpdir))
cfg_mgr = ConfigManager(enc_mgr, Path(tmpdir))
vault = Vault(enc_mgr, Path(tmpdir))
cfg_mgr = ConfigManager(vault, Path(tmpdir))
with pytest.raises(ValueError):
cfg_mgr.set_relays([], require_pin=False)
def test_password_hash_migrates_from_file(tmp_path):
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, tmp_path)
vault = Vault(enc_mgr, tmp_path)
cfg_mgr = ConfigManager(vault, tmp_path)
# save legacy config without password_hash
legacy_cfg = {"relays": ["wss://r"], "pin_hash": ""}
cfg_mgr.save_config(legacy_cfg)
hashed = bcrypt.hashpw(b"pw", bcrypt.gensalt())
(tmp_path / "hashed_password.enc").write_bytes(hashed)
cfg = cfg_mgr.load_config(require_pin=False)
assert cfg["password_hash"] == hashed.decode()
# subsequent loads should read from config
(tmp_path / "hashed_password.enc").unlink()
cfg2 = cfg_mgr.load_config(require_pin=False)
assert cfg2["password_hash"] == hashed.decode()

View File

@@ -0,0 +1,34 @@
import re
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from cryptography.fernet import Fernet
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.encryption import EncryptionManager
from utils.checksum import verify_and_update_checksum
def test_encryption_checksum_workflow():
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
key = Fernet.generate_key()
manager = EncryptionManager(key, tmp_path)
data = {"value": 1}
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"
checksum = chk_file.read_text().strip()
assert re.fullmatch(r"[0-9a-f]{64}", checksum)
manager.save_json_data({"value": 2})
assert not verify_and_update_checksum(str(enc_file), str(chk_file))
manager.update_checksum()
assert verify_and_update_checksum(str(enc_file), str(chk_file))

View File

@@ -7,13 +7,15 @@ 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
def test_list_entries_empty():
with TemporaryDirectory() as tmpdir:
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, Path(tmpdir))
entry_mgr = EntryManager(enc_mgr, Path(tmpdir))
vault = Vault(enc_mgr, Path(tmpdir))
entry_mgr = EntryManager(vault, Path(tmpdir))
entries = entry_mgr.list_entries()
assert entries == []

View File

@@ -7,13 +7,15 @@ 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
def test_add_and_retrieve_entry():
with TemporaryDirectory() as tmpdir:
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, Path(tmpdir))
entry_mgr = EntryManager(enc_mgr, Path(tmpdir))
vault = Vault(enc_mgr, Path(tmpdir))
entry_mgr = EntryManager(vault, Path(tmpdir))
index = entry_mgr.add_entry("example.com", 12, "user")
entry = entry_mgr.retrieve_entry(index)

View File

@@ -0,0 +1,41 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from cryptography.fernet import Fernet
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.encryption import EncryptionManager
from password_manager.vault import Vault
from password_manager.entry_management import EntryManager
def test_update_checksum_writes_to_expected_path():
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, tmp_path)
vault = Vault(enc_mgr, tmp_path)
entry_mgr = EntryManager(vault, tmp_path)
# create an empty index file
vault.save_index({"passwords": {}})
entry_mgr.update_checksum()
expected = tmp_path / "seedpass_passwords_db_checksum.txt"
assert expected.exists()
def test_backup_index_file_creates_backup_in_directory():
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, tmp_path)
vault = Vault(enc_mgr, tmp_path)
entry_mgr = EntryManager(vault, tmp_path)
vault.save_index({"passwords": {}})
entry_mgr.backup_index_file()
backups = list(tmp_path.glob("passwords_db_backup_*.json.enc"))
assert len(backups) == 1

View File

@@ -19,14 +19,22 @@ def _try_lock(path: Path, wait_time: mp.Value):
wait_time.value = time.perf_counter() - t0
def test_exclusive_lock_blocks_until_released(tmp_path: Path):
def test_exclusive_lock_blocks_until_released(tmp_path: Path) -> None:
file_path = tmp_path / "locktest.txt"
started = mp.Event()
wait_time = mp.Value("d", 0.0)
# Use 'fork' start method when available for more deterministic timing on
# platforms like macOS where the default 'spawn' method can delay process
# startup significantly.
if "fork" in mp.get_all_start_methods():
ctx = mp.get_context("fork")
else:
ctx = mp.get_context()
p1 = mp.Process(target=_hold_lock, args=(file_path, 1.0, started))
p2 = mp.Process(target=_try_lock, args=(file_path, wait_time))
started = ctx.Event()
wait_time = ctx.Value("d", 0.0)
p1 = ctx.Process(target=_hold_lock, args=(file_path, 1.0, started))
p2 = ctx.Process(target=_try_lock, args=(file_path, wait_time))
p1.start()
started.wait()

View File

@@ -0,0 +1,20 @@
from utils.fingerprint_manager import FingerprintManager
def test_add_and_remove_fingerprint(tmp_path):
mgr = FingerprintManager(tmp_path)
phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
fp = mgr.add_fingerprint(phrase)
assert fp in mgr.list_fingerprints()
dir_path = mgr.get_fingerprint_directory(fp)
assert dir_path and dir_path.exists()
assert mgr.select_fingerprint(fp)
assert mgr.get_current_fingerprint_dir() == dir_path
assert mgr.remove_fingerprint(fp)
assert fp not in mgr.list_fingerprints()
assert not dir_path.exists()
def test_remove_nonexistent_fingerprint(tmp_path):
mgr = FingerprintManager(tmp_path)
assert not mgr.remove_fingerprint("UNKNOWN")

View File

@@ -0,0 +1,45 @@
import time
from types import SimpleNamespace
from pathlib import Path
import pytest
import sys
sys.path.append(str(Path(__file__).resolve().parents[1]))
import main
def test_inactivity_triggers_lock(monkeypatch):
locked = {"locked": False, "unlocked": False}
def update_activity():
pm.last_activity = time.time()
def lock_vault():
locked["locked"] = True
def unlock_vault():
locked["unlocked"] = True
update_activity()
pm = SimpleNamespace(
is_dirty=False,
last_update=time.time(),
last_activity=time.time() - 1.0,
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,
)
monkeypatch.setattr("builtins.input", lambda _: "5")
with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1)
assert locked["locked"]
assert locked["unlocked"]

View File

@@ -1,6 +1,12 @@
import logging
import pytest
from utils.key_derivation import derive_key_from_password
from utils.key_derivation import (
derive_key_from_password,
derive_index_key_seed_only,
derive_index_key_seed_plus_pw,
derive_index_key,
EncryptionMode,
)
def test_derive_key_deterministic():
@@ -16,3 +22,33 @@ def test_derive_key_empty_password_error():
with pytest.raises(ValueError):
derive_key_from_password("")
logging.info("Empty password correctly raised ValueError")
def test_seed_only_key_deterministic():
seed = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
k1 = derive_index_key_seed_only(seed)
k2 = derive_index_key_seed_only(seed)
assert k1 == k2
assert len(k1) == 44
def test_seed_plus_pw_differs_from_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)

View File

@@ -0,0 +1,58 @@
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.manager import PasswordManager
class FakeBackupManager:
def __init__(self, calls):
self.calls = calls
def create_backup(self):
self.calls["create"] += 1
def restore_latest_backup(self):
self.calls["restore"] += 1
def _make_pm():
pm = PasswordManager.__new__(PasswordManager)
return pm
def test_handle_verify_checksum_success(monkeypatch, tmp_path, capsys):
pm = _make_pm()
chk_file = tmp_path / "chk.txt"
chk_file.write_text("abc")
monkeypatch.setattr("password_manager.manager.SCRIPT_CHECKSUM_FILE", chk_file)
monkeypatch.setattr("password_manager.manager.calculate_checksum", lambda _: "abc")
pm.handle_verify_checksum()
out = capsys.readouterr().out
assert "Checksum verification passed." in out
def test_handle_verify_checksum_failure(monkeypatch, tmp_path, capsys):
pm = _make_pm()
chk_file = tmp_path / "chk.txt"
chk_file.write_text("xyz")
monkeypatch.setattr("password_manager.manager.SCRIPT_CHECKSUM_FILE", chk_file)
monkeypatch.setattr("password_manager.manager.calculate_checksum", lambda _: "abc")
pm.handle_verify_checksum()
out = capsys.readouterr().out
assert "Checksum verification failed" in out
def test_backup_and_restore_database(monkeypatch, capsys):
pm = _make_pm()
calls = {"create": 0, "restore": 0}
pm.backup_manager = FakeBackupManager(calls)
pm.backup_database()
out1 = capsys.readouterr().out
pm.restore_database()
out2 = capsys.readouterr().out
assert calls["create"] == 1
assert calls["restore"] == 1
assert "Backup created successfully." in out1
assert "Database restored from the latest backup successfully." in out2

View File

@@ -0,0 +1,87 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
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 password_manager.backup import BackupManager
from password_manager.manager import PasswordManager
class FakePasswordGenerator:
def generate_password(self, length: int, index: int) -> str: # noqa: D401
return f"pw-{index}-{length}"
class FakeNostrClient:
def __init__(self, *args, **kwargs):
self.published = []
def publish_json_to_nostr(self, data: bytes):
self.published.append(data)
return True
def test_manager_workflow(monkeypatch):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, tmp_path)
vault = Vault(enc_mgr, tmp_path)
entry_mgr = EntryManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path)
monkeypatch.setattr("password_manager.manager.NostrClient", FakeNostrClient)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_manager = enc_mgr
pm.vault = vault
pm.entry_manager = entry_mgr
pm.backup_manager = backup_mgr
pm.password_generator = FakePasswordGenerator()
pm.nostr_client = FakeNostrClient()
pm.fingerprint_dir = tmp_path
pm.is_dirty = False
inputs = iter(
[
"example.com",
"", # username
"", # url
"", # length (default)
"0", # retrieve index
"0", # modify index
"user", # new username
"", # new url
"", # blacklist status
]
)
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 len(backups) == 1
checksum_file = tmp_path / "seedpass_passwords_db_checksum.txt"
assert checksum_file.exists()
checksum_after_add = checksum_file.read_text()
first_post = pm.nostr_client.published[-1]
pm.is_dirty = False
pm.handle_retrieve_entry()
assert pm.is_dirty is False
pm.handle_modify_entry()
assert pm.is_dirty is True
pm.backup_manager.create_backup()
backup_dir = tmp_path / "backups"
backups_mod = list(backup_dir.glob("passwords_db_backup_*.json.enc"))
assert backups_mod
checksum_after_modify = checksum_file.read_text()
assert checksum_after_modify != checksum_after_add
assert first_post in pm.nostr_client.published
assert pm.nostr_client.published[-1] != first_post

View File

@@ -8,6 +8,7 @@ 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
@@ -16,7 +17,8 @@ def test_backup_and_publish_to_nostr():
tmp_path = Path(tmpdir)
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, tmp_path)
entry_mgr = EntryManager(enc_mgr, tmp_path)
vault = Vault(enc_mgr, tmp_path)
entry_mgr = EntryManager(vault, tmp_path)
# create an index by adding an entry
entry_mgr.add_entry("example.com", 12)
@@ -24,8 +26,8 @@ def test_backup_and_publish_to_nostr():
assert encrypted_index is not None
with patch(
"nostr.client.NostrClient.publish_json_to_nostr"
) as mock_publish, patch("nostr.client.ClientPool"), patch(
"nostr.client.NostrClient.publish_json_to_nostr", return_value=True
) as mock_publish, patch("nostr.client.ClientBuilder"), patch(
"nostr.client.KeyManager"
), patch.object(
NostrClient, "initialize_client_pool"
@@ -34,6 +36,7 @@ def test_backup_and_publish_to_nostr():
):
nostr_client = NostrClient(enc_mgr, "fp")
entry_mgr.backup_index_file()
nostr_client.publish_json_to_nostr(encrypted_index)
result = nostr_client.publish_json_to_nostr(encrypted_index)
mock_publish.assert_called_with(encrypted_index)
assert result is True

View File

@@ -16,11 +16,62 @@ def test_nostr_client_uses_custom_relays():
enc_mgr = EncryptionManager(key, Path(tmpdir))
custom_relays = ["wss://relay1", "wss://relay2"]
with patch("nostr.client.ClientPool") as MockPool, patch(
with patch("nostr.client.ClientBuilder") as MockBuilder, patch(
"nostr.client.KeyManager"
), patch.object(NostrClient, "initialize_client_pool"):
mock_builder = MockBuilder.return_value
with patch.object(enc_mgr, "decrypt_parent_seed", return_value="seed"):
client = NostrClient(enc_mgr, "fp", relays=custom_relays)
MockPool.assert_called_with(custom_relays)
assert client.relays == custom_relays
class FakeAddRelaysClient:
def __init__(self, _signer):
self.added = []
self.connected = False
async def add_relays(self, relays):
self.added.append(relays)
async def connect(self):
self.connected = True
class FakeAddRelayClient:
def __init__(self, _signer):
self.added = []
self.connected = False
async def add_relay(self, relay):
self.added.append(relay)
async def connect(self):
self.connected = True
def _setup_client(tmpdir, fake_cls):
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, Path(tmpdir))
with patch("nostr.client.Client", fake_cls), patch(
"nostr.client.KeyManager"
) as MockKM, patch.object(enc_mgr, "decrypt_parent_seed", return_value="seed"):
km_inst = MockKM.return_value
km_inst.keys.private_key_hex.return_value = "1" * 64
client = NostrClient(enc_mgr, "fp")
return client
def test_initialize_client_pool_add_relays_used(tmp_path):
client = _setup_client(tmp_path, FakeAddRelaysClient)
fc = client.client
assert fc.added == [client.relays]
assert fc.connected is True
def test_initialize_client_pool_add_relay_fallback(tmp_path):
client = _setup_client(tmp_path, FakeAddRelayClient)
fc = client.client
assert fc.added == client.relays
assert fc.connected is True

View File

@@ -0,0 +1,76 @@
import sys
from pathlib import Path
from unittest.mock import patch
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
class MockNostrServer:
def __init__(self):
self.events = []
class MockClient:
def __init__(self, server):
self.server = server
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.server.events.append(event)
class FakeId:
def to_hex(self_inner):
return "abcd"
class FakeOutput:
def __init__(self):
self.id = FakeId()
return FakeOutput()
async def fetch_events(self, filter_obj, timeout):
class FakeEvents:
def __init__(self, events):
self._events = events
def to_vec(self):
return self._events
return FakeEvents(self.server.events[-1:])
def setup_client(tmp_path, server):
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, tmp_path)
with patch("nostr.client.Client", lambda signer: MockClient(server)), patch(
"nostr.client.KeyManager"
) as MockKM, patch.object(enc_mgr, "decrypt_parent_seed", return_value="seed"):
km_inst = MockKM.return_value
km_inst.keys.private_key_hex.return_value = "1" * 64
km_inst.keys.public_key_hex.return_value = "2" * 64
client = NostrClient(enc_mgr, "fp", relays=["ws://mock"])
return client
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

View File

@@ -0,0 +1,37 @@
import os
import sys
import time
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
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
@pytest.mark.network
@pytest.mark.skipif(not os.getenv("NOSTR_E2E"), reason="NOSTR_E2E not set")
def test_nostr_publish_and_retrieve():
seed = (
"abandon abandon abandon abandon abandon abandon abandon "
"abandon abandon abandon abandon about"
)
with TemporaryDirectory() as tmpdir:
enc_mgr = EncryptionManager(Fernet.generate_key(), Path(tmpdir))
with patch.object(enc_mgr, "decrypt_parent_seed", return_value=seed):
client = NostrClient(
enc_mgr,
"test_fp_real",
relays=["wss://relay.snort.social"],
)
payload = b"seedpass"
assert client.publish_json_to_nostr(payload) is True
time.sleep(2)
retrieved = client.retrieve_json_from_nostr_sync()
client.close_client_pool()
assert retrieved == payload

View File

@@ -0,0 +1,72 @@
import asyncio
import json
import threading
import time
from websocket import create_connection
import asyncio
import websockets
from nostr.key_manager import KeyManager
from nostr_sdk import nostr_sdk as sdk
class FakeRelay:
def __init__(self):
self.events = []
async def handler(self, ws):
async for message in ws:
data = json.loads(message)
if data[0] == "EVENT":
event = data[1]
self.events.append(event)
await ws.send(json.dumps(["OK", event["id"], True, ""]))
elif data[0] == "REQ":
sub_id = data[1]
for event in self.events:
await ws.send(json.dumps(["EVENT", sub_id, event]))
await ws.send(json.dumps(["EOSE", sub_id]))
def run_relay(relay, host="localhost", port=8765):
async def main():
async with websockets.serve(relay.handler, host, port):
await asyncio.Future()
asyncio.run(main())
def test_nostr_sdk_send_receive(tmp_path):
relay = FakeRelay()
thread = threading.Thread(target=run_relay, args=(relay,), daemon=True)
thread.start()
time.sleep(0.5)
seed = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
fingerprint = "test"
km = KeyManager(seed, fingerprint)
ws = create_connection("ws://localhost:8765")
keys = sdk.Keys.parse(km.get_private_key_hex())
event = (
sdk.EventBuilder.text_note("hello")
.build(keys.public_key())
.sign_with_keys(keys)
)
ws.send(json.dumps(["EVENT", json.loads(event.as_json())]))
sub_id = "1"
ws.send(json.dumps(["REQ", sub_id, {}]))
received = None
while True:
msg = json.loads(ws.recv())
if msg[0] == "EVENT":
received = msg[2]
elif msg[0] == "EOSE":
break
ws.close()
assert received is not None
assert received["content"] == "hello"

View File

@@ -0,0 +1,73 @@
import builtins
import sys
from pathlib import Path
from types import SimpleNamespace
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.manager import PasswordManager
from constants import DEFAULT_SEED_BACKUP_FILENAME
def _make_pm(tmp_path: Path) -> PasswordManager:
pm = PasswordManager.__new__(PasswordManager)
pm.parent_seed = "seed phrase"
pm.fingerprint_dir = tmp_path
pm.encryption_manager = SimpleNamespace(encrypt_and_save_file=lambda *a, **k: None)
pm.verify_password = lambda pw: True
return pm
def test_handle_backup_reveal_parent_seed_confirm(monkeypatch, tmp_path, capsys):
pm = _make_pm(tmp_path)
monkeypatch.setattr(
"password_manager.manager.prompt_existing_password", lambda *_: "pw"
)
confirms = iter([True, True])
monkeypatch.setattr(
"password_manager.manager.confirm_action", lambda *_a, **_k: next(confirms)
)
saved = []
def fake_save(data, path):
saved.append((data, path))
pm.encryption_manager = SimpleNamespace(encrypt_and_save_file=fake_save)
monkeypatch.setattr(builtins, "input", lambda *_: "mybackup.enc")
pm.handle_backup_reveal_parent_seed()
out = capsys.readouterr().out
assert "seed phrase" in out
assert saved
assert saved[0][1] == tmp_path / "mybackup.enc"
def test_handle_backup_reveal_parent_seed_cancel(monkeypatch, tmp_path, capsys):
pm = _make_pm(tmp_path)
monkeypatch.setattr(
"password_manager.manager.prompt_existing_password", lambda *_: "pw"
)
monkeypatch.setattr(
"password_manager.manager.confirm_action", lambda *_a, **_k: False
)
saved = []
pm.encryption_manager = SimpleNamespace(
encrypt_and_save_file=lambda data, path: saved.append((data, path))
)
pm.handle_backup_reveal_parent_seed()
out = capsys.readouterr().out
assert "seed phrase" not in out
assert not saved
def test_is_valid_filename(tmp_path):
pm = _make_pm(tmp_path)
invalid = ["../bad", "", "bad/name", "bad\\name", "..", "/absolute"]
for name in invalid:
assert not pm.is_valid_filename(name)
assert pm.is_valid_filename("good.enc")

View File

@@ -11,20 +11,23 @@ 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.config_manager import ConfigManager
from password_manager.vault import Vault
from password_manager.manager import PasswordManager
def test_change_password_does_not_trigger_nostr_backup(monkeypatch):
def test_change_password_triggers_nostr_backup(monkeypatch):
with TemporaryDirectory() as tmpdir:
fp = Path(tmpdir)
enc_mgr = EncryptionManager(Fernet.generate_key(), fp)
entry_mgr = EntryManager(enc_mgr, fp)
cfg_mgr = ConfigManager(enc_mgr, fp)
vault = Vault(enc_mgr, fp)
entry_mgr = EntryManager(vault, fp)
cfg_mgr = ConfigManager(vault, fp)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_manager = enc_mgr
pm.entry_manager = entry_mgr
pm.config_manager = cfg_mgr
pm.vault = vault
pm.password_generator = SimpleNamespace(encryption_manager=enc_mgr)
pm.fingerprint_dir = fp
pm.current_fingerprint = "fp"
@@ -43,4 +46,4 @@ def test_change_password_does_not_trigger_nostr_backup(monkeypatch):
mock_instance = MockClient.return_value
pm.nostr_client = mock_instance
pm.change_password()
mock_instance.publish_json_to_nostr.assert_not_called()
mock_instance.publish_json_to_nostr.assert_called_once()

View File

@@ -0,0 +1,55 @@
import string
from password_manager.password_generation import PasswordGenerator
class DummyEnc:
def derive_seed_from_mnemonic(self, mnemonic):
return b"\x00" * 32
class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len))
def make_generator():
pg = PasswordGenerator.__new__(PasswordGenerator)
pg.encryption_manager = DummyEnc()
pg.bip85 = DummyBIP85()
return pg
def test_derive_password_entropy_length():
pg = make_generator()
dk = pg._derive_password_entropy(index=1)
assert isinstance(dk, bytes)
assert len(dk) == 32
dk2 = pg._derive_password_entropy(index=2)
assert dk != dk2
def test_map_entropy_to_chars_only_uses_alphabet():
pg = make_generator()
alphabet = string.ascii_letters + string.digits
mapped = pg._map_entropy_to_chars(b"\x00\x01\x02", alphabet)
assert all(c in alphabet for c in mapped)
assert len(mapped) == 3
def test_enforce_complexity_minimum_counts():
pg = make_generator()
alphabet = string.ascii_letters + string.digits + string.punctuation
dk = bytes(range(32))
result = pg._enforce_complexity("a" * 32, alphabet, dk)
assert sum(1 for c in result if c.isupper()) >= 2
assert sum(1 for c in result if c.islower()) >= 2
assert sum(1 for c in result if c.isdigit()) >= 2
assert sum(1 for c in result if c in string.punctuation) >= 2
def test_shuffle_deterministically_repeatable():
pg = make_generator()
dk = bytes(range(32))
pw1 = pg._shuffle_deterministically("abcdef", dk)
pw2 = pg._shuffle_deterministically("abcdef", dk)
assert pw1 == pw2

View File

@@ -0,0 +1,31 @@
import pytest
from pathlib import Path
import sys
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.password_generation import PasswordGenerator
from constants import MIN_PASSWORD_LENGTH
class DummyEnc:
def derive_seed_from_mnemonic(self, mnemonic):
return b"\x00" * 32
class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len))
def make_generator():
pg = PasswordGenerator.__new__(PasswordGenerator)
pg.encryption_manager = DummyEnc()
pg.bip85 = DummyBIP85()
return pg
def test_generate_password_too_short_raises():
pg = make_generator()
with pytest.raises(ValueError):
pg.generate_password(length=MIN_PASSWORD_LENGTH - 1)

View File

@@ -0,0 +1,37 @@
import builtins
from itertools import cycle
import pytest
import logging
from utils import password_prompt
def test_prompt_new_password(monkeypatch):
responses = cycle(["goodpass", "goodpass"])
monkeypatch.setattr(
password_prompt.getpass, "getpass", lambda prompt: next(responses)
)
result = password_prompt.prompt_new_password()
assert result == "goodpass"
def test_prompt_new_password_retry(monkeypatch, caplog):
seq = iter(["pass1", "pass2", "passgood", "passgood"])
monkeypatch.setattr(password_prompt.getpass, "getpass", lambda prompt: next(seq))
caplog.set_level(logging.WARNING)
result = password_prompt.prompt_new_password()
assert "User entered a password shorter" in caplog.text
assert result == "passgood"
def test_prompt_existing_password(monkeypatch):
monkeypatch.setattr(password_prompt.getpass, "getpass", lambda prompt: "mypassword")
assert password_prompt.prompt_existing_password() == "mypassword"
def test_confirm_action_yes_no(monkeypatch):
monkeypatch.setattr(builtins, "input", lambda _: "Y")
assert password_prompt.confirm_action()
monkeypatch.setattr(builtins, "input", lambda _: "n")
assert not password_prompt.confirm_action()

View File

@@ -0,0 +1,44 @@
import sys
import string
from pathlib import Path
from hypothesis import given, strategies as st
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.password_generation import PasswordGenerator
class DummyEnc:
def derive_seed_from_mnemonic(self, mnemonic):
return b"\x00" * 32
class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len))
def make_generator():
pg = PasswordGenerator.__new__(PasswordGenerator)
pg.encryption_manager = DummyEnc()
pg.bip85 = DummyBIP85()
return pg
@given(
length=st.integers(min_value=8, max_value=64),
index=st.integers(min_value=0, max_value=1000),
)
def test_password_properties(length, index):
pg = make_generator()
pw1 = pg.generate_password(length=length, index=index)
pw2 = pg.generate_password(length=length, index=index)
assert pw1 == pw2
assert len(pw1) == length
assert sum(c.isupper() for c in pw1) >= 2
assert sum(c.islower() for c in pw1) >= 2
assert sum(c.isdigit() for c in pw1) >= 2
assert sum(c in string.punctuation for c in pw1) >= 2
assert not any(c.isspace() for c in pw1)

View File

@@ -0,0 +1,85 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
import bcrypt
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.encryption import EncryptionManager
from password_manager.vault import Vault
from password_manager.entry_management import EntryManager
from password_manager.config_manager import ConfigManager
from password_manager.manager import PasswordManager, 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"
def test_password_change_and_unlock(monkeypatch):
with TemporaryDirectory() as tmpdir:
fp = Path(tmpdir)
old_pw = "oldpw"
new_pw = "newpw"
# initial encryption setup
index_key = derive_index_key(SEED, old_pw, EncryptionMode.SEED_PLUS_PW)
seed_key = derive_key_from_password(old_pw)
enc_mgr = EncryptionManager(index_key, fp)
seed_mgr = EncryptionManager(seed_key, fp)
vault = Vault(enc_mgr, fp)
entry_mgr = EntryManager(vault, fp)
cfg_mgr = ConfigManager(vault, fp)
vault.save_index({"passwords": {}})
cfg_mgr.save_config(
{
"relays": [],
"pin_hash": "",
"password_hash": bcrypt.hashpw(
old_pw.encode(), bcrypt.gensalt()
).decode(),
}
)
seed_mgr.encrypt_parent_seed(SEED)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_PLUS_PW
pm.encryption_manager = enc_mgr
pm.entry_manager = entry_mgr
pm.config_manager = cfg_mgr
pm.vault = vault
pm.password_generator = SimpleNamespace(encryption_manager=enc_mgr)
pm.fingerprint_dir = fp
pm.current_fingerprint = "fp"
pm.parent_seed = SEED
pm.nostr_client = SimpleNamespace(publish_json_to_nostr=lambda *a, **k: None)
monkeypatch.setattr(
"password_manager.manager.prompt_existing_password", lambda *_: old_pw
)
monkeypatch.setattr(
"password_manager.manager.prompt_for_password", lambda: new_pw
)
monkeypatch.setattr(
"password_manager.manager.NostrClient",
lambda *a, **kw: SimpleNamespace(
publish_json_to_nostr=lambda *a, **k: None
),
)
pm.change_password()
pm.lock_vault()
monkeypatch.setattr(
"password_manager.manager.prompt_existing_password", lambda *_: new_pw
)
monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None)
monkeypatch.setattr(PasswordManager, "initialize_managers", lambda self: None)
pm.unlock_vault()
assert pm.parent_seed == SEED
assert pm.verify_password(new_pw)
assert not pm.locked

View File

@@ -0,0 +1,31 @@
import sys
from types import SimpleNamespace
from pathlib import Path
sys.path.append(str(Path(__file__).resolve().parents[1]))
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
),
)
main.handle_post_to_nostr(pm)
out = capsys.readouterr().out
assert "✅ Sync complete." 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
),
)
main.handle_post_to_nostr(pm)
out = capsys.readouterr().out
assert "❌ Sync failed…" in out

View File

@@ -0,0 +1,77 @@
import sys
import importlib
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from cryptography.fernet import Fernet
sys.path.append(str(Path(__file__).resolve().parents[1]))
from utils.fingerprint_manager import FingerprintManager
import constants
import password_manager.manager as manager_module
from password_manager.encryption import EncryptionManager
from password_manager.vault import Vault
from password_manager.entry_management import EntryManager
def test_add_and_delete_entry(monkeypatch):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
importlib.reload(constants)
importlib.reload(manager_module)
pm = manager_module.PasswordManager.__new__(manager_module.PasswordManager)
pm.fingerprint_manager = FingerprintManager(constants.APP_DIR)
pm.current_fingerprint = None
pm.save_and_encrypt_seed = lambda seed, fingerprint_dir: None
pm.initialize_bip85 = lambda: None
pm.initialize_managers = lambda: None
pm.sync_index_from_nostr_if_missing = lambda: None
seed = "abandon " * 11 + "about"
monkeypatch.setattr(
manager_module.PasswordManager, "generate_bip85_seed", lambda self: seed
)
monkeypatch.setattr(manager_module, "confirm_action", lambda *_a, **_k: True)
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "2")
pm.add_new_fingerprint()
fingerprint = pm.current_fingerprint
fingerprint_dir = constants.APP_DIR / fingerprint
pm.fingerprint_dir = fingerprint_dir
assert fingerprint_dir.exists()
assert pm.fingerprint_manager.current_fingerprint == fingerprint
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, fingerprint_dir)
vault = Vault(enc_mgr, fingerprint_dir)
entry_mgr = EntryManager(vault, fingerprint_dir)
pm.encryption_manager = enc_mgr
pm.vault = vault
pm.entry_manager = entry_mgr
index = entry_mgr.add_entry("example.com", 12)
assert str(index) in vault.load_index()["passwords"]
published = []
pm.nostr_client = SimpleNamespace(
publish_json_to_nostr=lambda data, alt_summary=None: (
published.append(data) or True
)
)
inputs = iter([str(index)])
monkeypatch.setattr("builtins.input", lambda *_a, **_k: next(inputs))
pm.delete_entry()
assert str(index) not in vault.load_index()["passwords"]
assert published

View File

@@ -0,0 +1,75 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
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
def setup_client(tmp_path):
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, tmp_path)
with patch("nostr.client.ClientBuilder"), patch(
"nostr.client.KeyManager"
) as MockKM, patch.object(NostrClient, "initialize_client_pool"), patch.object(
enc_mgr, "decrypt_parent_seed", return_value="seed"
):
km_inst = MockKM.return_value
km_inst.keys.private_key_hex.return_value = "1" * 64
km_inst.keys.public_key_hex.return_value = "2" * 64
client = NostrClient(enc_mgr, "fp")
return client
class FakeEvent:
def __init__(self):
self._id = "id"
def id(self):
return self._id
class FakeUnsignedEvent:
def sign_with_keys(self, _):
return FakeEvent()
class FakeBuilder:
def build(self, _):
return FakeUnsignedEvent()
class FakeEventId:
def to_hex(self):
return "abcd"
class FakeSendEventOutput:
def __init__(self):
self.id = FakeEventId()
def test_publish_json_success():
with TemporaryDirectory() as tmpdir, patch(
"nostr.client.EventBuilder.text_note", return_value=FakeBuilder()
):
client = setup_client(Path(tmpdir))
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()
def test_publish_json_failure():
with TemporaryDirectory() as tmpdir, patch(
"nostr.client.EventBuilder.text_note", return_value=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

View File

@@ -13,6 +13,7 @@ import main
from nostr.client import DEFAULT_RELAYS
from password_manager.encryption import EncryptionManager
from password_manager.config_manager import ConfigManager
from password_manager.vault import Vault
from utils.fingerprint_manager import FingerprintManager
@@ -26,14 +27,15 @@ def setup_pm(tmp_path, monkeypatch):
fp_dir = constants.APP_DIR / "fp"
fp_dir.mkdir(parents=True)
enc_mgr = EncryptionManager(Fernet.generate_key(), fp_dir)
cfg_mgr = ConfigManager(enc_mgr, fp_dir)
vault = Vault(enc_mgr, fp_dir)
cfg_mgr = ConfigManager(vault, fp_dir)
fp_mgr = FingerprintManager(constants.APP_DIR)
nostr_stub = SimpleNamespace(
relays=list(DEFAULT_RELAYS),
close_client_pool=lambda: None,
initialize_client_pool=lambda: None,
publish_json_to_nostr=lambda data: None,
publish_json_to_nostr=lambda data, alt_summary=None: None,
key_manager=SimpleNamespace(get_npub=lambda: "npub"),
)

View File

@@ -0,0 +1,33 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.manager import PasswordManager, EncryptionMode
from password_manager.vault import Vault
VALID_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
def test_save_and_encrypt_seed_initializes_vault(monkeypatch):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.vault = None
pm.config_manager = None
pm.current_fingerprint = "fp"
monkeypatch.setattr(
"password_manager.manager.prompt_for_password", lambda: "pw"
)
monkeypatch.setattr(
"password_manager.manager.NostrClient", lambda *a, **kw: object()
)
pm.save_and_encrypt_seed(VALID_SEED, tmp_path)
assert isinstance(pm.vault, Vault)
assert pm.entry_manager is not None

View File

@@ -3,20 +3,32 @@
import logging
import traceback
logger = logging.getLogger(__name__)
try:
from .file_lock import exclusive_lock, shared_lock
from .key_derivation import derive_key_from_password, derive_key_from_parent_seed
from .key_derivation import (
derive_key_from_password,
derive_key_from_parent_seed,
derive_index_key,
EncryptionMode,
DEFAULT_ENCRYPTION_MODE,
)
from .checksum import calculate_checksum, verify_checksum
from .password_prompt import prompt_for_password
logging.info("Modules imported successfully.")
if logger.isEnabledFor(logging.DEBUG):
logger.info("Modules imported successfully.")
except Exception as e:
logging.error(f"Failed to import one or more modules: {e}")
logging.error(traceback.format_exc()) # Log full traceback
if logger.isEnabledFor(logging.DEBUG):
logger.error(f"Failed to import one or more modules: {e}", exc_info=True)
__all__ = [
"derive_key_from_password",
"derive_key_from_parent_seed",
"derive_index_key",
"EncryptionMode",
"DEFAULT_ENCRYPTION_MODE",
"calculate_checksum",
"verify_checksum",
"exclusive_lock",

View File

@@ -52,8 +52,9 @@ def calculate_checksum(file_path: str) -> Optional[str]:
)
return None
except Exception as e:
logging.error(f"Error calculating checksum for '{file_path}': {e}")
logging.error(traceback.format_exc()) # Log full traceback
logging.error(
f"Error calculating checksum for '{file_path}': {e}", exc_info=True
)
print(
colored(
f"Error: Failed to calculate checksum for '{file_path}': {e}", "red"
@@ -87,8 +88,9 @@ def verify_checksum(current_checksum: str, checksum_file_path: str) -> bool:
print(colored(f"Error: Checksum file '{checksum_file_path}' not found.", "red"))
return False
except Exception as e:
logging.error(f"Error reading checksum file '{checksum_file_path}': {e}")
logging.error(traceback.format_exc()) # Log full traceback
logging.error(
f"Error reading checksum file '{checksum_file_path}': {e}", exc_info=True
)
print(
colored(
f"Error: Failed to read checksum file '{checksum_file_path}': {e}",
@@ -118,8 +120,9 @@ def update_checksum(content: str, checksum_file_path: str) -> bool:
logging.debug(f"Updated checksum for '{checksum_file_path}' to: {new_checksum}")
return True
except Exception as e:
logging.error(f"Failed to update checksum for '{checksum_file_path}': {e}")
logging.error(traceback.format_exc()) # Log full traceback
logging.error(
f"Failed to update checksum for '{checksum_file_path}': {e}", exc_info=True
)
print(
colored(
f"Error: Failed to update checksum for '{checksum_file_path}': {e}",
@@ -178,8 +181,10 @@ def initialize_checksum(file_path: str, checksum_file_path: str) -> bool:
print(colored(f"Initialized checksum for '{file_path}'.", "green"))
return True
except Exception as e:
logging.error(f"Failed to initialize checksum file '{checksum_file_path}': {e}")
logging.error(traceback.format_exc()) # Log full traceback
logging.error(
f"Failed to initialize checksum file '{checksum_file_path}': {e}",
exc_info=True,
)
print(
colored(
f"Error: Failed to initialize checksum file '{checksum_file_path}': {e}",

View File

@@ -1,14 +1,15 @@
"""File-based locking utilities using portalocker for cross-platform support."""
from contextlib import contextmanager
from typing import Generator, Optional
from typing import Generator, Optional, Union
from os import PathLike
from pathlib import Path
import portalocker
@contextmanager
def exclusive_lock(
path: Path, timeout: Optional[float] = None
path: Union[str, PathLike[str], Path], timeout: Optional[float] = None
) -> Generator[None, None, None]:
"""Context manager that locks *path* exclusively.
@@ -19,14 +20,17 @@ def exclusive_lock(
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
lock = portalocker.Lock(str(path), mode="a+b", timeout=timeout)
if timeout is None:
lock = portalocker.Lock(str(path), mode="a+b")
else:
lock = portalocker.Lock(str(path), mode="a+b", timeout=timeout)
with lock as fh:
yield fh
@contextmanager
def shared_lock(
path: Path, timeout: Optional[float] = None
path: Union[str, PathLike[str], Path], timeout: Optional[float] = None
) -> Generator[None, None, None]:
"""Context manager that locks *path* with a shared lock.
@@ -38,9 +42,19 @@ def shared_lock(
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.touch(exist_ok=True)
lock = portalocker.Lock(
str(path), mode="r+b", timeout=timeout, flags=portalocker.LockFlags.SHARED
)
if timeout is None:
lock = portalocker.Lock(
str(path),
mode="r+b",
flags=portalocker.LockFlags.SHARED,
)
else:
lock = portalocker.Lock(
str(path),
mode="r+b",
timeout=timeout,
flags=portalocker.LockFlags.SHARED,
)
with lock as fh:
fh.seek(0)
yield fh

View File

@@ -43,6 +43,5 @@ def generate_fingerprint(seed_phrase: str, length: int = 16) -> Optional[str]:
return fingerprint
except Exception as e:
logger.error(f"Failed to generate fingerprint: {e}")
logger.error(traceback.format_exc())
logger.error(f"Failed to generate fingerprint: {e}", exc_info=True)
return None

View File

@@ -61,7 +61,6 @@ class FingerprintManager:
logger.error(
f"Failed to create application directory at {self.app_dir}: {e}"
)
logger.error(traceback.format_exc())
raise
def _load_fingerprints(self) -> List[str]:
@@ -84,8 +83,7 @@ class FingerprintManager:
)
return []
except Exception as e:
logger.error(f"Failed to load fingerprints: {e}")
logger.error(traceback.format_exc())
logger.error(f"Failed to load fingerprints: {e}", exc_info=True)
return []
def _save_fingerprints(self):
@@ -97,8 +95,7 @@ class FingerprintManager:
json.dump({"fingerprints": self.fingerprints}, f, indent=4)
logger.debug(f"Fingerprints saved: {self.fingerprints}")
except Exception as e:
logger.error(f"Failed to save fingerprints: {e}")
logger.error(traceback.format_exc())
logger.error(f"Failed to save fingerprints: {e}", exc_info=True)
raise
def add_fingerprint(self, seed_phrase: str) -> Optional[str]:
@@ -154,8 +151,9 @@ class FingerprintManager:
logger.info(f"Fingerprint {fingerprint} removed successfully.")
return True
except Exception as e:
logger.error(f"Failed to remove fingerprint {fingerprint}: {e}")
logger.error(traceback.format_exc())
logger.error(
f"Failed to remove fingerprint {fingerprint}: {e}", exc_info=True
)
return False
else:
logger.warning(f"Fingerprint {fingerprint} does not exist.")

View File

@@ -20,7 +20,8 @@ import base64
import unicodedata
import logging
import traceback
from typing import Union
from enum import Enum
from typing import Optional, Union
from bip_utils import Bip39SeedGenerator
from local_bip85.bip85 import BIP85
@@ -36,6 +37,17 @@ from cryptography.hazmat.backends import default_backend
logger = logging.getLogger(__name__)
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
def derive_key_from_password(password: str, iterations: int = 100_000) -> bytes:
"""
Derives a Fernet-compatible encryption key from the provided password using PBKDF2-HMAC-SHA256.
@@ -84,8 +96,7 @@ def derive_key_from_password(password: str, iterations: int = 100_000) -> bytes:
return key_b64
except Exception as e:
logger.error(f"Error deriving key from password: {e}")
logger.error(traceback.format_exc()) # Log full traceback
logger.error(f"Error deriving key from password: {e}", exc_info=True)
raise
@@ -127,8 +138,7 @@ def derive_key_from_parent_seed(parent_seed: str, fingerprint: str = None) -> by
return derived_key
except Exception as e:
logger.error(f"Failed to derive key using HKDF: {e}")
logger.error(traceback.format_exc())
logger.error(f"Failed to derive key using HKDF: {e}", exc_info=True)
raise
@@ -167,3 +177,51 @@ class KeyManager:
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()
hkdf = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=b"password-db",
backend=default_backend(),
)
key = hkdf.derive(seed_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}")

View File

@@ -11,7 +11,6 @@ this module enhances code reuse, security, and maintainability across the applic
Ensure that all dependencies are installed and properly configured in your environment.
"""
import os
import getpass
import logging
import sys
@@ -90,8 +89,9 @@ def prompt_new_password() -> str:
logging.info("Password prompt interrupted by user.")
sys.exit(0)
except Exception as e:
logging.error(f"Unexpected error during password prompt: {e}")
logging.error(traceback.format_exc()) # Log full traceback
logging.error(
f"Unexpected error during password prompt: {e}", exc_info=True
)
print(colored(f"Error: {e}", "red"))
attempts += 1
@@ -133,8 +133,9 @@ def prompt_existing_password(prompt_message: str = "Enter your password: ") -> s
logging.info("Existing password prompt interrupted by user.")
sys.exit(0)
except Exception as e:
logging.error(f"Unexpected error during existing password prompt: {e}")
logging.error(traceback.format_exc()) # Log full traceback
logging.error(
f"Unexpected error during existing password prompt: {e}", exc_info=True
)
print(colored(f"Error: {e}", "red"))
sys.exit(1)
@@ -172,8 +173,9 @@ def confirm_action(
logging.info("Action confirmation interrupted by user.")
sys.exit(0)
except Exception as e:
logging.error(f"Unexpected error during action confirmation: {e}")
logging.error(traceback.format_exc()) # Log full traceback
logging.error(
f"Unexpected error during action confirmation: {e}", exc_info=True
)
print(colored(f"Error: {e}", "red"))
sys.exit(1)