mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-09 07:48:57 +00:00
23
README.md
23
README.md
@@ -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
|
||||||
|
@@ -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
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 */
|
||||||
.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 {
|
||||||
|
@@ -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
|
||||||
|
37
src/main.py
37
src/main.py
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
@@ -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:
|
||||||
|
@@ -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:
|
||||||
|
@@ -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.
|
||||||
|
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 -----
|
# ----- 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
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 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()
|
||||||
|
@@ -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"
|
||||||
|
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
|
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)
|
||||||
|
@@ -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
|
||||||
|
@@ -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()
|
||||||
|
@@ -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")
|
||||||
|
@@ -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": {}})
|
||||||
|
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
|
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}
|
||||||
|
@@ -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)
|
||||||
|
|
||||||
|
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 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
|
||||||
|
@@ -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)
|
||||||
|
|
||||||
|
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 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
|
||||||
|
@@ -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)
|
||||||
|
@@ -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)
|
||||||
|
@@ -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)
|
||||||
|
|
||||||
|
@@ -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",
|
||||||
|
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user