mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 07:18:47 +00:00
23
README.md
23
README.md
@@ -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
|
||||
|
@@ -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
25
docs/migrations.md
Normal 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.
|
@@ -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 {
|
||||
|
@@ -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
|
||||
|
37
src/main.py
37
src/main.py
@@ -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):
|
||||
"""
|
||||
|
@@ -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:
|
||||
|
@@ -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:
|
||||
|
@@ -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.
|
||||
|
45
src/password_manager/migrations.py
Normal file
45
src/password_manager/migrations.py
Normal 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
|
151
src/password_manager/portable_backup.py
Normal file
151
src/password_manager/portable_backup.py
Normal 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)
|
@@ -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
32
src/tests/helpers.py
Normal 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
|
@@ -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()
|
||||
|
@@ -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"
|
||||
|
47
src/tests/test_cli_portable_backup_commands.py
Normal file
47
src/tests/test_cli_portable_backup_commands.py
Normal 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
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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()
|
||||
|
@@ -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")
|
||||
|
@@ -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": {}})
|
||||
|
60
src/tests/test_index_import_export.py
Normal file
60
src/tests/test_index_import_export.py
Normal 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
|
@@ -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}
|
||||
|
@@ -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)
|
||||
|
||||
|
30
src/tests/test_migrations.py
Normal file
30
src/tests/test_migrations.py
Normal 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()
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
||||
|
187
src/tests/test_portable_backup.py
Normal file
187
src/tests/test_portable_backup.py
Normal 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
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
||||
|
@@ -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",
|
||||
|
@@ -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
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user