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 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 ## Usage
After successfully installing the dependencies, you can run SeedPass using the following command: 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`, After running this command, every `git push` will execute `scripts/update_checksum.py`,
updating the checksum file automatically. 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`: To run mutation tests locally, generate coverage data first and then execute `mutmut`:
```bash ```bash

View File

@@ -217,7 +217,25 @@ seedpass export --file "backup_passwords.json"
``` ```
**Options:** **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:** **Options:**
- `--file` (`-F`): The source file path containing the password entries to import. - `--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 */
.dark-mode-toggle { .dark-mode-toggle {
position: fixed; position: fixed;
top: 20px; top: 12px;
right: 20px; right: 20px;
z-index: 1000; z-index: 1000;
} }
@@ -832,8 +832,8 @@ footer .social-media a:focus {
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.navbar .container { .navbar .container {
flex-direction: column; flex-direction: row;
align-items: flex-start; align-items: center;
} }
.nav-links { .nav-links {

View File

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

View File

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

View File

@@ -121,7 +121,6 @@ class EncryptionManager:
logger.error( logger.error(
"Invalid encryption key or corrupted data while decrypting parent seed." "Invalid encryption key or corrupted data while decrypting parent seed."
) )
print(colored("Error: Invalid encryption key or corrupted data.", "red"))
raise raise
except Exception as e: except Exception as e:
logger.error(f"Failed to decrypt parent seed: {e}", exc_info=True) logger.error(f"Failed to decrypt parent seed: {e}", exc_info=True)
@@ -159,7 +158,6 @@ class EncryptionManager:
logger.error( logger.error(
"Invalid encryption key or corrupted data while decrypting data." "Invalid encryption key or corrupted data while decrypting data."
) )
print(colored("Error: Invalid encryption key or corrupted data.", "red"))
raise raise
except Exception as e: except Exception as e:
logger.error(f"Failed to decrypt data: {e}", exc_info=True) logger.error(f"Failed to decrypt data: {e}", exc_info=True)
@@ -230,7 +228,6 @@ class EncryptionManager:
logger.error( logger.error(
"Invalid encryption key or corrupted data while decrypting file." "Invalid encryption key or corrupted data while decrypting file."
) )
print(colored("Error: Invalid encryption key or corrupted data.", "red"))
raise raise
except Exception as e: except Exception as e:
logger.error( logger.error(
@@ -306,27 +303,16 @@ class EncryptionManager:
logger.error( logger.error(
f"Failed to decode JSON data from '{file_path}': {e}", exc_info=True 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 raise
except InvalidToken: except InvalidToken:
logger.error( logger.error(
"Invalid encryption key or corrupted data while decrypting JSON data." "Invalid encryption key or corrupted data while decrypting JSON data."
) )
print(colored("Error: Invalid encryption key or corrupted data.", "red"))
raise raise
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Failed to load JSON data from '{file_path}': {e}", exc_info=True 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 raise
def update_checksum(self, relative_path: Optional[Path] = None) -> None: 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 pathlib import Path
from termcolor import colored from termcolor import colored
from password_manager.migrations import LATEST_VERSION
from password_manager.vault import Vault from password_manager.vault import Vault
from utils.file_lock import exclusive_lock from utils.file_lock import exclusive_lock
@@ -61,12 +62,12 @@ class EntryManager:
return data return data
except Exception as e: except Exception as e:
logger.error(f"Failed to load index: {e}") logger.error(f"Failed to load index: {e}")
return {"passwords": {}} return {"schema_version": LATEST_VERSION, "passwords": {}}
else: else:
logger.info( logger.info(
f"Index file '{self.index_file}' not found. Initializing new password database." 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: def _save_index(self, data: Dict[str, Any]) -> None:
try: try:

View File

@@ -24,6 +24,11 @@ from password_manager.entry_management import EntryManager
from password_manager.password_generation import PasswordGenerator from password_manager.password_generation import PasswordGenerator
from password_manager.backup import BackupManager from password_manager.backup import BackupManager
from password_manager.vault import Vault from password_manager.vault import Vault
from password_manager.portable_backup import (
export_backup,
import_backup,
PortableMode,
)
from utils.key_derivation import ( from utils.key_derivation import (
derive_key_from_parent_seed, derive_key_from_parent_seed,
derive_key_from_password, derive_key_from_password,
@@ -251,22 +256,28 @@ class PasswordManager:
sys.exit(1) sys.exit(1)
def setup_encryption_manager( def setup_encryption_manager(
self, fingerprint_dir: Path, password: Optional[str] = None self,
) -> None: fingerprint_dir: Path,
password: Optional[str] = None,
*,
exit_on_fail: bool = True,
) -> bool:
"""Set up encryption for the current fingerprint and load the seed.""" """Set up encryption for the current fingerprint and load the seed."""
try: try:
if password is None: if password is None:
password = prompt_existing_password("Enter your master password: ") password = prompt_existing_password("Enter your master password: ")
if not self.parent_seed: seed_key = derive_key_from_password(password)
seed_key = derive_key_from_password(password) seed_mgr = EncryptionManager(seed_key, fingerprint_dir)
seed_mgr = EncryptionManager(seed_key, fingerprint_dir) try:
try: self.parent_seed = seed_mgr.decrypt_parent_seed()
self.parent_seed = seed_mgr.decrypt_parent_seed() except Exception:
except Exception: msg = "Invalid password for selected seed profile."
print(colored("Invalid password. Exiting.", "red")) print(colored(msg, "red"))
raise if exit_on_fail:
sys.exit(1)
return False
key = derive_index_key( key = derive_index_key(
self.parent_seed, self.parent_seed,
@@ -284,12 +295,17 @@ class PasswordManager:
self.fingerprint_dir = fingerprint_dir self.fingerprint_dir = fingerprint_dir
if not self.verify_password(password): if not self.verify_password(password):
print(colored("Invalid password. Exiting.", "red")) print(colored("Invalid password.", "red"))
sys.exit(1) if exit_on_fail:
sys.exit(1)
return False
return True
except Exception as e: except Exception as e:
logger.error(f"Failed to set up EncryptionManager: {e}", exc_info=True) logger.error(f"Failed to set up EncryptionManager: {e}", exc_info=True)
print(colored(f"Error: Failed to set up encryption: {e}", "red")) 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( def load_parent_seed(
self, fingerprint_dir: Path, password: Optional[str] = None self, fingerprint_dir: Path, password: Optional[str] = None
@@ -349,10 +365,15 @@ class PasswordManager:
return False # Return False to indicate failure return False # Return False to indicate failure
# Prompt for master password for the selected seed profile # 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 # 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 # Initialize BIP85 and other managers
self.initialize_bip85() self.initialize_bip85()
@@ -543,6 +564,12 @@ class PasswordManager:
seed_mgr = EncryptionManager(seed_key, fingerprint_dir) seed_mgr = EncryptionManager(seed_key, fingerprint_dir)
self.vault = Vault(self.encryption_manager, 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 # Encrypt and save the parent seed
seed_mgr.encrypt_parent_seed(parent_seed) seed_mgr.encrypt_parent_seed(parent_seed)
logging.info("Parent seed encrypted and saved successfully.") logging.info("Parent seed encrypted and saved successfully.")
@@ -679,6 +706,13 @@ class PasswordManager:
self.vault = Vault(self.encryption_manager, fingerprint_dir) 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) self.store_hashed_password(password)
logging.info("User password hashed and stored successfully.") logging.info("User password hashed and stored successfully.")
@@ -1054,7 +1088,19 @@ class PasswordManager:
""" """
try: try:
current_checksum = calculate_checksum(__file__) 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")) print(colored("Checksum verification passed.", "green"))
logging.info("Checksum verification passed.") logging.info("Checksum verification passed.")
else: else:
@@ -1137,6 +1183,41 @@ class PasswordManager:
logging.error(f"Failed to restore backup: {e}", exc_info=True) logging.error(f"Failed to restore backup: {e}", exc_info=True)
print(colored(f"Error: Failed to restore backup: {e}", "red")) 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: def handle_backup_reveal_parent_seed(self) -> None:
""" """
Handles the backup and reveal of the parent seed. 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 ----- # ----- Password index helpers -----
def load_index(self) -> dict: def load_index(self) -> dict:
"""Return decrypted password index data as a dict.""" """Return decrypted password index data as a dict, applying migrations."""
return self.encryption_manager.load_json_data(self.index_file) 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: def save_index(self, data: dict) -> None:
"""Encrypt and write password index.""" """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 pathlib import Path
from tempfile import TemporaryDirectory 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])) 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.backup import BackupManager
def test_backup_restore_workflow(monkeypatch): def test_backup_restore_workflow(monkeypatch):
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
fp_dir = Path(tmpdir) fp_dir = Path(tmpdir)
key = Fernet.generate_key() vault, enc_mgr = create_vault(fp_dir, TEST_SEED, TEST_PASSWORD)
enc_mgr = EncryptionManager(key, fp_dir)
vault = Vault(enc_mgr, fp_dir)
backup_mgr = BackupManager(fp_dir) backup_mgr = BackupManager(fp_dir)
index_file = fp_dir / "seedpass_passwords_db.json.enc" index_file = fp_dir / "seedpass_passwords_db.json.enc"
@@ -45,11 +41,11 @@ def test_backup_restore_workflow(monkeypatch):
vault.save_index({"passwords": {"temp": {}}}) vault.save_index({"passwords": {"temp": {}}})
backup_mgr.restore_latest_backup() backup_mgr.restore_latest_backup()
assert vault.load_index() == data2 assert vault.load_index()["passwords"] == data2["passwords"]
vault.save_index({"passwords": {}}) vault.save_index({"passwords": {}})
backup_mgr.restore_backup_by_timestamp(1111) backup_mgr.restore_backup_by_timestamp(1111)
assert vault.load_index() == data1 assert vault.load_index()["passwords"] == data1["passwords"]
backup1.unlink() backup1.unlink()
current = vault.load_index() current = vault.load_index()

View File

@@ -1,9 +1,18 @@
import hashlib import hashlib
import json
from pathlib import Path from pathlib import Path
from utils import checksum 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): def test_calculate_checksum(tmp_path):
file = tmp_path / "data.txt" file = tmp_path / "data.txt"
content = "hello world" 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 import sys
from pathlib import Path from pathlib import Path
from multiprocessing import Process, Queue from multiprocessing import Process, Queue
from cryptography.fernet import Fernet
import pytest import pytest
from helpers import TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.encryption import EncryptionManager from password_manager.encryption import EncryptionManager
from password_manager.vault import Vault from password_manager.vault import Vault
from password_manager.backup import BackupManager 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: try:
enc = EncryptionManager(key, dir_path) enc = EncryptionManager(index_key, dir_path)
vault = Vault(enc, dir_path) vault = Vault(enc, dir_path)
for _ in range(loops): for _ in range(loops):
data = vault.load_index() data = vault.load_index()
@@ -23,9 +28,9 @@ def _writer(key: bytes, dir_path: Path, loops: int, out: Queue) -> None:
out.put(repr(e)) 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: try:
enc = EncryptionManager(key, dir_path) enc = EncryptionManager(index_key, dir_path)
vault = Vault(enc, dir_path) vault = Vault(enc, dir_path)
for _ in range(loops): for _ in range(loops):
vault.load_index() 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("loops", [5, pytest.param(20, marks=pytest.mark.stress)])
@pytest.mark.parametrize("_", range(3)) @pytest.mark.parametrize("_", range(3))
def test_concurrency_stress(tmp_path: Path, loops: int, _): def test_concurrency_stress(tmp_path: Path, loops: int, _):
key = Fernet.generate_key() index_key = derive_index_key(TEST_SEED, TEST_PASSWORD, EncryptionMode.SEED_ONLY)
enc = EncryptionManager(key, tmp_path) 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}) Vault(enc, tmp_path).save_index({"counter": 0})
q: Queue = Queue() q: Queue = Queue()
procs = [ procs = [
Process(target=_writer, args=(key, tmp_path, loops, q)), Process(target=_writer, args=(index_key, tmp_path, loops, q)),
Process(target=_writer, args=(key, tmp_path, loops, q)), Process(target=_writer, args=(index_key, tmp_path, loops, q)),
Process(target=_reader, args=(key, tmp_path, loops, q)), Process(target=_reader, args=(index_key, tmp_path, loops, q)),
Process(target=_reader, args=(key, tmp_path, loops, q)), Process(target=_reader, args=(index_key, tmp_path, loops, q)),
Process(target=_backup, args=(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 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) assert isinstance(vault.load_index(), dict)

View File

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

View File

@@ -1,20 +1,17 @@
import sys import sys
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory 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])) 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.entry_management import EntryManager
from password_manager.vault import Vault from password_manager.vault import Vault
def test_list_entries_empty(): def test_list_entries_empty():
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
key = Fernet.generate_key() vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
enc_mgr = EncryptionManager(key, Path(tmpdir))
vault = Vault(enc_mgr, Path(tmpdir))
entry_mgr = EntryManager(vault, Path(tmpdir)) entry_mgr = EntryManager(vault, Path(tmpdir))
entries = entry_mgr.list_entries() entries = entry_mgr.list_entries()

View File

@@ -1,20 +1,17 @@
import sys import sys
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory 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])) 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.entry_management import EntryManager
from password_manager.vault import Vault from password_manager.vault import Vault
def test_add_and_retrieve_entry(): def test_add_and_retrieve_entry():
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
key = Fernet.generate_key() vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
enc_mgr = EncryptionManager(key, Path(tmpdir))
vault = Vault(enc_mgr, Path(tmpdir))
entry_mgr = EntryManager(vault, Path(tmpdir)) entry_mgr = EntryManager(vault, Path(tmpdir))
index = entry_mgr.add_entry("example.com", 12, "user") index = entry_mgr.add_entry("example.com", 12, "user")

View File

@@ -1,21 +1,18 @@
import sys import sys
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory 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])) 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.entry_management import EntryManager
from password_manager.vault import Vault
def test_update_checksum_writes_to_expected_path(): def test_update_checksum_writes_to_expected_path():
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir) tmp_path = Path(tmpdir)
key = Fernet.generate_key() vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
enc_mgr = EncryptionManager(key, tmp_path)
vault = Vault(enc_mgr, tmp_path)
entry_mgr = EntryManager(vault, tmp_path) entry_mgr = EntryManager(vault, tmp_path)
# create an empty index file # 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(): def test_backup_index_file_creates_backup_in_directory():
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir) tmp_path = Path(tmpdir)
key = Fernet.generate_key() vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
enc_mgr = EncryptionManager(key, tmp_path)
vault = Vault(enc_mgr, tmp_path)
entry_mgr = EntryManager(vault, tmp_path) entry_mgr = EntryManager(vault, tmp_path)
vault.save_index({"passwords": {}}) 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 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): def test_backup_and_restore_database(monkeypatch, capsys):
pm = _make_pm() pm = _make_pm()
calls = {"create": 0, "restore": 0} calls = {"create": 0, "restore": 0}

View File

@@ -1,11 +1,10 @@
import sys import sys
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory 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])) 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.entry_management import EntryManager
from password_manager.vault import Vault from password_manager.vault import Vault
from password_manager.backup import BackupManager from password_manager.backup import BackupManager
@@ -29,9 +28,7 @@ class FakeNostrClient:
def test_manager_workflow(monkeypatch): def test_manager_workflow(monkeypatch):
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir) tmp_path = Path(tmpdir)
key = Fernet.generate_key() vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
enc_mgr = EncryptionManager(key, tmp_path)
vault = Vault(enc_mgr, tmp_path)
entry_mgr = EntryManager(vault, tmp_path) entry_mgr = EntryManager(vault, tmp_path)
backup_mgr = BackupManager(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 pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from unittest.mock import patch 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])) 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.entry_management import EntryManager
from password_manager.vault import Vault from password_manager.vault import Vault
from nostr.client import NostrClient from nostr.client import NostrClient
@@ -15,9 +14,7 @@ from nostr.client import NostrClient
def test_backup_and_publish_to_nostr(): def test_backup_and_publish_to_nostr():
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir) tmp_path = Path(tmpdir)
key = Fernet.generate_key() vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
enc_mgr = EncryptionManager(key, tmp_path)
vault = Vault(enc_mgr, tmp_path)
entry_mgr = EntryManager(vault, tmp_path) entry_mgr = EntryManager(vault, tmp_path)
# create an index by adding an entry # create an index by adding an entry

View File

@@ -4,11 +4,10 @@ from tempfile import TemporaryDirectory
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch 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])) 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.entry_management import EntryManager
from password_manager.config_manager import ConfigManager from password_manager.config_manager import ConfigManager
from password_manager.vault import Vault from password_manager.vault import Vault
@@ -18,8 +17,7 @@ from password_manager.manager import PasswordManager
def test_change_password_triggers_nostr_backup(monkeypatch): def test_change_password_triggers_nostr_backup(monkeypatch):
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
fp = Path(tmpdir) fp = Path(tmpdir)
enc_mgr = EncryptionManager(Fernet.generate_key(), fp) vault, enc_mgr = create_vault(fp, TEST_SEED, TEST_PASSWORD)
vault = Vault(enc_mgr, fp)
entry_mgr = EntryManager(vault, fp) entry_mgr = EntryManager(vault, fp)
cfg_mgr = ConfigManager(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 tempfile import TemporaryDirectory
from types import SimpleNamespace 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])) 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 from utils.fingerprint_manager import FingerprintManager
import constants import constants
import password_manager.manager as manager_module import password_manager.manager as manager_module
from password_manager.encryption import EncryptionManager
from password_manager.vault import Vault from password_manager.vault import Vault
from password_manager.entry_management import EntryManager from password_manager.entry_management import EntryManager
@@ -49,9 +48,7 @@ def test_add_and_delete_entry(monkeypatch):
assert fingerprint_dir.exists() assert fingerprint_dir.exists()
assert pm.fingerprint_manager.current_fingerprint == fingerprint assert pm.fingerprint_manager.current_fingerprint == fingerprint
key = Fernet.generate_key() vault, enc_mgr = create_vault(fingerprint_dir, TEST_SEED, TEST_PASSWORD)
enc_mgr = EncryptionManager(key, fingerprint_dir)
vault = Vault(enc_mgr, fingerprint_dir)
entry_mgr = EntryManager(vault, fingerprint_dir) entry_mgr = EntryManager(vault, fingerprint_dir)
pm.encryption_manager = enc_mgr pm.encryption_manager = enc_mgr

View File

@@ -34,7 +34,7 @@ def test_add_and_switch_fingerprint(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
PasswordManager, PasswordManager,
"setup_encryption_manager", "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, "load_parent_seed", lambda self, d: None)
monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None) monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None)

View File

@@ -1,7 +1,8 @@
import sys import sys
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory 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 from mnemonic import Mnemonic
sys.path.append(str(Path(__file__).resolve().parents[1])) 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(): def test_seed_encryption_round_trip():
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
key = Fernet.generate_key() key = derive_key_from_password(TEST_PASSWORD)
enc_mgr = EncryptionManager(key, Path(tmpdir)) enc_mgr = EncryptionManager(key, Path(tmpdir))
seed = Mnemonic("english").generate(strength=128) seed = Mnemonic("english").generate(strength=128)

View File

@@ -5,13 +5,12 @@ from tempfile import TemporaryDirectory
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch 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])) sys.path.append(str(Path(__file__).resolve().parents[1]))
import main import main
from nostr.client import DEFAULT_RELAYS from nostr.client import DEFAULT_RELAYS
from password_manager.encryption import EncryptionManager
from password_manager.config_manager import ConfigManager from password_manager.config_manager import ConfigManager
from password_manager.vault import Vault from password_manager.vault import Vault
from utils.fingerprint_manager import FingerprintManager from utils.fingerprint_manager import FingerprintManager
@@ -26,8 +25,7 @@ def setup_pm(tmp_path, monkeypatch):
fp_dir = constants.APP_DIR / "fp" fp_dir = constants.APP_DIR / "fp"
fp_dir.mkdir(parents=True) fp_dir.mkdir(parents=True)
enc_mgr = EncryptionManager(Fernet.generate_key(), fp_dir) vault, enc_mgr = create_vault(fp_dir, TEST_SEED, TEST_PASSWORD)
vault = Vault(enc_mgr, fp_dir)
cfg_mgr = ConfigManager(vault, fp_dir) cfg_mgr = ConfigManager(vault, fp_dir)
fp_mgr = FingerprintManager(constants.APP_DIR) fp_mgr = FingerprintManager(constants.APP_DIR)

View File

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

View File

@@ -14,8 +14,9 @@ import hashlib
import logging import logging
import sys import sys
import os import os
import json
import traceback import traceback
from typing import Optional from typing import Optional, Any
from termcolor import colored from termcolor import colored
@@ -25,6 +26,17 @@ from constants import APP_DIR, SCRIPT_CHECKSUM_FILE
logger = logging.getLogger(__name__) 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]: def calculate_checksum(file_path: str) -> Optional[str]:
""" """
Calculates the SHA-256 checksum of the given file. 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: try:
with open(checksum_file_path, "r") as f: with open(checksum_file_path, "r") as f:
stored_checksum = f.read().strip() 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: except FileNotFoundError:
logging.error(f"Checksum file '{checksum_file_path}' not found.") logging.error(f"Checksum file '{checksum_file_path}' not found.")
print(colored(f"Error: Checksum file '{checksum_file_path}' not found.", "red")) raise
return False
except Exception as e: except Exception as e:
logging.error( logging.error(
f"Error reading checksum file '{checksum_file_path}': {e}", exc_info=True f"Error reading checksum file '{checksum_file_path}': {e}", exc_info=True
) )
print( raise
colored(
f"Error: Failed to read checksum file '{checksum_file_path}': {e}", if current_checksum == stored_checksum:
"red", logging.debug(f"Checksum verification passed for '{checksum_file_path}'.")
) return True
) else:
logging.warning(f"Checksum mismatch for '{checksum_file_path}'.")
return False return False