Merge pull request #129 from PR0M3TH3AN/beta

Beta
This commit is contained in:
thePR0M3TH3AN
2025-07-01 23:21:17 -04:00
committed by GitHub
34 changed files with 883 additions and 142 deletions

View File

@@ -103,6 +103,23 @@ pip install --upgrade pip
pip install -r src/requirements.txt
```
## Quick Start
After installing dependencies and activating your virtual environment, launch
SeedPass and create a backup:
```bash
# Start the application
python src/main.py
# Export your index using seed-only encryption
seedpass export --mode seed-only --file "~/seedpass_backup.json"
# Later you can restore it
seedpass import --mode seed-only --file "~/seedpass_backup.json"
```
## Usage
After successfully installing the dependencies, you can run SeedPass using the following command:
@@ -233,6 +250,12 @@ pre-commit install -t pre-push
After running this command, every `git push` will execute `scripts/update_checksum.py`,
updating the checksum file automatically.
If the checksum file is missing, generate it manually:
```bash
python scripts/update_checksum.py
```
To run mutation tests locally, generate coverage data first and then execute `mutmut`:
```bash

View File

@@ -217,7 +217,25 @@ seedpass export --file "backup_passwords.json"
```
**Options:**
- `--file` (`-F`): The destination file path for the exported data.
- `--file` (`-F`): The destination file path for the exported data. If omitted, the export
is saved to the current profile's `exports` directory under `~/.seedpass/<profile>/exports/`.
- `--mode` (`-M`): Choose the encryption mode for the exported file. Valid values are:
`seed-only`, `seed+pw`, `pw-only`, and `plaintext`.
**Examples:**
```bash
# Standard encrypted export
seedpass export --mode seed-only --file "backup.json"
# Combine seed and master password for the export key
seedpass export --mode seed+pw --file "backup.json"
# Derive the key solely from your password
seedpass export --mode pw-only --file "backup.json"
# Plaintext JSON export (not recommended)
seedpass export --mode plaintext --file "backup.json"
```
**Warning:** The `plaintext` mode writes an unencrypted index to disk. Only use it
for debugging and delete the file immediately after use.
---
@@ -237,6 +255,15 @@ seedpass import --file "backup_passwords.json"
**Options:**
- `--file` (`-F`): The source file path containing the password entries to import.
- `--mode` (`-M`): Indicates the encryption mode used when the file was exported. Accepted values are `seed-only`, `seed+pw`, `pw-only`, and `plaintext`.
**Examples:**
```bash
# Import a standard encrypted backup
seedpass import --mode seed-only --file "backup.json"
# Import a backup that also used the master password
seedpass import --mode seed+pw --file "backup.json"
```
---

25
docs/migrations.md Normal file
View File

@@ -0,0 +1,25 @@
# Index Migrations
SeedPass stores its password index in an encrypted JSON file. Each index contains
a `schema_version` field so the application knows how to upgrade older files.
## How migrations work
When the vault loads the index, `Vault.load_index()` checks the version and
applies migrations defined in `password_manager/migrations.py`. The
`apply_migrations()` function iterates through registered migrations until the
file reaches `LATEST_VERSION`.
If an old file lacks `schema_version`, it is treated as version 0 and upgraded
to the latest format. Attempting to load an index from a future version will
raise an error.
## Upgrading an index
1. The JSON is decrypted and parsed.
2. `apply_migrations()` applies any necessary steps, such as injecting the
`schema_version` field on first upgrade.
3. After migration, the updated index is saved back to disk.
This process happens automatically; users only need to open their vault to
upgrade older indices.

View File

@@ -90,7 +90,7 @@ body.dark-mode {
/* Dark Mode Toggle */
.dark-mode-toggle {
position: fixed;
top: 20px;
top: 12px;
right: 20px;
z-index: 1000;
}
@@ -832,8 +832,8 @@ footer .social-media a:focus {
@media screen and (max-width: 768px) {
.navbar .container {
flex-direction: column;
align-items: flex-start;
flex-direction: row;
align-items: center;
}
.nav-links {

View File

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

View File

@@ -13,6 +13,7 @@ from termcolor import colored
import traceback
from password_manager.manager import PasswordManager
from password_manager.portable_backup import PortableMode
from nostr.client import NostrClient
from constants import INACTIVITY_TIMEOUT
from utils.key_derivation import EncryptionMode
@@ -457,8 +458,10 @@ def handle_settings(password_manager: PasswordManager) -> None:
print("3. Change password")
print("4. Verify Script Checksum")
print("5. Backup Parent Seed")
print("6. Lock Vault")
print("7. Back")
print("6. Export database")
print("7. Import database")
print("8. Lock Vault")
print("9. Back")
choice = input("Select an option: ").strip()
if choice == "1":
handle_profiles_menu(password_manager)
@@ -471,10 +474,16 @@ def handle_settings(password_manager: PasswordManager) -> None:
elif choice == "5":
password_manager.handle_backup_reveal_parent_seed()
elif choice == "6":
password_manager.handle_export_database()
elif choice == "7":
path = input("Enter path to backup file: ").strip()
if path:
password_manager.handle_import_database(Path(path))
elif choice == "8":
password_manager.lock_vault()
print(colored("Vault locked. Please re-enter your password.", "yellow"))
password_manager.unlock_vault()
elif choice == "7":
elif choice == "9":
break
else:
print(colored("Invalid choice.", "red"))
@@ -565,11 +574,25 @@ if __name__ == "__main__":
# Load config from disk and parse command-line arguments
cfg = load_global_config()
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command")
parser.add_argument(
"--encryption-mode",
choices=[m.value for m in EncryptionMode],
help="Select encryption mode",
)
exp = sub.add_parser("export")
exp.add_argument(
"--mode",
choices=[m.value for m in PortableMode],
default=PortableMode.SEED_ONLY.value,
)
exp.add_argument("--file")
imp = sub.add_parser("import")
imp.add_argument("--file")
args = parser.parse_args()
mode_value = cfg.get("encryption_mode", EncryptionMode.SEED_ONLY.value)
@@ -591,6 +614,14 @@ if __name__ == "__main__":
print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red"))
sys.exit(1)
if args.command == "export":
mode = PortableMode(args.mode)
password_manager.handle_export_database(mode, Path(args.file))
sys.exit(0)
elif args.command == "import":
password_manager.handle_import_database(Path(args.file))
sys.exit(0)
# Register signal handlers for graceful shutdown
def signal_handler(sig, frame):
"""

View File

@@ -121,7 +121,6 @@ class EncryptionManager:
logger.error(
"Invalid encryption key or corrupted data while decrypting parent seed."
)
print(colored("Error: Invalid encryption key or corrupted data.", "red"))
raise
except Exception as e:
logger.error(f"Failed to decrypt parent seed: {e}", exc_info=True)
@@ -159,7 +158,6 @@ class EncryptionManager:
logger.error(
"Invalid encryption key or corrupted data while decrypting data."
)
print(colored("Error: Invalid encryption key or corrupted data.", "red"))
raise
except Exception as e:
logger.error(f"Failed to decrypt data: {e}", exc_info=True)
@@ -230,7 +228,6 @@ class EncryptionManager:
logger.error(
"Invalid encryption key or corrupted data while decrypting file."
)
print(colored("Error: Invalid encryption key or corrupted data.", "red"))
raise
except Exception as e:
logger.error(
@@ -306,27 +303,16 @@ class EncryptionManager:
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"
)
)
raise
except InvalidToken:
logger.error(
"Invalid encryption key or corrupted data while decrypting JSON data."
)
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}", exc_info=True
)
print(
colored(
f"Error: Failed to load JSON data from '{file_path}': {e}", "red"
)
)
raise
def update_checksum(self, relative_path: Optional[Path] = None) -> None:

View File

@@ -27,6 +27,7 @@ from typing import Optional, Tuple, Dict, Any, List
from pathlib import Path
from termcolor import colored
from password_manager.migrations import LATEST_VERSION
from password_manager.vault import Vault
from utils.file_lock import exclusive_lock
@@ -61,12 +62,12 @@ class EntryManager:
return data
except Exception as e:
logger.error(f"Failed to load index: {e}")
return {"passwords": {}}
return {"schema_version": LATEST_VERSION, "passwords": {}}
else:
logger.info(
f"Index file '{self.index_file}' not found. Initializing new password database."
)
return {"passwords": {}}
return {"schema_version": LATEST_VERSION, "passwords": {}}
def _save_index(self, data: Dict[str, Any]) -> None:
try:

View File

@@ -24,6 +24,11 @@ from password_manager.entry_management import EntryManager
from password_manager.password_generation import PasswordGenerator
from password_manager.backup import BackupManager
from password_manager.vault import Vault
from password_manager.portable_backup import (
export_backup,
import_backup,
PortableMode,
)
from utils.key_derivation import (
derive_key_from_parent_seed,
derive_key_from_password,
@@ -251,22 +256,28 @@ class PasswordManager:
sys.exit(1)
def setup_encryption_manager(
self, fingerprint_dir: Path, password: Optional[str] = None
) -> None:
self,
fingerprint_dir: Path,
password: Optional[str] = None,
*,
exit_on_fail: bool = True,
) -> bool:
"""Set up encryption for the current fingerprint and load the seed."""
try:
if password is None:
password = prompt_existing_password("Enter your master password: ")
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
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:
msg = "Invalid password for selected seed profile."
print(colored(msg, "red"))
if exit_on_fail:
sys.exit(1)
return False
key = derive_index_key(
self.parent_seed,
@@ -284,12 +295,17 @@ class PasswordManager:
self.fingerprint_dir = fingerprint_dir
if not self.verify_password(password):
print(colored("Invalid password. Exiting.", "red"))
sys.exit(1)
print(colored("Invalid password.", "red"))
if exit_on_fail:
sys.exit(1)
return False
return True
except Exception as e:
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)
if exit_on_fail:
sys.exit(1)
return False
def load_parent_seed(
self, fingerprint_dir: Path, password: Optional[str] = None
@@ -349,10 +365,15 @@ class PasswordManager:
return False # Return False to indicate failure
# Prompt for master password for the selected seed profile
password = prompt_existing_password("Enter your master password: ")
password = prompt_existing_password(
"Enter the master password for the selected seed profile: "
)
# Set up the encryption manager with the new password and seed profile directory
self.setup_encryption_manager(self.fingerprint_dir, password)
if not self.setup_encryption_manager(
self.fingerprint_dir, password, exit_on_fail=False
):
return False
# Initialize BIP85 and other managers
self.initialize_bip85()
@@ -543,6 +564,12 @@ class PasswordManager:
seed_mgr = EncryptionManager(seed_key, fingerprint_dir)
self.vault = Vault(self.encryption_manager, fingerprint_dir)
# Ensure config manager is set for the new fingerprint
self.config_manager = ConfigManager(
vault=self.vault,
fingerprint_dir=fingerprint_dir,
)
# Encrypt and save the parent seed
seed_mgr.encrypt_parent_seed(parent_seed)
logging.info("Parent seed encrypted and saved successfully.")
@@ -679,6 +706,13 @@ class PasswordManager:
self.vault = Vault(self.encryption_manager, fingerprint_dir)
# Ensure the config manager points to the new fingerprint before
# storing the hashed password
self.config_manager = ConfigManager(
vault=self.vault,
fingerprint_dir=fingerprint_dir,
)
self.store_hashed_password(password)
logging.info("User password hashed and stored successfully.")
@@ -1054,7 +1088,19 @@ class PasswordManager:
"""
try:
current_checksum = calculate_checksum(__file__)
if verify_checksum(current_checksum, SCRIPT_CHECKSUM_FILE):
try:
verified = verify_checksum(current_checksum, SCRIPT_CHECKSUM_FILE)
except FileNotFoundError:
print(
colored(
"Checksum file missing. Run scripts/update_checksum.py to generate it.",
"yellow",
)
)
logging.warning("Checksum file missing during verification.")
return
if verified:
print(colored("Checksum verification passed.", "green"))
logging.info("Checksum verification passed.")
else:
@@ -1137,6 +1183,41 @@ class PasswordManager:
logging.error(f"Failed to restore backup: {e}", exc_info=True)
print(colored(f"Error: Failed to restore backup: {e}", "red"))
def handle_export_database(
self,
mode: "PortableMode" = PortableMode.SEED_ONLY,
dest: Path | None = None,
) -> Path | None:
"""Export the current database to an encrypted portable file."""
try:
path = export_backup(
self.vault,
self.backup_manager,
mode,
dest,
parent_seed=self.parent_seed,
)
print(colored(f"Database exported to '{path}'.", "green"))
return path
except Exception as e:
logging.error(f"Failed to export database: {e}", exc_info=True)
print(colored(f"Error: Failed to export database: {e}", "red"))
return None
def handle_import_database(self, src: Path) -> None:
"""Import a portable database file, replacing the current index."""
try:
import_backup(
self.vault,
self.backup_manager,
src,
parent_seed=self.parent_seed,
)
print(colored("Database imported successfully.", "green"))
except Exception as e:
logging.error(f"Failed to import database: {e}", exc_info=True)
print(colored(f"Error: Failed to import database: {e}", "red"))
def handle_backup_reveal_parent_seed(self) -> None:
"""
Handles the backup and reveal of the parent seed.

View File

@@ -0,0 +1,45 @@
"""Schema migration helpers for password index files."""
from __future__ import annotations
from typing import Callable, Dict
MIGRATIONS: Dict[int, Callable[[dict], dict]] = {}
def migration(
from_ver: int,
) -> Callable[[Callable[[dict], dict]], Callable[[dict], dict]]:
"""Register a migration function from *from_ver* to *from_ver* + 1."""
def decorator(func: Callable[[dict], dict]) -> Callable[[dict], dict]:
MIGRATIONS[from_ver] = func
return func
return decorator
@migration(0)
def _v0_to_v1(data: dict) -> dict:
"""Inject schema_version field for initial upgrade."""
data["schema_version"] = 1
return data
LATEST_VERSION = 1
def apply_migrations(data: dict) -> dict:
"""Upgrade *data* in-place to the latest schema version."""
current = data.get("schema_version", 0)
if current > LATEST_VERSION:
raise ValueError(f"Unsupported schema version {current}")
while current < LATEST_VERSION:
migrate = MIGRATIONS.get(current)
if migrate is None:
raise ValueError(f"No migration available from version {current}")
data = migrate(data)
current = data.get("schema_version", current + 1)
return data

View File

@@ -0,0 +1,151 @@
# portable_backup.py
"""Export and import encrypted profile backups."""
from __future__ import annotations
import base64
import json
import logging
import os
import time
from enum import Enum
from pathlib import Path
from password_manager.vault import Vault
from password_manager.backup import BackupManager
from nostr.client import NostrClient
from utils.key_derivation import (
derive_index_key,
EncryptionMode,
DEFAULT_ENCRYPTION_MODE,
)
from utils.password_prompt import prompt_existing_password
from password_manager.encryption import EncryptionManager
from utils.checksum import json_checksum, canonical_json_dumps
logger = logging.getLogger(__name__)
FORMAT_VERSION = 1
EXPORT_NAME_TEMPLATE = "seedpass_export_{ts}.json"
class PortableMode(Enum):
"""Encryption mode for portable exports."""
SEED_ONLY = EncryptionMode.SEED_ONLY.value
SEED_PLUS_PW = EncryptionMode.SEED_PLUS_PW.value
PW_ONLY = EncryptionMode.PW_ONLY.value
def _derive_export_key(
seed: str,
mode: PortableMode,
password: str | None = None,
) -> bytes:
"""Derive the Fernet key for the export payload."""
enc_mode = EncryptionMode(mode.value)
return derive_index_key(seed, password, enc_mode)
def export_backup(
vault: Vault,
backup_manager: BackupManager,
mode: PortableMode = PortableMode.SEED_ONLY,
dest_path: Path | None = None,
*,
publish: bool = False,
parent_seed: str | None = None,
) -> Path:
"""Export the current vault state to a portable encrypted file."""
if dest_path is None:
ts = int(time.time())
dest_dir = vault.fingerprint_dir / "exports"
dest_dir.mkdir(parents=True, exist_ok=True)
dest_path = dest_dir / EXPORT_NAME_TEMPLATE.format(ts=ts)
index_data = vault.load_index()
seed = (
parent_seed
if parent_seed is not None
else vault.encryption_manager.decrypt_parent_seed()
)
password = None
if mode in (PortableMode.SEED_PLUS_PW, PortableMode.PW_ONLY):
password = prompt_existing_password("Enter your master password: ")
key = _derive_export_key(seed, mode, password)
enc_mgr = EncryptionManager(key, vault.fingerprint_dir)
canonical = canonical_json_dumps(index_data)
payload_bytes = enc_mgr.encrypt_data(canonical.encode("utf-8"))
checksum = json_checksum(index_data)
wrapper = {
"format_version": FORMAT_VERSION,
"created_at": int(time.time()),
"fingerprint": vault.fingerprint_dir.name,
"encryption_mode": mode.value,
"cipher": "fernet",
"checksum": checksum,
"payload": base64.b64encode(payload_bytes).decode("utf-8"),
}
json_bytes = json.dumps(wrapper, indent=2).encode("utf-8")
dest_path.write_bytes(json_bytes)
os.chmod(dest_path, 0o600)
if publish:
encrypted = vault.encryption_manager.encrypt_data(json_bytes)
enc_file = dest_path.with_suffix(dest_path.suffix + ".enc")
enc_file.write_bytes(encrypted)
os.chmod(enc_file, 0o600)
try:
client = NostrClient(vault.encryption_manager, vault.fingerprint_dir.name)
client.publish_json_to_nostr(encrypted)
except Exception:
logger.error("Failed to publish backup via Nostr", exc_info=True)
return dest_path
def import_backup(
vault: Vault,
backup_manager: BackupManager,
path: Path,
parent_seed: str | None = None,
) -> None:
"""Import a portable backup file and replace the current index."""
raw = Path(path).read_bytes()
if path.suffix.endswith(".enc"):
raw = vault.encryption_manager.decrypt_data(raw)
wrapper = json.loads(raw.decode("utf-8"))
if wrapper.get("format_version") != FORMAT_VERSION:
raise ValueError("Unsupported backup format")
mode = PortableMode(wrapper.get("encryption_mode", PortableMode.SEED_ONLY.value))
payload = base64.b64decode(wrapper["payload"])
seed = (
parent_seed
if parent_seed is not None
else vault.encryption_manager.decrypt_parent_seed()
)
password = None
if mode in (PortableMode.SEED_PLUS_PW, PortableMode.PW_ONLY):
password = prompt_existing_password("Enter your master password: ")
key = _derive_export_key(seed, mode, password)
enc_mgr = EncryptionManager(key, vault.fingerprint_dir)
index_bytes = enc_mgr.decrypt_data(payload)
index = json.loads(index_bytes.decode("utf-8"))
checksum = json_checksum(index)
if checksum != wrapper.get("checksum"):
raise ValueError("Checksum mismatch")
backup_manager.create_backup()
vault.save_index(index)

View File

@@ -29,8 +29,17 @@ class Vault:
# ----- 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)
"""Return decrypted password index data as a dict, applying migrations."""
data = self.encryption_manager.load_json_data(self.index_file)
from .migrations import apply_migrations, LATEST_VERSION
version = data.get("schema_version", 0)
if version > LATEST_VERSION:
raise ValueError(
f"File schema version {version} is newer than supported {LATEST_VERSION}"
)
data = apply_migrations(data)
return data
def save_index(self, data: dict) -> None:
"""Encrypt and write password index."""

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

@@ -0,0 +1,32 @@
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.vault import Vault
from password_manager.encryption import EncryptionManager
from utils.key_derivation import (
derive_index_key,
derive_key_from_password,
EncryptionMode,
)
TEST_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
TEST_PASSWORD = "pw"
def create_vault(
dir_path: Path,
seed: str = TEST_SEED,
password: str = TEST_PASSWORD,
mode: EncryptionMode = EncryptionMode.SEED_ONLY,
) -> tuple[Vault, EncryptionManager]:
"""Create a Vault initialized for tests."""
seed_key = derive_key_from_password(password)
seed_mgr = EncryptionManager(seed_key, dir_path)
seed_mgr.encrypt_parent_seed(seed)
index_key = derive_index_key(seed, password, mode)
enc_mgr = EncryptionManager(index_key, dir_path)
vault = Vault(enc_mgr, dir_path)
return vault, enc_mgr

View File

@@ -4,21 +4,17 @@ import time
from pathlib import Path
from tempfile import TemporaryDirectory
from cryptography.fernet import Fernet
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
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)
vault, enc_mgr = create_vault(fp_dir, TEST_SEED, TEST_PASSWORD)
backup_mgr = BackupManager(fp_dir)
index_file = fp_dir / "seedpass_passwords_db.json.enc"
@@ -45,11 +41,11 @@ def test_backup_restore_workflow(monkeypatch):
vault.save_index({"passwords": {"temp": {}}})
backup_mgr.restore_latest_backup()
assert vault.load_index() == data2
assert vault.load_index()["passwords"] == data2["passwords"]
vault.save_index({"passwords": {}})
backup_mgr.restore_backup_by_timestamp(1111)
assert vault.load_index() == data1
assert vault.load_index()["passwords"] == data1["passwords"]
backup1.unlink()
current = vault.load_index()

View File

@@ -1,9 +1,18 @@
import hashlib
import json
from pathlib import Path
from utils import checksum
def test_json_checksum():
data = {"b": 1, "a": 2}
expected = hashlib.sha256(
json.dumps(data, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
assert checksum.json_checksum(data) == expected
def test_calculate_checksum(tmp_path):
file = tmp_path / "data.txt"
content = "hello world"

View File

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

View File

@@ -1,19 +1,24 @@
import sys
from pathlib import Path
from multiprocessing import Process, Queue
from cryptography.fernet import Fernet
import pytest
from helpers import TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.encryption import EncryptionManager
from password_manager.vault import Vault
from password_manager.backup import BackupManager
from utils.key_derivation import (
derive_index_key,
derive_key_from_password,
EncryptionMode,
)
def _writer(key: bytes, dir_path: Path, loops: int, out: Queue) -> None:
def _writer(index_key: bytes, dir_path: Path, loops: int, out: Queue) -> None:
try:
enc = EncryptionManager(key, dir_path)
enc = EncryptionManager(index_key, dir_path)
vault = Vault(enc, dir_path)
for _ in range(loops):
data = vault.load_index()
@@ -23,9 +28,9 @@ def _writer(key: bytes, dir_path: Path, loops: int, out: Queue) -> None:
out.put(repr(e))
def _reader(key: bytes, dir_path: Path, loops: int, out: Queue) -> None:
def _reader(index_key: bytes, dir_path: Path, loops: int, out: Queue) -> None:
try:
enc = EncryptionManager(key, dir_path)
enc = EncryptionManager(index_key, dir_path)
vault = Vault(enc, dir_path)
for _ in range(loops):
vault.load_index()
@@ -45,16 +50,18 @@ def _backup(dir_path: Path, loops: int, out: Queue) -> None:
@pytest.mark.parametrize("loops", [5, pytest.param(20, marks=pytest.mark.stress)])
@pytest.mark.parametrize("_", range(3))
def test_concurrency_stress(tmp_path: Path, loops: int, _):
key = Fernet.generate_key()
enc = EncryptionManager(key, tmp_path)
index_key = derive_index_key(TEST_SEED, TEST_PASSWORD, EncryptionMode.SEED_ONLY)
seed_key = derive_key_from_password(TEST_PASSWORD)
EncryptionManager(seed_key, tmp_path).encrypt_parent_seed(TEST_SEED)
enc = EncryptionManager(index_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=_writer, args=(index_key, tmp_path, loops, q)),
Process(target=_writer, args=(index_key, tmp_path, loops, q)),
Process(target=_reader, args=(index_key, tmp_path, loops, q)),
Process(target=_reader, args=(index_key, tmp_path, loops, q)),
Process(target=_backup, args=(tmp_path, loops, q)),
]
@@ -69,5 +76,5 @@ def test_concurrency_stress(tmp_path: Path, loops: int, _):
assert not errors
vault = Vault(EncryptionManager(key, tmp_path), tmp_path)
vault = Vault(EncryptionManager(index_key, tmp_path), tmp_path)
assert isinstance(vault.load_index(), dict)

View File

@@ -1,13 +1,12 @@
import bcrypt
from pathlib import Path
from tempfile import TemporaryDirectory
from cryptography.fernet import Fernet
import pytest
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
import sys
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
@@ -15,9 +14,7 @@ from nostr.client import DEFAULT_RELAYS
def test_config_defaults_and_round_trip():
with TemporaryDirectory() as tmpdir:
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, Path(tmpdir))
vault = Vault(enc_mgr, Path(tmpdir))
vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, Path(tmpdir))
cfg = cfg_mgr.load_config(require_pin=False)
@@ -35,9 +32,7 @@ def test_config_defaults_and_round_trip():
def test_pin_verification_and_change():
with TemporaryDirectory() as tmpdir:
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, Path(tmpdir))
vault = Vault(enc_mgr, Path(tmpdir))
vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, Path(tmpdir))
cfg_mgr.set_pin("1234")
@@ -52,9 +47,7 @@ import json
def test_config_file_encrypted_after_save():
with TemporaryDirectory() as tmpdir:
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, Path(tmpdir))
vault = Vault(enc_mgr, Path(tmpdir))
vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, Path(tmpdir))
data = {"relays": ["wss://r"], "pin_hash": ""}
@@ -72,9 +65,7 @@ def test_config_file_encrypted_after_save():
def test_set_relays_persists_changes():
with TemporaryDirectory() as tmpdir:
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, Path(tmpdir))
vault = Vault(enc_mgr, Path(tmpdir))
vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, Path(tmpdir))
cfg_mgr.set_relays(["wss://custom"], require_pin=False)
cfg = cfg_mgr.load_config(require_pin=False)
@@ -83,18 +74,14 @@ def test_set_relays_persists_changes():
def test_set_relays_requires_at_least_one():
with TemporaryDirectory() as tmpdir:
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, Path(tmpdir))
vault = Vault(enc_mgr, Path(tmpdir))
vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
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)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
# save legacy config without password_hash

View File

@@ -1,20 +1,17 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from cryptography.fernet import Fernet
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
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))
vault = Vault(enc_mgr, Path(tmpdir))
vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
entry_mgr = EntryManager(vault, Path(tmpdir))
entries = entry_mgr.list_entries()

View File

@@ -1,20 +1,17 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from cryptography.fernet import Fernet
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
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))
vault = Vault(enc_mgr, Path(tmpdir))
vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
entry_mgr = EntryManager(vault, Path(tmpdir))
index = entry_mgr.add_entry("example.com", 12, "user")

View File

@@ -1,21 +1,18 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from cryptography.fernet import Fernet
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
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.vault import Vault
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)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
entry_mgr = EntryManager(vault, tmp_path)
# create an empty index file
@@ -29,9 +26,7 @@ def test_update_checksum_writes_to_expected_path():
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)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
entry_mgr = EntryManager(vault, tmp_path)
vault.save_index({"passwords": {}})

View File

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

View File

@@ -44,6 +44,21 @@ def test_handle_verify_checksum_failure(monkeypatch, tmp_path, capsys):
assert "Checksum verification failed" in out
def test_handle_verify_checksum_missing(monkeypatch, tmp_path, capsys):
pm = _make_pm()
chk_file = tmp_path / "chk.txt"
monkeypatch.setattr("password_manager.manager.SCRIPT_CHECKSUM_FILE", chk_file)
monkeypatch.setattr("password_manager.manager.calculate_checksum", lambda _: "abc")
def raise_missing(*_args, **_kwargs):
raise FileNotFoundError
monkeypatch.setattr("password_manager.manager.verify_checksum", raise_missing)
pm.handle_verify_checksum()
out = capsys.readouterr().out.lower()
assert "update_checksum.py" in out
def test_backup_and_restore_database(monkeypatch, capsys):
pm = _make_pm()
calls = {"create": 0, "restore": 0}

View File

@@ -1,11 +1,10 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from cryptography.fernet import Fernet
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
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
@@ -29,9 +28,7 @@ class FakeNostrClient:
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)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
entry_mgr = EntryManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path)

View File

@@ -0,0 +1,30 @@
import sys
from pathlib import Path
import pytest
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.migrations import LATEST_VERSION
def setup(tmp_path: Path):
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
return enc_mgr, vault
def test_migrate_v0_to_v1(tmp_path: Path):
enc_mgr, vault = setup(tmp_path)
legacy = {"passwords": {"0": {"website": "a", "length": 8}}}
enc_mgr.save_json_data(legacy)
data = vault.load_index()
assert data["schema_version"] == LATEST_VERSION
assert data["passwords"] == legacy["passwords"]
def test_error_on_future_version(tmp_path: Path):
enc_mgr, vault = setup(tmp_path)
future = {"schema_version": LATEST_VERSION + 1, "passwords": {}}
enc_mgr.save_json_data(future)
with pytest.raises(ValueError):
vault.load_index()

View File

@@ -2,11 +2,10 @@ import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from cryptography.fernet import Fernet
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
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
@@ -15,9 +14,7 @@ from nostr.client import NostrClient
def test_backup_and_publish_to_nostr():
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, tmp_path)
vault = Vault(enc_mgr, tmp_path)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
entry_mgr = EntryManager(vault, tmp_path)
# create an index by adding an entry

View File

@@ -4,11 +4,10 @@ from tempfile import TemporaryDirectory
from types import SimpleNamespace
from unittest.mock import patch
from cryptography.fernet import Fernet
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
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
@@ -18,8 +17,7 @@ from password_manager.manager import PasswordManager
def test_change_password_triggers_nostr_backup(monkeypatch):
with TemporaryDirectory() as tmpdir:
fp = Path(tmpdir)
enc_mgr = EncryptionManager(Fernet.generate_key(), fp)
vault = Vault(enc_mgr, fp)
vault, enc_mgr = create_vault(fp, TEST_SEED, TEST_PASSWORD)
entry_mgr = EntryManager(vault, fp)
cfg_mgr = ConfigManager(vault, fp)

View File

@@ -0,0 +1,187 @@
import json
import base64
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
import sys
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.encryption import EncryptionManager
from password_manager.vault import Vault
from password_manager.backup import BackupManager
from password_manager.portable_backup import (
PortableMode,
export_backup,
import_backup,
)
from utils.key_derivation import (
derive_index_key,
derive_key_from_password,
EncryptionMode,
)
SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
PASSWORD = "passw0rd"
def setup_vault(tmp: Path, mode: EncryptionMode = EncryptionMode.SEED_ONLY):
seed_key = derive_key_from_password(PASSWORD)
seed_mgr = EncryptionManager(seed_key, tmp)
seed_mgr.encrypt_parent_seed(SEED)
index_key = derive_index_key(SEED, PASSWORD, mode)
enc_mgr = EncryptionManager(index_key, tmp)
vault = Vault(enc_mgr, tmp)
backup = BackupManager(tmp)
return vault, backup
def test_round_trip_across_modes(monkeypatch):
for pmode in [
PortableMode.SEED_ONLY,
PortableMode.SEED_PLUS_PW,
PortableMode.PW_ONLY,
]:
with TemporaryDirectory() as td:
tmp = Path(td)
vault, backup = setup_vault(tmp)
data = {"pw": 1}
vault.save_index(data)
monkeypatch.setattr(
"password_manager.portable_backup.prompt_existing_password",
lambda *_a, **_k: PASSWORD,
)
path = export_backup(vault, backup, pmode, parent_seed=SEED)
assert path.exists()
vault.save_index({"pw": 0})
import_backup(vault, backup, path, parent_seed=SEED)
assert vault.load_index()["pw"] == data["pw"]
from cryptography.fernet import InvalidToken
def test_corruption_detection(monkeypatch):
with TemporaryDirectory() as td:
tmp = Path(td)
vault, backup = setup_vault(tmp)
vault.save_index({"a": 1})
monkeypatch.setattr(
"password_manager.portable_backup.prompt_existing_password",
lambda *_a, **_k: PASSWORD,
)
path = export_backup(vault, backup, PortableMode.SEED_ONLY, parent_seed=SEED)
content = json.loads(path.read_text())
payload = base64.b64decode(content["payload"])
payload = b"x" + payload[1:]
content["payload"] = base64.b64encode(payload).decode()
path.write_text(json.dumps(content))
with pytest.raises(InvalidToken):
import_backup(vault, backup, path, parent_seed=SEED)
def test_incorrect_credentials(monkeypatch):
with TemporaryDirectory() as td:
tmp = Path(td)
vault, backup = setup_vault(tmp)
vault.save_index({"a": 2})
monkeypatch.setattr(
"password_manager.portable_backup.prompt_existing_password",
lambda *_a, **_k: PASSWORD,
)
path = export_backup(
vault,
backup,
PortableMode.SEED_PLUS_PW,
parent_seed=SEED,
)
monkeypatch.setattr(
"password_manager.portable_backup.prompt_existing_password",
lambda *_a, **_k: "wrong",
)
with pytest.raises(Exception):
import_backup(vault, backup, path, parent_seed=SEED)
def test_import_over_existing(monkeypatch):
with TemporaryDirectory() as td:
tmp = Path(td)
vault, backup = setup_vault(tmp)
vault.save_index({"v": 1})
monkeypatch.setattr(
"password_manager.portable_backup.prompt_existing_password",
lambda *_a, **_k: PASSWORD,
)
path = export_backup(vault, backup, PortableMode.SEED_ONLY, parent_seed=SEED)
vault.save_index({"v": 2})
import_backup(vault, backup, path, parent_seed=SEED)
loaded = vault.load_index()
assert loaded["v"] == 1
def test_checksum_mismatch_detection(monkeypatch):
with TemporaryDirectory() as td:
tmp = Path(td)
vault, backup = setup_vault(tmp)
vault.save_index({"a": 1})
monkeypatch.setattr(
"password_manager.portable_backup.prompt_existing_password",
lambda *_a, **_k: PASSWORD,
)
path = export_backup(
vault,
backup,
PortableMode.SEED_ONLY,
parent_seed=SEED,
)
wrapper = json.loads(path.read_text())
payload = base64.b64decode(wrapper["payload"])
key = derive_index_key(SEED, PASSWORD, EncryptionMode.SEED_ONLY)
enc_mgr = EncryptionManager(key, tmp)
data = json.loads(enc_mgr.decrypt_data(payload).decode())
data["a"] = 2
mod_canon = json.dumps(data, sort_keys=True, separators=(",", ":"))
new_payload = enc_mgr.encrypt_data(mod_canon.encode())
wrapper["payload"] = base64.b64encode(new_payload).decode()
path.write_text(json.dumps(wrapper))
with pytest.raises(ValueError):
import_backup(vault, backup, path, parent_seed=SEED)
@pytest.mark.parametrize(
"pmode",
[PortableMode.SEED_ONLY, PortableMode.SEED_PLUS_PW],
)
def test_export_import_seed_encrypted_with_different_key(monkeypatch, pmode):
"""Ensure backup round trip works when seed is encrypted with another key."""
with TemporaryDirectory() as td:
tmp = Path(td)
vault, backup = setup_vault(tmp)
vault.save_index({"v": 123})
monkeypatch.setattr(
"password_manager.portable_backup.prompt_existing_password",
lambda *_a, **_k: PASSWORD,
)
path = export_backup(vault, backup, pmode, parent_seed=SEED)
vault.save_index({"v": 0})
import_backup(vault, backup, path, parent_seed=SEED)
assert vault.load_index()["v"] == 123

View File

@@ -4,7 +4,7 @@ from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from cryptography.fernet import Fernet
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
@@ -12,7 +12,6 @@ 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
@@ -49,9 +48,7 @@ def test_add_and_delete_entry(monkeypatch):
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)
vault, enc_mgr = create_vault(fingerprint_dir, TEST_SEED, TEST_PASSWORD)
entry_mgr = EntryManager(vault, fingerprint_dir)
pm.encryption_manager = enc_mgr

View File

@@ -34,7 +34,7 @@ def test_add_and_switch_fingerprint(monkeypatch):
monkeypatch.setattr(
PasswordManager,
"setup_encryption_manager",
lambda self, d, password=None: None,
lambda self, d, password=None, exit_on_fail=True: True,
)
monkeypatch.setattr(PasswordManager, "load_parent_seed", lambda self, d: None)
monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None)

View File

@@ -1,7 +1,8 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from cryptography.fernet import Fernet
from helpers import TEST_PASSWORD
from utils.key_derivation import derive_key_from_password
from mnemonic import Mnemonic
sys.path.append(str(Path(__file__).resolve().parents[1]))
@@ -12,7 +13,7 @@ from password_manager.manager import PasswordManager
def test_seed_encryption_round_trip():
with TemporaryDirectory() as tmpdir:
key = Fernet.generate_key()
key = derive_key_from_password(TEST_PASSWORD)
enc_mgr = EncryptionManager(key, Path(tmpdir))
seed = Mnemonic("english").generate(strength=128)

View File

@@ -5,13 +5,12 @@ from tempfile import TemporaryDirectory
from types import SimpleNamespace
from unittest.mock import patch
from cryptography.fernet import Fernet
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
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,8 +25,7 @@ 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)
vault = Vault(enc_mgr, fp_dir)
vault, enc_mgr = create_vault(fp_dir, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, fp_dir)
fp_mgr = FingerprintManager(constants.APP_DIR)

View File

@@ -14,7 +14,12 @@ try:
EncryptionMode,
DEFAULT_ENCRYPTION_MODE,
)
from .checksum import calculate_checksum, verify_checksum
from .checksum import (
calculate_checksum,
verify_checksum,
json_checksum,
canonical_json_dumps,
)
from .password_prompt import prompt_for_password
if logger.isEnabledFor(logging.DEBUG):
@@ -31,6 +36,8 @@ __all__ = [
"DEFAULT_ENCRYPTION_MODE",
"calculate_checksum",
"verify_checksum",
"json_checksum",
"canonical_json_dumps",
"exclusive_lock",
"shared_lock",
"prompt_for_password",

View File

@@ -14,8 +14,9 @@ import hashlib
import logging
import sys
import os
import json
import traceback
from typing import Optional
from typing import Optional, Any
from termcolor import colored
@@ -25,6 +26,17 @@ from constants import APP_DIR, SCRIPT_CHECKSUM_FILE
logger = logging.getLogger(__name__)
def canonical_json_dumps(data: Any) -> str:
"""Serialize ``data`` into a canonical JSON string."""
return json.dumps(data, sort_keys=True, separators=(",", ":"))
def json_checksum(data: Any) -> str:
"""Return SHA-256 checksum of canonical JSON serialization of ``data``."""
canon = canonical_json_dumps(data)
return hashlib.sha256(canon.encode("utf-8")).hexdigest()
def calculate_checksum(file_path: str) -> Optional[str]:
"""
Calculates the SHA-256 checksum of the given file.
@@ -77,26 +89,20 @@ def verify_checksum(current_checksum: str, checksum_file_path: str) -> bool:
try:
with open(checksum_file_path, "r") as f:
stored_checksum = f.read().strip()
if current_checksum == stored_checksum:
logging.debug(f"Checksum verification passed for '{checksum_file_path}'.")
return True
else:
logging.warning(f"Checksum mismatch for '{checksum_file_path}'.")
return False
except FileNotFoundError:
logging.error(f"Checksum file '{checksum_file_path}' not found.")
print(colored(f"Error: Checksum file '{checksum_file_path}' not found.", "red"))
return False
raise
except Exception as e:
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}",
"red",
)
)
raise
if current_checksum == stored_checksum:
logging.debug(f"Checksum verification passed for '{checksum_file_path}'.")
return True
else:
logging.warning(f"Checksum mismatch for '{checksum_file_path}'.")
return False