mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 07:18:47 +00:00
18
.github/workflows/python-ci.yml
vendored
18
.github/workflows/python-ci.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -29,3 +29,6 @@ Thumbs.db
|
||||
# Coverage files
|
||||
.coverage
|
||||
coverage.xml
|
||||
|
||||
# Other
|
||||
.hypothesis
|
22
.pre-commit-config.yaml
Normal file
22
.pre-commit-config.yaml
Normal 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]
|
80
README.md
80
README.md
@@ -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 pre‑push git hook:
|
||||
|
||||
```bash
|
||||
pre-commit install -t pre-push
|
||||
```
|
||||
|
||||
After running this command, every `git push` will execute `scripts/update_checksum.py`,
|
||||
updating the checksum file automatically.
|
||||
|
||||
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
93
dev-plan.md
Normal 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 you’re 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.10–3.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 that’s 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 you’ll reach a polished, installer-ready, GUI-enhanced 1.0 in roughly four months without sacrificing day-to-day agility.
|
27
examples/entry_management_demo.py
Normal file
27
examples/entry_management_demo.py
Normal 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()
|
13
examples/password_manager_demo.py
Normal file
13
examples/password_manager_demo.py
Normal 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()
|
@@ -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>© 2024 SeedPass. All rights reserved.</p>
|
||||
<p>© 2025 SeedPass</p>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- JavaScript -->
|
||||
|
4
pyproject.toml
Normal file
4
pyproject.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
strict = true
|
||||
mypy_path = "src"
|
@@ -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
62
requirements.lock
Normal 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
|
26
scripts/update_checksum.py
Normal file
26
scripts/update_checksum.py
Normal 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()
|
@@ -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)
|
||||
# -----------------------------------
|
||||
|
@@ -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"]
|
||||
|
@@ -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)
|
||||
|
117
src/main.py
117
src/main.py
@@ -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
|
||||
|
@@ -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}'")
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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}'")
|
||||
|
@@ -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"
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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")
|
||||
|
@@ -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()
|
||||
|
@@ -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
|
||||
|
54
src/password_manager/vault.py
Normal file
54
src/password_manager/vault.py
Normal 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)
|
@@ -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
32
src/tests/conftest.py
Normal 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)
|
40
src/tests/test_auto_sync.py
Normal file
40
src/tests/test_auto_sync.py
Normal 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
|
57
src/tests/test_backup_restore.py
Normal file
57
src/tests/test_backup_restore.py
Normal 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
|
39
src/tests/test_bip85_vectors.py
Normal file
39
src/tests/test_bip85_vectors.py
Normal 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)
|
38
src/tests/test_checksum_utils.py
Normal file
38
src/tests/test_checksum_utils.py
Normal 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
|
55
src/tests/test_cli_encryption_mode.py
Normal file
55
src/tests/test_cli_encryption_mode.py
Normal 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
|
100
src/tests/test_cli_invalid_input.py
Normal file
100
src/tests/test_cli_invalid_input.py
Normal 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())
|
73
src/tests/test_concurrency_stress.py
Normal file
73
src/tests/test_concurrency_stress.py
Normal 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)
|
@@ -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()
|
||||
|
34
src/tests/test_encryption_checksum.py
Normal file
34
src/tests/test_encryption_checksum.py
Normal 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))
|
@@ -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 == []
|
@@ -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)
|
41
src/tests/test_entry_management_checksum_path.py
Normal file
41
src/tests/test_entry_management_checksum_path.py
Normal 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
|
@@ -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()
|
||||
|
20
src/tests/test_fingerprint_manager_utils.py
Normal file
20
src/tests/test_fingerprint_manager_utils.py
Normal 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")
|
45
src/tests/test_inactivity_lock.py
Normal file
45
src/tests/test_inactivity_lock.py
Normal 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"]
|
@@ -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)
|
||||
|
58
src/tests/test_manager_checksum_backup.py
Normal file
58
src/tests/test_manager_checksum_backup.py
Normal 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
|
87
src/tests/test_manager_workflow.py
Normal file
87
src/tests/test_manager_workflow.py
Normal 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
|
@@ -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
|
@@ -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
|
||||
|
76
src/tests/test_nostr_contract.py
Normal file
76
src/tests/test_nostr_contract.py
Normal 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
|
37
src/tests/test_nostr_real.py
Normal file
37
src/tests/test_nostr_real.py
Normal 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
|
72
src/tests/test_nostr_sdk_workflow.py
Normal file
72
src/tests/test_nostr_sdk_workflow.py
Normal 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"
|
73
src/tests/test_parent_seed_backup.py
Normal file
73
src/tests/test_parent_seed_backup.py
Normal 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")
|
@@ -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()
|
||||
|
55
src/tests/test_password_helpers.py
Normal file
55
src/tests/test_password_helpers.py
Normal 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
|
31
src/tests/test_password_length_constraints.py
Normal file
31
src/tests/test_password_length_constraints.py
Normal 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)
|
37
src/tests/test_password_prompt.py
Normal file
37
src/tests/test_password_prompt.py
Normal 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()
|
44
src/tests/test_password_properties.py
Normal file
44
src/tests/test_password_properties.py
Normal 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)
|
85
src/tests/test_password_unlock_after_change.py
Normal file
85
src/tests/test_password_unlock_after_change.py
Normal 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
|
31
src/tests/test_post_sync_messages.py
Normal file
31
src/tests/test_post_sync_messages.py
Normal 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
|
77
src/tests/test_profile_management.py
Normal file
77
src/tests/test_profile_management.py
Normal 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
|
75
src/tests/test_publish_json_result.py
Normal file
75
src/tests/test_publish_json_result.py
Normal 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
|
@@ -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"),
|
||||
)
|
||||
|
||||
|
33
src/tests/test_vault_initialization.py
Normal file
33
src/tests/test_vault_initialization.py
Normal 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
|
@@ -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",
|
||||
|
@@ -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}",
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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.")
|
||||
|
@@ -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}")
|
||||
|
@@ -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)
|
||||
|
||||
|
Reference in New Issue
Block a user