mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-09 15:58:48 +00:00
42
README.md
42
README.md
@@ -46,6 +46,11 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
|||||||
- **Checksum Verification:** Ensure the integrity of the script with checksum verification.
|
- **Checksum Verification:** Ensure the integrity of the script with checksum verification.
|
||||||
- **Multiple Seed Profiles:** Manage separate seed profiles and switch between them seamlessly.
|
- **Multiple Seed Profiles:** Manage separate seed profiles and switch between them seamlessly.
|
||||||
- **Interactive TUI:** Navigate through menus to add, retrieve, and modify entries as well as configure Nostr settings.
|
- **Interactive TUI:** Navigate through menus to add, retrieve, and modify entries as well as configure Nostr settings.
|
||||||
|
- **SeedPass 2FA:** Generate TOTP codes with a real-time countdown progress bar.
|
||||||
|
- **2FA Secret Issuance & Import:** Derive new TOTP secrets from your seed or import existing `otpauth://` URIs.
|
||||||
|
- **Export 2FA Codes:** Save all stored TOTP entries to an encrypted JSON file for use with other apps.
|
||||||
|
- **Optional External Backup Location:** Configure a second directory where backups are automatically copied.
|
||||||
|
- **Auto‑Lock on Inactivity:** Vault locks after a configurable timeout for additional security.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -118,6 +123,9 @@ seedpass export --file "~/seedpass_backup.json"
|
|||||||
|
|
||||||
# Later you can restore it
|
# Later you can restore it
|
||||||
seedpass import --file "~/seedpass_backup.json"
|
seedpass import --file "~/seedpass_backup.json"
|
||||||
|
|
||||||
|
# Use the **Settings** menu to configure an extra backup directory
|
||||||
|
# on an external drive.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Vault JSON Layout
|
### Vault JSON Layout
|
||||||
@@ -168,12 +176,31 @@ python src/main.py
|
|||||||
1. Add Entry
|
1. Add Entry
|
||||||
2. Retrieve Entry
|
2. Retrieve Entry
|
||||||
3. Modify an Existing Entry
|
3. Modify an Existing Entry
|
||||||
4. Settings
|
4. 2FA Codes
|
||||||
5. Exit
|
5. Settings
|
||||||
|
6. Exit
|
||||||
|
|
||||||
Enter your choice (1-5):
|
Enter your choice (1-6):
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When choosing **Add Entry**, you can now select **Password** or **2FA (TOTP)**.
|
||||||
|
|
||||||
|
### Adding a 2FA Entry
|
||||||
|
|
||||||
|
1. From the main menu choose **Add Entry** and select **2FA (TOTP)**.
|
||||||
|
2. Pick **Make 2FA** to derive a new secret from your seed or **Import 2FA** to paste an existing `otpauth://` URI or secret.
|
||||||
|
3. Provide a label for the account (for example, `GitHub`).
|
||||||
|
4. SeedPass automatically chooses the next available derivation index when deriving.
|
||||||
|
5. Optionally specify the TOTP period and digit count.
|
||||||
|
6. SeedPass will display the URI and secret so you can add it to your authenticator app.
|
||||||
|
|
||||||
|
### Modifying a 2FA Entry
|
||||||
|
|
||||||
|
1. From the main menu choose **Modify an Existing Entry** and enter the index of the 2FA code you want to edit.
|
||||||
|
2. SeedPass will show the current label, period, digit count, and blacklist status.
|
||||||
|
3. Enter new values or press **Enter** to keep the existing settings.
|
||||||
|
4. The updated entry is saved back to your encrypted vault.
|
||||||
|
|
||||||
|
|
||||||
### Managing Multiple Seeds
|
### Managing Multiple Seeds
|
||||||
|
|
||||||
@@ -223,7 +250,14 @@ Back in the Settings menu you can:
|
|||||||
* Select `3` to change your master password.
|
* Select `3` to change your master password.
|
||||||
* Choose `4` to verify the script checksum.
|
* Choose `4` to verify the script checksum.
|
||||||
* Choose `5` to back up the parent seed.
|
* Choose `5` to back up the parent seed.
|
||||||
* Choose `6` to lock the vault and require re-entry of your password.
|
* Select `6` to export the database to an encrypted file.
|
||||||
|
* Choose `7` to import a database from a backup file.
|
||||||
|
* Select `8` to export all 2FA codes.
|
||||||
|
* Choose `9` to set an additional backup location.
|
||||||
|
* Select `10` to change the inactivity timeout.
|
||||||
|
* Choose `11` to lock the vault and require re-entry of your password.
|
||||||
|
* Select `12` to return to the main menu.
|
||||||
|
* Choose `13` to view seed profile stats.
|
||||||
|
|
||||||
## Running Tests
|
## Running Tests
|
||||||
|
|
||||||
|
15
docs/README.md
Normal file
15
docs/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# SeedPass Documentation
|
||||||
|
|
||||||
|
This directory contains supplementary guides for using SeedPass.
|
||||||
|
|
||||||
|
## Quick Example: Get a TOTP Code
|
||||||
|
|
||||||
|
Run `seedpass get-code` to retrieve a time-based one-time password (TOTP). A progress bar shows the remaining seconds in the current period.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ seedpass get-code --index 0
|
||||||
|
[##########----------] 15s
|
||||||
|
Code: 123456
|
||||||
|
```
|
||||||
|
|
||||||
|
See [advanced_cli.md](advanced_cli.md) for a full command reference.
|
@@ -4,6 +4,7 @@ from cryptography.fernet import Fernet
|
|||||||
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.entry_management import EntryManager
|
from password_manager.entry_management import EntryManager
|
||||||
|
from password_manager.backup import BackupManager
|
||||||
from constants import initialize_app
|
from constants import initialize_app
|
||||||
|
|
||||||
|
|
||||||
@@ -13,7 +14,8 @@ def main() -> None:
|
|||||||
key = Fernet.generate_key()
|
key = Fernet.generate_key()
|
||||||
enc = EncryptionManager(key, Path("."))
|
enc = EncryptionManager(key, Path("."))
|
||||||
vault = Vault(enc, Path("."))
|
vault = Vault(enc, Path("."))
|
||||||
manager = EntryManager(vault, Path("."))
|
backup_mgr = BackupManager(Path("."))
|
||||||
|
manager = EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
index = manager.add_entry(
|
index = manager.add_entry(
|
||||||
"Example Website",
|
"Example Website",
|
||||||
|
@@ -68,6 +68,10 @@
|
|||||||
<li><i class="fas fa-exchange-alt" aria-hidden="true"></i> Seed/Fingerprint switching for managing multiple profiles</li>
|
<li><i class="fas fa-exchange-alt" aria-hidden="true"></i> Seed/Fingerprint switching for managing multiple profiles</li>
|
||||||
<li><i class="fas fa-check" aria-hidden="true"></i> Checksum verification to ensure script integrity</li>
|
<li><i class="fas fa-check" aria-hidden="true"></i> Checksum verification to ensure script integrity</li>
|
||||||
<li><i class="fas fa-terminal" aria-hidden="true"></i> Interactive TUI for managing entries and settings</li>
|
<li><i class="fas fa-terminal" aria-hidden="true"></i> Interactive TUI for managing entries and settings</li>
|
||||||
|
<li><i class="fas fa-shield-alt" aria-hidden="true"></i> Issue or import TOTP secrets for 2FA</li>
|
||||||
|
<li><i class="fas fa-file-export" aria-hidden="true"></i> Export your 2FA codes to an encrypted file</li>
|
||||||
|
<li><i class="fas fa-folder-open" aria-hidden="true"></i> Optional external backup location</li>
|
||||||
|
<li><i class="fas fa-lock" aria-hidden="true"></i> Auto-lock after inactivity</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -101,10 +105,11 @@ Select an option:
|
|||||||
1. Add Entry
|
1. Add Entry
|
||||||
2. Retrieve Entry
|
2. Retrieve Entry
|
||||||
3. Modify an Existing Entry
|
3. Modify an Existing Entry
|
||||||
4. Settings
|
4. 2FA Codes
|
||||||
5. Exit
|
5. Settings
|
||||||
|
6. Exit
|
||||||
|
|
||||||
Enter your choice (1-5):
|
Enter your choice (1-6):
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
@@ -90,8 +90,8 @@ body.dark-mode {
|
|||||||
/* Dark Mode Toggle */
|
/* Dark Mode Toggle */
|
||||||
.dark-mode-toggle {
|
.dark-mode-toggle {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 12px;
|
bottom: 12px;
|
||||||
right: 20px;
|
left: 20px;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,8 +856,8 @@ footer .social-media a:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark-mode-toggle {
|
.dark-mode-toggle {
|
||||||
top: 10px;
|
bottom: 10px;
|
||||||
right: 10px;
|
left: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Adjust disclaimer container padding on smaller screens */
|
/* Adjust disclaimer container padding on smaller screens */
|
||||||
|
@@ -11,6 +11,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# -----------------------------------
|
# -----------------------------------
|
||||||
MAX_RETRIES = 3 # Maximum number of retries for relay connections
|
MAX_RETRIES = 3 # Maximum number of retries for relay connections
|
||||||
RETRY_DELAY = 5 # Seconds to wait before retrying a failed connection
|
RETRY_DELAY = 5 # Seconds to wait before retrying a failed connection
|
||||||
|
MIN_HEALTHY_RELAYS = 2 # Minimum relays that should return data on startup
|
||||||
|
|
||||||
# -----------------------------------
|
# -----------------------------------
|
||||||
# Application Directory and Paths
|
# Application Directory and Paths
|
||||||
|
101
src/main.py
101
src/main.py
@@ -222,6 +222,17 @@ def handle_display_npub(password_manager: PasswordManager):
|
|||||||
print(colored(f"Error: Failed to display npub: {e}", "red"))
|
print(colored(f"Error: Failed to display npub: {e}", "red"))
|
||||||
|
|
||||||
|
|
||||||
|
def handle_display_stats(password_manager: PasswordManager) -> None:
|
||||||
|
"""Print seed profile statistics."""
|
||||||
|
try:
|
||||||
|
display_fn = getattr(password_manager, "display_stats", None)
|
||||||
|
if callable(display_fn):
|
||||||
|
display_fn()
|
||||||
|
except Exception as e: # pragma: no cover - display best effort
|
||||||
|
logging.error(f"Failed to display stats: {e}", exc_info=True)
|
||||||
|
print(colored(f"Error: Failed to display stats: {e}", "red"))
|
||||||
|
|
||||||
|
|
||||||
def handle_post_to_nostr(
|
def handle_post_to_nostr(
|
||||||
password_manager: PasswordManager, alt_summary: str | None = None
|
password_manager: PasswordManager, alt_summary: str | None = None
|
||||||
):
|
):
|
||||||
@@ -422,6 +433,54 @@ def handle_set_inactivity_timeout(password_manager: PasswordManager) -> None:
|
|||||||
print(colored(f"Error: {e}", "red"))
|
print(colored(f"Error: {e}", "red"))
|
||||||
|
|
||||||
|
|
||||||
|
def handle_set_additional_backup_location(pm: PasswordManager) -> None:
|
||||||
|
"""Configure an optional second backup directory."""
|
||||||
|
cfg_mgr = pm.config_manager
|
||||||
|
if cfg_mgr is None:
|
||||||
|
print(colored("Configuration manager unavailable.", "red"))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
current = cfg_mgr.get_additional_backup_path()
|
||||||
|
if current:
|
||||||
|
print(colored(f"Current path: {current}", "cyan"))
|
||||||
|
else:
|
||||||
|
print(colored("No additional backup location configured.", "cyan"))
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error loading backup path: {e}")
|
||||||
|
print(colored(f"Error: {e}", "red"))
|
||||||
|
return
|
||||||
|
|
||||||
|
value = input(
|
||||||
|
"Enter directory for extra backups (leave blank to disable): "
|
||||||
|
).strip()
|
||||||
|
if not value:
|
||||||
|
try:
|
||||||
|
cfg_mgr.set_additional_backup_path(None)
|
||||||
|
print(colored("Additional backup location disabled.", "green"))
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error clearing path: {e}")
|
||||||
|
print(colored(f"Error: {e}", "red"))
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
path = Path(value).expanduser()
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
test_file = path / ".seedpass_write_test"
|
||||||
|
with open(test_file, "w") as f:
|
||||||
|
f.write("test")
|
||||||
|
test_file.unlink()
|
||||||
|
except Exception as e:
|
||||||
|
print(colored(f"Path not writable: {e}", "red"))
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
cfg_mgr.set_additional_backup_path(str(path))
|
||||||
|
print(colored(f"Additional backups will be copied to {path}", "green"))
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error saving backup path: {e}")
|
||||||
|
print(colored(f"Error: {e}", "red"))
|
||||||
|
|
||||||
|
|
||||||
def handle_profiles_menu(password_manager: PasswordManager) -> None:
|
def handle_profiles_menu(password_manager: PasswordManager) -> None:
|
||||||
"""Submenu for managing seed profiles."""
|
"""Submenu for managing seed profiles."""
|
||||||
while True:
|
while True:
|
||||||
@@ -503,9 +562,12 @@ def handle_settings(password_manager: PasswordManager) -> None:
|
|||||||
print("5. Backup Parent Seed")
|
print("5. Backup Parent Seed")
|
||||||
print("6. Export database")
|
print("6. Export database")
|
||||||
print("7. Import database")
|
print("7. Import database")
|
||||||
print("8. Set inactivity timeout")
|
print("8. Export 2FA codes")
|
||||||
print("9. Lock Vault")
|
print("9. Set additional backup location")
|
||||||
print("10. Back")
|
print("10. Set inactivity timeout")
|
||||||
|
print("11. Lock Vault")
|
||||||
|
print("12. Back")
|
||||||
|
print("13. Stats")
|
||||||
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)
|
||||||
@@ -524,13 +586,19 @@ def handle_settings(password_manager: PasswordManager) -> None:
|
|||||||
if path:
|
if path:
|
||||||
password_manager.handle_import_database(Path(path))
|
password_manager.handle_import_database(Path(path))
|
||||||
elif choice == "8":
|
elif choice == "8":
|
||||||
handle_set_inactivity_timeout(password_manager)
|
password_manager.handle_export_totp_codes()
|
||||||
elif choice == "9":
|
elif choice == "9":
|
||||||
|
handle_set_additional_backup_location(password_manager)
|
||||||
|
elif choice == "10":
|
||||||
|
handle_set_inactivity_timeout(password_manager)
|
||||||
|
elif choice == "11":
|
||||||
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 == "10":
|
elif choice == "12":
|
||||||
break
|
break
|
||||||
|
elif choice == "13":
|
||||||
|
handle_display_stats(password_manager)
|
||||||
else:
|
else:
|
||||||
print(colored("Invalid choice.", "red"))
|
print(colored("Invalid choice.", "red"))
|
||||||
|
|
||||||
@@ -548,9 +616,13 @@ def display_menu(
|
|||||||
1. Add Entry
|
1. Add Entry
|
||||||
2. Retrieve Entry
|
2. Retrieve Entry
|
||||||
3. Modify an Existing Entry
|
3. Modify an Existing Entry
|
||||||
4. Settings
|
4. 2FA Codes
|
||||||
5. Exit
|
5. Settings
|
||||||
|
6. Exit
|
||||||
"""
|
"""
|
||||||
|
display_fn = getattr(password_manager, "display_stats", None)
|
||||||
|
if callable(display_fn):
|
||||||
|
display_fn()
|
||||||
while True:
|
while True:
|
||||||
if time.time() - password_manager.last_activity > inactivity_timeout:
|
if time.time() - password_manager.last_activity > inactivity_timeout:
|
||||||
print(colored("Session timed out. Vault locked.", "yellow"))
|
print(colored("Session timed out. Vault locked.", "yellow"))
|
||||||
@@ -571,7 +643,7 @@ def display_menu(
|
|||||||
print(colored(menu, "cyan"))
|
print(colored(menu, "cyan"))
|
||||||
try:
|
try:
|
||||||
choice = timed_input(
|
choice = timed_input(
|
||||||
"Enter your choice (1-5): ", inactivity_timeout
|
"Enter your choice (1-6): ", inactivity_timeout
|
||||||
).strip()
|
).strip()
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
print(colored("Session timed out. Vault locked.", "yellow"))
|
print(colored("Session timed out. Vault locked.", "yellow"))
|
||||||
@@ -582,7 +654,7 @@ def display_menu(
|
|||||||
if not choice:
|
if not choice:
|
||||||
print(
|
print(
|
||||||
colored(
|
colored(
|
||||||
"No input detected. Please enter a number between 1 and 5.",
|
"No input detected. Please enter a number between 1 and 6.",
|
||||||
"yellow",
|
"yellow",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -591,13 +663,17 @@ def display_menu(
|
|||||||
while True:
|
while True:
|
||||||
print("\nAdd Entry:")
|
print("\nAdd Entry:")
|
||||||
print("1. Password")
|
print("1. Password")
|
||||||
print("2. Back")
|
print("2. 2FA (TOTP)")
|
||||||
|
print("3. Back")
|
||||||
sub_choice = input("Select entry type: ").strip()
|
sub_choice = input("Select entry type: ").strip()
|
||||||
password_manager.update_activity()
|
password_manager.update_activity()
|
||||||
if sub_choice == "1":
|
if sub_choice == "1":
|
||||||
password_manager.handle_add_password()
|
password_manager.handle_add_password()
|
||||||
break
|
break
|
||||||
elif sub_choice == "2":
|
elif sub_choice == "2":
|
||||||
|
password_manager.handle_add_totp()
|
||||||
|
break
|
||||||
|
elif sub_choice == "3":
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
print(colored("Invalid choice.", "red"))
|
print(colored("Invalid choice.", "red"))
|
||||||
@@ -609,8 +685,11 @@ def display_menu(
|
|||||||
password_manager.handle_modify_entry()
|
password_manager.handle_modify_entry()
|
||||||
elif choice == "4":
|
elif choice == "4":
|
||||||
password_manager.update_activity()
|
password_manager.update_activity()
|
||||||
handle_settings(password_manager)
|
password_manager.handle_display_totp_codes()
|
||||||
elif choice == "5":
|
elif choice == "5":
|
||||||
|
password_manager.update_activity()
|
||||||
|
handle_settings(password_manager)
|
||||||
|
elif choice == "6":
|
||||||
logging.info("Exiting the program.")
|
logging.info("Exiting the program.")
|
||||||
print(colored("Exiting the program.", "green"))
|
print(colored("Exiting the program.", "green"))
|
||||||
password_manager.nostr_client.close_client_pool()
|
password_manager.nostr_client.close_client_pool()
|
||||||
|
@@ -8,6 +8,7 @@ from typing import List, Optional, Tuple
|
|||||||
import hashlib
|
import hashlib
|
||||||
import asyncio
|
import asyncio
|
||||||
import gzip
|
import gzip
|
||||||
|
import websockets
|
||||||
|
|
||||||
# Imports from the nostr-sdk library
|
# Imports from the nostr-sdk library
|
||||||
from nostr_sdk import (
|
from nostr_sdk import (
|
||||||
@@ -137,6 +138,42 @@ class NostrClient:
|
|||||||
await self.client.connect()
|
await self.client.connect()
|
||||||
logger.info(f"NostrClient connected to relays: {self.relays}")
|
logger.info(f"NostrClient connected to relays: {self.relays}")
|
||||||
|
|
||||||
|
async def _ping_relay(self, relay: str, timeout: float) -> bool:
|
||||||
|
"""Attempt to retrieve the latest event from a single relay."""
|
||||||
|
sub_id = "seedpass-health"
|
||||||
|
pubkey = self.keys.public_key().to_hex()
|
||||||
|
req = json.dumps(
|
||||||
|
["REQ", sub_id, {"kinds": [1], "authors": [pubkey], "limit": 1}]
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with websockets.connect(
|
||||||
|
relay, open_timeout=timeout, close_timeout=timeout
|
||||||
|
) as ws:
|
||||||
|
await ws.send(req)
|
||||||
|
while True:
|
||||||
|
msg = await asyncio.wait_for(ws.recv(), timeout=timeout)
|
||||||
|
data = json.loads(msg)
|
||||||
|
if data[0] == "EVENT":
|
||||||
|
return True
|
||||||
|
if data[0] == "EOSE":
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _check_relay_health(self, min_relays: int, timeout: float) -> int:
|
||||||
|
tasks = [self._ping_relay(r, timeout) for r in self.relays]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
healthy = sum(1 for r in results if r is True)
|
||||||
|
if healthy < min_relays:
|
||||||
|
logger.warning(
|
||||||
|
"Only %s relays responded with data; consider adding more.", healthy
|
||||||
|
)
|
||||||
|
return healthy
|
||||||
|
|
||||||
|
def check_relay_health(self, min_relays: int = 2, timeout: float = 5.0) -> int:
|
||||||
|
"""Ping relays and return the count of those providing data."""
|
||||||
|
return asyncio.run(self._check_relay_health(min_relays, timeout))
|
||||||
|
|
||||||
def publish_json_to_nostr(
|
def publish_json_to_nostr(
|
||||||
self,
|
self,
|
||||||
encrypted_json: bytes,
|
encrypted_json: bytes,
|
||||||
|
@@ -36,7 +36,7 @@ class EventHandler:
|
|||||||
# Assuming evt.created_at is always an integer Unix timestamp
|
# Assuming evt.created_at is always an integer Unix timestamp
|
||||||
if isinstance(evt.created_at, int):
|
if isinstance(evt.created_at, int):
|
||||||
created_at_str = time.strftime(
|
created_at_str = time.strftime(
|
||||||
"%Y-%m-%d %H:%M:%S", time.localtime(evt.created_at)
|
"%Y-%m-%d %H:%M:%S", time.gmtime(evt.created_at)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Handle unexpected types gracefully
|
# Handle unexpected types gracefully
|
||||||
|
@@ -19,6 +19,8 @@ import traceback
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from termcolor import colored
|
from termcolor import colored
|
||||||
|
|
||||||
|
from password_manager.config_manager import ConfigManager
|
||||||
|
|
||||||
from utils.file_lock import exclusive_lock
|
from utils.file_lock import exclusive_lock
|
||||||
from constants import APP_DIR
|
from constants import APP_DIR
|
||||||
|
|
||||||
@@ -37,14 +39,18 @@ class BackupManager:
|
|||||||
|
|
||||||
BACKUP_FILENAME_TEMPLATE = "entries_db_backup_{timestamp}.json.enc"
|
BACKUP_FILENAME_TEMPLATE = "entries_db_backup_{timestamp}.json.enc"
|
||||||
|
|
||||||
def __init__(self, fingerprint_dir: Path):
|
def __init__(self, fingerprint_dir: Path, config_manager: ConfigManager):
|
||||||
"""
|
"""Initialize BackupManager for a specific profile.
|
||||||
Initializes the BackupManager with the fingerprint directory.
|
|
||||||
|
|
||||||
Parameters:
|
Parameters
|
||||||
fingerprint_dir (Path): The directory corresponding to the fingerprint.
|
----------
|
||||||
|
fingerprint_dir : Path
|
||||||
|
Directory for this profile.
|
||||||
|
config_manager : ConfigManager
|
||||||
|
Configuration manager used for retrieving settings.
|
||||||
"""
|
"""
|
||||||
self.fingerprint_dir = fingerprint_dir
|
self.fingerprint_dir = fingerprint_dir
|
||||||
|
self.config_manager = config_manager
|
||||||
self.backup_dir = self.fingerprint_dir / "backups"
|
self.backup_dir = self.fingerprint_dir / "backups"
|
||||||
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self.index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc"
|
self.index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc"
|
||||||
@@ -72,10 +78,30 @@ class BackupManager:
|
|||||||
shutil.copy2(index_file, backup_file)
|
shutil.copy2(index_file, backup_file)
|
||||||
logger.info(f"Backup created successfully at '{backup_file}'.")
|
logger.info(f"Backup created successfully at '{backup_file}'.")
|
||||||
print(colored(f"Backup created successfully at '{backup_file}'.", "green"))
|
print(colored(f"Backup created successfully at '{backup_file}'.", "green"))
|
||||||
|
|
||||||
|
self._create_additional_backup(backup_file)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to create backup: {e}", exc_info=True)
|
logger.error(f"Failed to create backup: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to create backup: {e}", "red"))
|
print(colored(f"Error: Failed to create backup: {e}", "red"))
|
||||||
|
|
||||||
|
def _create_additional_backup(self, backup_file: Path) -> None:
|
||||||
|
"""Write a copy of *backup_file* to the configured secondary location."""
|
||||||
|
path = self.config_manager.get_additional_backup_path()
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
dest_dir = Path(path).expanduser()
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest_file = dest_dir / f"{self.fingerprint_dir.name}_{backup_file.name}"
|
||||||
|
shutil.copy2(backup_file, dest_file)
|
||||||
|
logger.info(f"Additional backup created at '{dest_file}'.")
|
||||||
|
except Exception as e: # pragma: no cover - best-effort logging
|
||||||
|
logger.error(
|
||||||
|
f"Failed to write additional backup to '{path}': {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
def restore_latest_backup(self) -> None:
|
def restore_latest_backup(self) -> None:
|
||||||
try:
|
try:
|
||||||
backup_files = sorted(
|
backup_files = sorted(
|
||||||
|
@@ -44,6 +44,7 @@ class ConfigManager:
|
|||||||
"pin_hash": "",
|
"pin_hash": "",
|
||||||
"password_hash": "",
|
"password_hash": "",
|
||||||
"inactivity_timeout": INACTIVITY_TIMEOUT,
|
"inactivity_timeout": INACTIVITY_TIMEOUT,
|
||||||
|
"additional_backup_path": "",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
data = self.vault.load_config()
|
data = self.vault.load_config()
|
||||||
@@ -54,6 +55,7 @@ class ConfigManager:
|
|||||||
data.setdefault("pin_hash", "")
|
data.setdefault("pin_hash", "")
|
||||||
data.setdefault("password_hash", "")
|
data.setdefault("password_hash", "")
|
||||||
data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT)
|
data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT)
|
||||||
|
data.setdefault("additional_backup_path", "")
|
||||||
|
|
||||||
# Migrate legacy hashed_password.enc if present and password_hash is missing
|
# Migrate legacy hashed_password.enc if present and password_hash is missing
|
||||||
legacy_file = self.fingerprint_dir / "hashed_password.enc"
|
legacy_file = self.fingerprint_dir / "hashed_password.enc"
|
||||||
@@ -130,3 +132,15 @@ class ConfigManager:
|
|||||||
"""Retrieve the inactivity timeout setting in seconds."""
|
"""Retrieve the inactivity timeout setting in seconds."""
|
||||||
config = self.load_config(require_pin=False)
|
config = self.load_config(require_pin=False)
|
||||||
return float(config.get("inactivity_timeout", INACTIVITY_TIMEOUT))
|
return float(config.get("inactivity_timeout", INACTIVITY_TIMEOUT))
|
||||||
|
|
||||||
|
def set_additional_backup_path(self, path: Optional[str]) -> None:
|
||||||
|
"""Persist an optional additional backup path in the config."""
|
||||||
|
config = self.load_config(require_pin=False)
|
||||||
|
config["additional_backup_path"] = path or ""
|
||||||
|
self.save_config(config)
|
||||||
|
|
||||||
|
def get_additional_backup_path(self) -> Optional[str]:
|
||||||
|
"""Retrieve the additional backup path if configured."""
|
||||||
|
config = self.load_config(require_pin=False)
|
||||||
|
value = config.get("additional_backup_path", "")
|
||||||
|
return value or None
|
||||||
|
@@ -19,19 +19,17 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import hashlib
|
import hashlib
|
||||||
import sys
|
import sys
|
||||||
import os
|
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
|
||||||
import traceback
|
|
||||||
from typing import Optional, Tuple, Dict, Any, List
|
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.migrations import LATEST_VERSION
|
||||||
from password_manager.entry_types import EntryType
|
from password_manager.entry_types import EntryType
|
||||||
|
from password_manager.totp import TotpManager
|
||||||
|
|
||||||
from password_manager.vault import Vault
|
from password_manager.vault import Vault
|
||||||
from utils.file_lock import exclusive_lock
|
from password_manager.backup import BackupManager
|
||||||
|
|
||||||
|
|
||||||
# Instantiate the logger
|
# Instantiate the logger
|
||||||
@@ -39,15 +37,16 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class EntryManager:
|
class EntryManager:
|
||||||
def __init__(self, vault: Vault, fingerprint_dir: Path):
|
def __init__(self, vault: Vault, backup_manager: BackupManager):
|
||||||
"""
|
"""Initialize the EntryManager.
|
||||||
Initializes the EntryManager with the EncryptionManager and fingerprint directory.
|
|
||||||
|
|
||||||
:param vault: The Vault instance for file access.
|
Parameters:
|
||||||
:param fingerprint_dir: The directory corresponding to the fingerprint.
|
vault: The Vault instance for file access.
|
||||||
|
backup_manager: Manages creation of entry database backups.
|
||||||
"""
|
"""
|
||||||
self.vault = vault
|
self.vault = vault
|
||||||
self.fingerprint_dir = fingerprint_dir
|
self.backup_manager = backup_manager
|
||||||
|
self.fingerprint_dir = backup_manager.fingerprint_dir
|
||||||
|
|
||||||
# Use paths relative to the fingerprint directory
|
# Use paths relative to the fingerprint directory
|
||||||
self.index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc"
|
self.index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc"
|
||||||
@@ -59,6 +58,9 @@ class EntryManager:
|
|||||||
if self.index_file.exists():
|
if self.index_file.exists():
|
||||||
try:
|
try:
|
||||||
data = self.vault.load_index()
|
data = self.vault.load_index()
|
||||||
|
# Ensure legacy entries without a type are treated as passwords
|
||||||
|
for entry in data.get("entries", {}).values():
|
||||||
|
entry.setdefault("type", EntryType.PASSWORD.value)
|
||||||
logger.debug("Index loaded successfully.")
|
logger.debug("Index loaded successfully.")
|
||||||
return data
|
return data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -137,7 +139,7 @@ class EntryManager:
|
|||||||
|
|
||||||
self._save_index(data)
|
self._save_index(data)
|
||||||
self.update_checksum()
|
self.update_checksum()
|
||||||
self.backup_index_file()
|
self.backup_manager.create_backup()
|
||||||
|
|
||||||
logger.info(f"Entry added successfully at index {index}.")
|
logger.info(f"Entry added successfully at index {index}.")
|
||||||
print(colored(f"[+] Entry added successfully at index {index}.", "green"))
|
print(colored(f"[+] Entry added successfully at index {index}.", "green"))
|
||||||
@@ -149,16 +151,63 @@ class EntryManager:
|
|||||||
print(colored(f"Error: Failed to add entry: {e}", "red"))
|
print(colored(f"Error: Failed to add entry: {e}", "red"))
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
def add_totp(self, notes: str = "") -> int:
|
def get_next_totp_index(self) -> int:
|
||||||
"""Placeholder for adding a TOTP entry."""
|
"""Return the next available derivation index for TOTP secrets."""
|
||||||
index = self.get_next_index()
|
data = self.vault.load_index()
|
||||||
|
entries = data.get("entries", {})
|
||||||
|
indices = [
|
||||||
|
int(v.get("index", 0))
|
||||||
|
for v in entries.values()
|
||||||
|
if v.get("type") == EntryType.TOTP.value
|
||||||
|
]
|
||||||
|
return (max(indices) + 1) if indices else 0
|
||||||
|
|
||||||
|
def add_totp(
|
||||||
|
self,
|
||||||
|
label: str,
|
||||||
|
parent_seed: str,
|
||||||
|
*,
|
||||||
|
secret: str | None = None,
|
||||||
|
index: int | None = None,
|
||||||
|
period: int = 30,
|
||||||
|
digits: int = 6,
|
||||||
|
) -> str:
|
||||||
|
"""Add a new TOTP entry and return the provisioning URI."""
|
||||||
|
entry_id = self.get_next_index()
|
||||||
data = self.vault.load_index()
|
data = self.vault.load_index()
|
||||||
data.setdefault("entries", {})
|
data.setdefault("entries", {})
|
||||||
data["entries"][str(index)] = {"type": EntryType.TOTP.value, "notes": notes}
|
|
||||||
|
if secret is None:
|
||||||
|
if index is None:
|
||||||
|
index = self.get_next_totp_index()
|
||||||
|
secret = TotpManager.derive_secret(parent_seed, index)
|
||||||
|
entry = {
|
||||||
|
"type": EntryType.TOTP.value,
|
||||||
|
"label": label,
|
||||||
|
"index": index,
|
||||||
|
"period": period,
|
||||||
|
"digits": digits,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
entry = {
|
||||||
|
"type": EntryType.TOTP.value,
|
||||||
|
"label": label,
|
||||||
|
"secret": secret,
|
||||||
|
"period": period,
|
||||||
|
"digits": digits,
|
||||||
|
}
|
||||||
|
|
||||||
|
data["entries"][str(entry_id)] = entry
|
||||||
|
|
||||||
self._save_index(data)
|
self._save_index(data)
|
||||||
self.update_checksum()
|
self.update_checksum()
|
||||||
self.backup_index_file()
|
self.backup_manager.create_backup()
|
||||||
raise NotImplementedError("TOTP entry support not implemented yet")
|
|
||||||
|
try:
|
||||||
|
return TotpManager.make_otpauth_uri(label, secret, period, digits)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to generate otpauth URI: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
def add_ssh_key(self, notes: str = "") -> int:
|
def add_ssh_key(self, notes: str = "") -> int:
|
||||||
"""Placeholder for adding an SSH key entry."""
|
"""Placeholder for adding an SSH key entry."""
|
||||||
@@ -168,7 +217,7 @@ class EntryManager:
|
|||||||
data["entries"][str(index)] = {"type": EntryType.SSH.value, "notes": notes}
|
data["entries"][str(index)] = {"type": EntryType.SSH.value, "notes": notes}
|
||||||
self._save_index(data)
|
self._save_index(data)
|
||||||
self.update_checksum()
|
self.update_checksum()
|
||||||
self.backup_index_file()
|
self.backup_manager.create_backup()
|
||||||
raise NotImplementedError("SSH key entry support not implemented yet")
|
raise NotImplementedError("SSH key entry support not implemented yet")
|
||||||
|
|
||||||
def add_seed(self, notes: str = "") -> int:
|
def add_seed(self, notes: str = "") -> int:
|
||||||
@@ -179,9 +228,32 @@ class EntryManager:
|
|||||||
data["entries"][str(index)] = {"type": EntryType.SEED.value, "notes": notes}
|
data["entries"][str(index)] = {"type": EntryType.SEED.value, "notes": notes}
|
||||||
self._save_index(data)
|
self._save_index(data)
|
||||||
self.update_checksum()
|
self.update_checksum()
|
||||||
self.backup_index_file()
|
self.backup_manager.create_backup()
|
||||||
raise NotImplementedError("Seed entry support not implemented yet")
|
raise NotImplementedError("Seed entry support not implemented yet")
|
||||||
|
|
||||||
|
def get_totp_code(
|
||||||
|
self, index: int, parent_seed: str | None = None, timestamp: int | None = None
|
||||||
|
) -> str:
|
||||||
|
"""Return the current TOTP code for the specified entry."""
|
||||||
|
entry = self.retrieve_entry(index)
|
||||||
|
if not entry or entry.get("type") != EntryType.TOTP.value:
|
||||||
|
raise ValueError("Entry is not a TOTP entry")
|
||||||
|
if "secret" in entry:
|
||||||
|
return TotpManager.current_code_from_secret(entry["secret"], timestamp)
|
||||||
|
if parent_seed is None:
|
||||||
|
raise ValueError("Seed required for derived TOTP")
|
||||||
|
totp_index = int(entry.get("index", 0))
|
||||||
|
return TotpManager.current_code(parent_seed, totp_index, timestamp)
|
||||||
|
|
||||||
|
def get_totp_time_remaining(self, index: int) -> int:
|
||||||
|
"""Return seconds remaining in the TOTP period for the given entry."""
|
||||||
|
entry = self.retrieve_entry(index)
|
||||||
|
if not entry or entry.get("type") != EntryType.TOTP.value:
|
||||||
|
raise ValueError("Entry is not a TOTP entry")
|
||||||
|
|
||||||
|
period = int(entry.get("period", 30))
|
||||||
|
return TotpManager.time_remaining(period)
|
||||||
|
|
||||||
def get_encrypted_index(self) -> Optional[bytes]:
|
def get_encrypted_index(self) -> Optional[bytes]:
|
||||||
"""
|
"""
|
||||||
Retrieves the encrypted password index file's contents.
|
Retrieves the encrypted password index file's contents.
|
||||||
@@ -232,15 +304,22 @@ class EntryManager:
|
|||||||
url: Optional[str] = None,
|
url: Optional[str] = None,
|
||||||
blacklisted: Optional[bool] = None,
|
blacklisted: Optional[bool] = None,
|
||||||
notes: Optional[str] = None,
|
notes: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
label: Optional[str] = None,
|
||||||
|
period: Optional[int] = None,
|
||||||
|
digits: Optional[int] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Modifies an existing entry based on the provided index and new values.
|
Modifies an existing entry based on the provided index and new values.
|
||||||
|
|
||||||
:param index: The index number of the entry to modify.
|
:param index: The index number of the entry to modify.
|
||||||
:param username: (Optional) The new username.
|
:param username: (Optional) The new username (password entries).
|
||||||
:param url: (Optional) The new URL.
|
:param url: (Optional) The new URL (password entries).
|
||||||
:param blacklisted: (Optional) The new blacklist status.
|
:param blacklisted: (Optional) The new blacklist status.
|
||||||
:param notes: (Optional) New notes to attach to the entry.
|
:param notes: (Optional) New notes to attach to the entry.
|
||||||
|
:param label: (Optional) The new label for TOTP entries.
|
||||||
|
:param period: (Optional) The new TOTP period in seconds.
|
||||||
|
:param digits: (Optional) The new number of digits for TOTP codes.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = self.vault.load_index()
|
data = self.vault.load_index()
|
||||||
@@ -258,13 +337,25 @@ class EntryManager:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if username is not None:
|
entry_type = entry.get("type", EntryType.PASSWORD.value)
|
||||||
entry["username"] = username
|
|
||||||
logger.debug(f"Updated username to '{username}' for index {index}.")
|
|
||||||
|
|
||||||
if url is not None:
|
if entry_type == EntryType.TOTP.value:
|
||||||
entry["url"] = url
|
if label is not None:
|
||||||
logger.debug(f"Updated URL to '{url}' for index {index}.")
|
entry["label"] = label
|
||||||
|
logger.debug(f"Updated label to '{label}' for index {index}.")
|
||||||
|
if period is not None:
|
||||||
|
entry["period"] = period
|
||||||
|
logger.debug(f"Updated period to '{period}' for index {index}.")
|
||||||
|
if digits is not None:
|
||||||
|
entry["digits"] = digits
|
||||||
|
logger.debug(f"Updated digits to '{digits}' for index {index}.")
|
||||||
|
else:
|
||||||
|
if username is not None:
|
||||||
|
entry["username"] = username
|
||||||
|
logger.debug(f"Updated username to '{username}' for index {index}.")
|
||||||
|
if url is not None:
|
||||||
|
entry["url"] = url
|
||||||
|
logger.debug(f"Updated URL to '{url}' for index {index}.")
|
||||||
|
|
||||||
if blacklisted is not None:
|
if blacklisted is not None:
|
||||||
entry["blacklisted"] = blacklisted
|
entry["blacklisted"] = blacklisted
|
||||||
@@ -281,7 +372,7 @@ class EntryManager:
|
|||||||
|
|
||||||
self._save_index(data)
|
self._save_index(data)
|
||||||
self.update_checksum()
|
self.update_checksum()
|
||||||
self.backup_index_file()
|
self.backup_manager.create_backup()
|
||||||
|
|
||||||
logger.info(f"Entry at index {index} modified successfully.")
|
logger.info(f"Entry at index {index} modified successfully.")
|
||||||
print(
|
print(
|
||||||
@@ -295,11 +386,7 @@ class EntryManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def list_entries(self) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]:
|
def list_entries(self) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]:
|
||||||
"""
|
"""List all entries in the index."""
|
||||||
Lists all entries in the index.
|
|
||||||
|
|
||||||
:return: A list of tuples containing entry details: (index, website, username, url, blacklisted)
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
data = self.vault.load_index()
|
data = self.vault.load_index()
|
||||||
entries_data = data.get("entries", {})
|
entries_data = data.get("entries", {})
|
||||||
@@ -311,23 +398,48 @@ class EntryManager:
|
|||||||
|
|
||||||
entries = []
|
entries = []
|
||||||
for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])):
|
for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])):
|
||||||
entries.append(
|
etype = entry.get("type", EntryType.PASSWORD.value)
|
||||||
(
|
if etype == EntryType.TOTP.value:
|
||||||
int(idx),
|
entries.append(
|
||||||
entry.get("website", ""),
|
(int(idx), entry.get("label", ""), None, None, False)
|
||||||
entry.get("username", ""),
|
)
|
||||||
entry.get("url", ""),
|
else:
|
||||||
entry.get("blacklisted", False),
|
entries.append(
|
||||||
|
(
|
||||||
|
int(idx),
|
||||||
|
entry.get("website", ""),
|
||||||
|
entry.get("username", ""),
|
||||||
|
entry.get("url", ""),
|
||||||
|
entry.get("blacklisted", False),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(f"Total entries found: {len(entries)}")
|
logger.debug(f"Total entries found: {len(entries)}")
|
||||||
for entry in entries:
|
for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])):
|
||||||
print(colored(f"Index: {entry[0]}", "cyan"))
|
etype = entry.get("type", EntryType.PASSWORD.value)
|
||||||
print(colored(f" Website: {entry[1]}", "cyan"))
|
print(colored(f"Index: {idx}", "cyan"))
|
||||||
print(colored(f" Username: {entry[2] or 'N/A'}", "cyan"))
|
if etype == EntryType.TOTP.value:
|
||||||
print(colored(f" URL: {entry[3] or 'N/A'}", "cyan"))
|
print(colored(" Type: TOTP", "cyan"))
|
||||||
print(colored(f" Blacklisted: {'Yes' if entry[4] else 'No'}", "cyan"))
|
print(colored(f" Label: {entry.get('label', '')}", "cyan"))
|
||||||
|
print(colored(f" Derivation Index: {entry.get('index')}", "cyan"))
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
f" Period: {entry.get('period', 30)}s Digits: {entry.get('digits', 6)}",
|
||||||
|
"cyan",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(colored(f" Website: {entry.get('website', '')}", "cyan"))
|
||||||
|
print(
|
||||||
|
colored(f" Username: {entry.get('username') or 'N/A'}", "cyan")
|
||||||
|
)
|
||||||
|
print(colored(f" URL: {entry.get('url') or 'N/A'}", "cyan"))
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
f" Blacklisted: {'Yes' if entry.get('blacklisted', False) else 'No'}",
|
||||||
|
"cyan",
|
||||||
|
)
|
||||||
|
)
|
||||||
print("-" * 40)
|
print("-" * 40)
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
@@ -350,7 +462,7 @@ class EntryManager:
|
|||||||
logger.debug(f"Deleted entry at index {index}.")
|
logger.debug(f"Deleted entry at index {index}.")
|
||||||
self.vault.save_index(data)
|
self.vault.save_index(data)
|
||||||
self.update_checksum()
|
self.update_checksum()
|
||||||
self.backup_index_file()
|
self.backup_manager.create_backup()
|
||||||
logger.info(f"Entry at index {index} deleted successfully.")
|
logger.info(f"Entry at index {index} deleted successfully.")
|
||||||
print(
|
print(
|
||||||
colored(
|
colored(
|
||||||
@@ -396,35 +508,6 @@ class EntryManager:
|
|||||||
logger.error(f"Failed to update checksum: {e}", exc_info=True)
|
logger.error(f"Failed to update checksum: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to update checksum: {e}", "red"))
|
print(colored(f"Error: Failed to update checksum: {e}", "red"))
|
||||||
|
|
||||||
def backup_index_file(self) -> None:
|
|
||||||
"""
|
|
||||||
Creates a backup of the encrypted JSON index file to prevent data loss.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# self.index_file already includes the fingerprint directory
|
|
||||||
index_file_path = self.index_file
|
|
||||||
if not index_file_path.exists():
|
|
||||||
logger.warning(
|
|
||||||
f"Index file '{index_file_path}' does not exist. No backup created."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
timestamp = int(time.time())
|
|
||||||
backup_filename = f"entries_db_backup_{timestamp}.json.enc"
|
|
||||||
backup_path = self.fingerprint_dir / backup_filename
|
|
||||||
|
|
||||||
with open(index_file_path, "rb") as original_file, open(
|
|
||||||
backup_path, "wb"
|
|
||||||
) as backup_file:
|
|
||||||
shutil.copyfileobj(original_file, backup_file)
|
|
||||||
|
|
||||||
logger.debug(f"Backup created at '{backup_path}'.")
|
|
||||||
print(colored(f"[+] Backup created at '{backup_path}'.", "green"))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to create backup: {e}", exc_info=True)
|
|
||||||
print(colored(f"Warning: Failed to create backup: {e}", "yellow"))
|
|
||||||
|
|
||||||
def restore_from_backup(self, backup_path: str) -> None:
|
def restore_from_backup(self, backup_path: str) -> None:
|
||||||
"""
|
"""
|
||||||
Restores the index file from a specified backup file.
|
Restores the index file from a specified backup file.
|
||||||
|
@@ -17,6 +17,7 @@ import os
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
|
import select
|
||||||
from termcolor import colored
|
from termcolor import colored
|
||||||
|
|
||||||
from password_manager.encryption import EncryptionManager
|
from password_manager.encryption import EncryptionManager
|
||||||
@@ -25,18 +26,23 @@ 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
|
from password_manager.portable_backup import export_backup, import_backup
|
||||||
|
from password_manager.totp import TotpManager
|
||||||
|
from password_manager.entry_types import EntryType
|
||||||
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,
|
||||||
derive_index_key,
|
derive_index_key,
|
||||||
EncryptionMode,
|
EncryptionMode,
|
||||||
)
|
)
|
||||||
from utils.checksum import calculate_checksum, verify_checksum
|
from utils.checksum import calculate_checksum, verify_checksum, json_checksum
|
||||||
from utils.password_prompt import (
|
from utils.password_prompt import (
|
||||||
prompt_for_password,
|
prompt_for_password,
|
||||||
prompt_existing_password,
|
prompt_existing_password,
|
||||||
|
prompt_new_password,
|
||||||
confirm_action,
|
confirm_action,
|
||||||
)
|
)
|
||||||
|
from utils.memory_protection import InMemorySecret
|
||||||
|
from constants import MIN_HEALTHY_RELAYS
|
||||||
|
|
||||||
from constants import (
|
from constants import (
|
||||||
APP_DIR,
|
APP_DIR,
|
||||||
@@ -89,7 +95,7 @@ class PasswordManager:
|
|||||||
self.backup_manager: Optional[BackupManager] = None
|
self.backup_manager: Optional[BackupManager] = None
|
||||||
self.vault: Optional[Vault] = None
|
self.vault: Optional[Vault] = None
|
||||||
self.fingerprint_manager: Optional[FingerprintManager] = None
|
self.fingerprint_manager: Optional[FingerprintManager] = None
|
||||||
self.parent_seed: Optional[str] = None
|
self._parent_seed_secret: Optional[InMemorySecret] = None
|
||||||
self.bip85: Optional[BIP85] = None
|
self.bip85: Optional[BIP85] = None
|
||||||
self.nostr_client: Optional[NostrClient] = None
|
self.nostr_client: Optional[NostrClient] = None
|
||||||
self.config_manager: Optional[ConfigManager] = None
|
self.config_manager: Optional[ConfigManager] = None
|
||||||
@@ -110,6 +116,22 @@ class PasswordManager:
|
|||||||
# Set the current fingerprint directory
|
# Set the current fingerprint directory
|
||||||
self.fingerprint_dir = self.fingerprint_manager.get_current_fingerprint_dir()
|
self.fingerprint_dir = self.fingerprint_manager.get_current_fingerprint_dir()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parent_seed(self) -> Optional[str]:
|
||||||
|
"""Return the decrypted parent seed if set."""
|
||||||
|
if self._parent_seed_secret is None:
|
||||||
|
return None
|
||||||
|
return self._parent_seed_secret.get_str()
|
||||||
|
|
||||||
|
@parent_seed.setter
|
||||||
|
def parent_seed(self, value: Optional[str]) -> None:
|
||||||
|
if value is None:
|
||||||
|
if self._parent_seed_secret:
|
||||||
|
self._parent_seed_secret.wipe()
|
||||||
|
self._parent_seed_secret = None
|
||||||
|
else:
|
||||||
|
self._parent_seed_secret = InMemorySecret(value.encode("utf-8"))
|
||||||
|
|
||||||
def update_activity(self) -> None:
|
def update_activity(self) -> None:
|
||||||
"""Record the current time as the last user activity."""
|
"""Record the current time as the last user activity."""
|
||||||
self.last_activity = time.time()
|
self.last_activity = time.time()
|
||||||
@@ -729,10 +751,18 @@ class PasswordManager:
|
|||||||
raise ValueError("EncryptionManager is not initialized.")
|
raise ValueError("EncryptionManager is not initialized.")
|
||||||
|
|
||||||
# Reinitialize the managers with the updated EncryptionManager and current fingerprint context
|
# Reinitialize the managers with the updated EncryptionManager and current fingerprint context
|
||||||
self.entry_manager = EntryManager(
|
self.config_manager = ConfigManager(
|
||||||
vault=self.vault,
|
vault=self.vault,
|
||||||
fingerprint_dir=self.fingerprint_dir,
|
fingerprint_dir=self.fingerprint_dir,
|
||||||
)
|
)
|
||||||
|
self.backup_manager = BackupManager(
|
||||||
|
fingerprint_dir=self.fingerprint_dir,
|
||||||
|
config_manager=self.config_manager,
|
||||||
|
)
|
||||||
|
self.entry_manager = EntryManager(
|
||||||
|
vault=self.vault,
|
||||||
|
backup_manager=self.backup_manager,
|
||||||
|
)
|
||||||
|
|
||||||
self.password_generator = PasswordGenerator(
|
self.password_generator = PasswordGenerator(
|
||||||
encryption_manager=self.encryption_manager,
|
encryption_manager=self.encryption_manager,
|
||||||
@@ -740,13 +770,7 @@ class PasswordManager:
|
|||||||
bip85=self.bip85,
|
bip85=self.bip85,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.backup_manager = BackupManager(fingerprint_dir=self.fingerprint_dir)
|
|
||||||
|
|
||||||
# Load relay configuration and initialize NostrClient
|
# Load relay configuration and initialize NostrClient
|
||||||
self.config_manager = ConfigManager(
|
|
||||||
vault=self.vault,
|
|
||||||
fingerprint_dir=self.fingerprint_dir,
|
|
||||||
)
|
|
||||||
config = self.config_manager.load_config()
|
config = self.config_manager.load_config()
|
||||||
relay_list = config.get("relays", list(DEFAULT_RELAYS))
|
relay_list = config.get("relays", list(DEFAULT_RELAYS))
|
||||||
self.inactivity_timeout = config.get(
|
self.inactivity_timeout = config.get(
|
||||||
@@ -760,6 +784,17 @@ class PasswordManager:
|
|||||||
parent_seed=getattr(self, "parent_seed", None),
|
parent_seed=getattr(self, "parent_seed", None),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if hasattr(self.nostr_client, "check_relay_health"):
|
||||||
|
healthy = self.nostr_client.check_relay_health(MIN_HEALTHY_RELAYS)
|
||||||
|
if healthy < MIN_HEALTHY_RELAYS:
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
f"Only {healthy} relay(s) responded with your latest event."
|
||||||
|
" Consider adding more relays via Settings.",
|
||||||
|
"yellow",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug("Managers re-initialized for the new fingerprint.")
|
logger.debug("Managers re-initialized for the new fingerprint.")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -862,6 +897,101 @@ class PasswordManager:
|
|||||||
logging.error(f"Error during password generation: {e}", exc_info=True)
|
logging.error(f"Error during password generation: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to generate password: {e}", "red"))
|
print(colored(f"Error: Failed to generate password: {e}", "red"))
|
||||||
|
|
||||||
|
def handle_add_totp(self) -> None:
|
||||||
|
"""Add a TOTP entry either derived from the seed or imported."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
print("\nAdd TOTP:")
|
||||||
|
print("1. Make 2FA (derive from seed)")
|
||||||
|
print("2. Import 2FA (paste otpauth URI or secret)")
|
||||||
|
print("3. Back")
|
||||||
|
choice = input("Select option: ").strip()
|
||||||
|
if choice == "1":
|
||||||
|
label = input("Label: ").strip()
|
||||||
|
if not label:
|
||||||
|
print(colored("Error: Label cannot be empty.", "red"))
|
||||||
|
continue
|
||||||
|
period = input("Period (default 30): ").strip() or "30"
|
||||||
|
digits = input("Digits (default 6): ").strip() or "6"
|
||||||
|
if not period.isdigit() or not digits.isdigit():
|
||||||
|
print(
|
||||||
|
colored("Error: Period and digits must be numbers.", "red")
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
totp_index = self.entry_manager.get_next_totp_index()
|
||||||
|
entry_id = self.entry_manager.get_next_index()
|
||||||
|
uri = self.entry_manager.add_totp(
|
||||||
|
label,
|
||||||
|
self.parent_seed,
|
||||||
|
index=totp_index,
|
||||||
|
period=int(period),
|
||||||
|
digits=int(digits),
|
||||||
|
)
|
||||||
|
secret = TotpManager.derive_secret(self.parent_seed, totp_index)
|
||||||
|
self.is_dirty = True
|
||||||
|
self.last_update = time.time()
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
f"\n[+] TOTP entry added with ID {entry_id}.\n", "green"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print(colored("Add this URI to your authenticator app:", "cyan"))
|
||||||
|
print(colored(uri, "yellow"))
|
||||||
|
print(colored(f"Secret: {secret}\n", "cyan"))
|
||||||
|
try:
|
||||||
|
self.sync_vault()
|
||||||
|
except Exception as nostr_error:
|
||||||
|
logging.error(
|
||||||
|
f"Failed to post updated index to Nostr: {nostr_error}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
elif choice == "2":
|
||||||
|
raw = input("Paste otpauth URI or secret: ").strip()
|
||||||
|
try:
|
||||||
|
if raw.lower().startswith("otpauth://"):
|
||||||
|
label, secret, period, digits = TotpManager.parse_otpauth(
|
||||||
|
raw
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
label = input("Label: ").strip()
|
||||||
|
secret = raw.upper()
|
||||||
|
period = int(input("Period (default 30): ").strip() or 30)
|
||||||
|
digits = int(input("Digits (default 6): ").strip() or 6)
|
||||||
|
entry_id = self.entry_manager.get_next_index()
|
||||||
|
uri = self.entry_manager.add_totp(
|
||||||
|
label,
|
||||||
|
self.parent_seed,
|
||||||
|
secret=secret,
|
||||||
|
period=period,
|
||||||
|
digits=digits,
|
||||||
|
)
|
||||||
|
self.is_dirty = True
|
||||||
|
self.last_update = time.time()
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
f"\nImported \u2714 Codes for {label} are now stored in SeedPass at ID {entry_id}.",
|
||||||
|
"green",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self.sync_vault()
|
||||||
|
except Exception as nostr_error:
|
||||||
|
logging.error(
|
||||||
|
f"Failed to post updated index to Nostr: {nostr_error}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except ValueError as err:
|
||||||
|
print(colored(f"Error: {err}", "red"))
|
||||||
|
elif choice == "3":
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
print(colored("Invalid choice.", "red"))
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error during TOTP setup: {e}", exc_info=True)
|
||||||
|
print(colored(f"Error: Failed to add TOTP: {e}", "red"))
|
||||||
|
|
||||||
def handle_retrieve_entry(self) -> None:
|
def handle_retrieve_entry(self) -> None:
|
||||||
"""
|
"""
|
||||||
Handles retrieving a password from the index by prompting the user for the index number
|
Handles retrieving a password from the index by prompting the user for the index number
|
||||||
@@ -876,21 +1006,62 @@ class PasswordManager:
|
|||||||
return
|
return
|
||||||
index = int(index_input)
|
index = int(index_input)
|
||||||
|
|
||||||
# Retrieve entry details
|
|
||||||
entry = self.entry_manager.retrieve_entry(index)
|
entry = self.entry_manager.retrieve_entry(index)
|
||||||
if not entry:
|
if not entry:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Display entry details
|
entry_type = entry.get("type", EntryType.PASSWORD.value)
|
||||||
|
|
||||||
|
if entry_type == EntryType.TOTP.value:
|
||||||
|
label = entry.get("label", "")
|
||||||
|
period = int(entry.get("period", 30))
|
||||||
|
notes = entry.get("notes", "")
|
||||||
|
print(colored(f"Retrieving 2FA code for '{label}'.", "cyan"))
|
||||||
|
print(colored("Press 'b' then Enter to return to the menu.", "cyan"))
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
code = self.entry_manager.get_totp_code(index, self.parent_seed)
|
||||||
|
print(colored("\n[+] Retrieved 2FA Code:\n", "green"))
|
||||||
|
print(colored(f"Label: {label}", "cyan"))
|
||||||
|
print(colored(f"Code: {code}", "yellow"))
|
||||||
|
if notes:
|
||||||
|
print(colored(f"Notes: {notes}", "cyan"))
|
||||||
|
remaining = self.entry_manager.get_totp_time_remaining(index)
|
||||||
|
exit_loop = False
|
||||||
|
while remaining > 0:
|
||||||
|
filled = int(20 * (period - remaining) / period)
|
||||||
|
bar = "[" + "#" * filled + "-" * (20 - filled) + "]"
|
||||||
|
sys.stdout.write(f"\r{bar} {remaining:2d}s")
|
||||||
|
sys.stdout.flush()
|
||||||
|
try:
|
||||||
|
if (
|
||||||
|
sys.stdin
|
||||||
|
in select.select([sys.stdin], [], [], 1)[0]
|
||||||
|
):
|
||||||
|
user_input = sys.stdin.readline().strip().lower()
|
||||||
|
if user_input == "b":
|
||||||
|
exit_loop = True
|
||||||
|
break
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
exit_loop = True
|
||||||
|
print()
|
||||||
|
break
|
||||||
|
remaining -= 1
|
||||||
|
sys.stdout.write("\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
if exit_loop:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error generating TOTP code: {e}", exc_info=True)
|
||||||
|
print(colored(f"Error: Failed to generate TOTP code: {e}", "red"))
|
||||||
|
return
|
||||||
|
|
||||||
website_name = entry.get("website")
|
website_name = entry.get("website")
|
||||||
length = entry.get("length")
|
length = entry.get("length")
|
||||||
username = entry.get("username")
|
username = entry.get("username")
|
||||||
url = entry.get("url")
|
url = entry.get("url")
|
||||||
blacklisted = entry.get("blacklisted")
|
blacklisted = entry.get("blacklisted")
|
||||||
notes = entry.get("notes", "")
|
notes = entry.get("notes", "")
|
||||||
notes = entry.get("notes", "")
|
|
||||||
notes = entry.get("notes", "")
|
|
||||||
notes = entry.get("notes", "")
|
|
||||||
|
|
||||||
print(
|
print(
|
||||||
colored(
|
colored(
|
||||||
@@ -910,10 +1081,8 @@ class PasswordManager:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate the password
|
|
||||||
password = self.password_generator.generate_password(length, index)
|
password = self.password_generator.generate_password(length, index)
|
||||||
|
|
||||||
# Display the password and associated details
|
|
||||||
if password:
|
if password:
|
||||||
print(
|
print(
|
||||||
colored(f"\n[+] Retrieved Password for {website_name}:\n", "green")
|
colored(f"\n[+] Retrieved Password for {website_name}:\n", "green")
|
||||||
@@ -952,76 +1121,165 @@ class PasswordManager:
|
|||||||
if not entry:
|
if not entry:
|
||||||
return
|
return
|
||||||
|
|
||||||
website_name = entry.get("website")
|
entry_type = entry.get("type", EntryType.PASSWORD.value)
|
||||||
length = entry.get("length")
|
|
||||||
username = entry.get("username")
|
|
||||||
url = entry.get("url")
|
|
||||||
blacklisted = entry.get("blacklisted")
|
|
||||||
notes = entry.get("notes", "")
|
|
||||||
|
|
||||||
# Display current values
|
if entry_type == EntryType.TOTP.value:
|
||||||
print(
|
label = entry.get("label", "")
|
||||||
colored(
|
period = int(entry.get("period", 30))
|
||||||
f"Modifying entry for '{website_name}' (Index: {index}):", "cyan"
|
digits = int(entry.get("digits", 6))
|
||||||
)
|
blacklisted = entry.get("blacklisted", False)
|
||||||
)
|
notes = entry.get("notes", "")
|
||||||
print(colored(f"Current Username: {username or 'N/A'}", "cyan"))
|
|
||||||
print(colored(f"Current URL: {url or 'N/A'}", "cyan"))
|
|
||||||
print(
|
|
||||||
colored(
|
|
||||||
f"Current Blacklist Status: {'Blacklisted' if blacklisted else 'Not Blacklisted'}",
|
|
||||||
"cyan",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Prompt for new values (optional)
|
|
||||||
new_username = (
|
|
||||||
input(
|
|
||||||
f'Enter new username (leave blank to keep "{username or "N/A"}"): '
|
|
||||||
).strip()
|
|
||||||
or username
|
|
||||||
)
|
|
||||||
new_url = (
|
|
||||||
input(f'Enter new URL (leave blank to keep "{url or "N/A"}"): ').strip()
|
|
||||||
or url
|
|
||||||
)
|
|
||||||
blacklist_input = (
|
|
||||||
input(
|
|
||||||
f'Is this password blacklisted? (Y/N, current: {"Y" if blacklisted else "N"}): '
|
|
||||||
)
|
|
||||||
.strip()
|
|
||||||
.lower()
|
|
||||||
)
|
|
||||||
if blacklist_input == "":
|
|
||||||
new_blacklisted = blacklisted
|
|
||||||
elif blacklist_input == "y":
|
|
||||||
new_blacklisted = True
|
|
||||||
elif blacklist_input == "n":
|
|
||||||
new_blacklisted = False
|
|
||||||
else:
|
|
||||||
print(
|
print(
|
||||||
colored(
|
colored(
|
||||||
"Invalid input for blacklist status. Keeping the current status.",
|
f"Modifying 2FA entry '{label}' (Index: {index}):",
|
||||||
"yellow",
|
"cyan",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
new_blacklisted = blacklisted
|
print(colored(f"Current Period: {period}s", "cyan"))
|
||||||
|
print(colored(f"Current Digits: {digits}", "cyan"))
|
||||||
new_notes = (
|
print(
|
||||||
input(
|
colored(
|
||||||
f'Enter new notes (leave blank to keep "{notes or "N/A"}"): '
|
f"Current Blacklist Status: {'Blacklisted' if blacklisted else 'Not Blacklisted'}",
|
||||||
|
"cyan",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
new_label = (
|
||||||
|
input(f'Enter new label (leave blank to keep "{label}"): ').strip()
|
||||||
|
or label
|
||||||
|
)
|
||||||
|
period_input = input(
|
||||||
|
f"Enter new period in seconds (current: {period}): "
|
||||||
).strip()
|
).strip()
|
||||||
or notes
|
new_period = period
|
||||||
)
|
if period_input:
|
||||||
|
if period_input.isdigit():
|
||||||
|
new_period = int(period_input)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
colored("Invalid period value. Keeping current.", "yellow")
|
||||||
|
)
|
||||||
|
digits_input = input(
|
||||||
|
f"Enter new digit count (current: {digits}): "
|
||||||
|
).strip()
|
||||||
|
new_digits = digits
|
||||||
|
if digits_input:
|
||||||
|
if digits_input.isdigit():
|
||||||
|
new_digits = int(digits_input)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
"Invalid digits value. Keeping current.",
|
||||||
|
"yellow",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
blacklist_input = (
|
||||||
|
input(
|
||||||
|
f'Is this 2FA code blacklisted? (Y/N, current: {"Y" if blacklisted else "N"}): '
|
||||||
|
)
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
|
if blacklist_input == "":
|
||||||
|
new_blacklisted = blacklisted
|
||||||
|
elif blacklist_input == "y":
|
||||||
|
new_blacklisted = True
|
||||||
|
elif blacklist_input == "n":
|
||||||
|
new_blacklisted = False
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
"Invalid input for blacklist status. Keeping the current status.",
|
||||||
|
"yellow",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
new_blacklisted = blacklisted
|
||||||
|
|
||||||
# Update the entry
|
new_notes = (
|
||||||
self.entry_manager.modify_entry(
|
input(
|
||||||
index,
|
f'Enter new notes (leave blank to keep "{notes or "N/A"}"): '
|
||||||
new_username,
|
).strip()
|
||||||
new_url,
|
or notes
|
||||||
new_blacklisted,
|
)
|
||||||
new_notes,
|
|
||||||
)
|
self.entry_manager.modify_entry(
|
||||||
|
index,
|
||||||
|
blacklisted=new_blacklisted,
|
||||||
|
notes=new_notes,
|
||||||
|
label=new_label,
|
||||||
|
period=new_period,
|
||||||
|
digits=new_digits,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
website_name = entry.get("website")
|
||||||
|
username = entry.get("username")
|
||||||
|
url = entry.get("url")
|
||||||
|
blacklisted = entry.get("blacklisted")
|
||||||
|
notes = entry.get("notes", "")
|
||||||
|
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
f"Modifying entry for '{website_name}' (Index: {index}):",
|
||||||
|
"cyan",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print(colored(f"Current Username: {username or 'N/A'}", "cyan"))
|
||||||
|
print(colored(f"Current URL: {url or 'N/A'}", "cyan"))
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
f"Current Blacklist Status: {'Blacklisted' if blacklisted else 'Not Blacklisted'}",
|
||||||
|
"cyan",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
new_username = (
|
||||||
|
input(
|
||||||
|
f'Enter new username (leave blank to keep "{username or "N/A"}"): '
|
||||||
|
).strip()
|
||||||
|
or username
|
||||||
|
)
|
||||||
|
new_url = (
|
||||||
|
input(
|
||||||
|
f'Enter new URL (leave blank to keep "{url or "N/A"}"): '
|
||||||
|
).strip()
|
||||||
|
or url
|
||||||
|
)
|
||||||
|
blacklist_input = (
|
||||||
|
input(
|
||||||
|
f'Is this password blacklisted? (Y/N, current: {"Y" if blacklisted else "N"}): '
|
||||||
|
)
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
|
if blacklist_input == "":
|
||||||
|
new_blacklisted = blacklisted
|
||||||
|
elif blacklist_input == "y":
|
||||||
|
new_blacklisted = True
|
||||||
|
elif blacklist_input == "n":
|
||||||
|
new_blacklisted = False
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
"Invalid input for blacklist status. Keeping the current status.",
|
||||||
|
"yellow",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
new_blacklisted = blacklisted
|
||||||
|
|
||||||
|
new_notes = (
|
||||||
|
input(
|
||||||
|
f'Enter new notes (leave blank to keep "{notes or "N/A"}"): '
|
||||||
|
).strip()
|
||||||
|
or notes
|
||||||
|
)
|
||||||
|
|
||||||
|
self.entry_manager.modify_entry(
|
||||||
|
index,
|
||||||
|
new_username,
|
||||||
|
new_url,
|
||||||
|
new_blacklisted,
|
||||||
|
new_notes,
|
||||||
|
)
|
||||||
|
|
||||||
# Mark database as dirty for background sync
|
# Mark database as dirty for background sync
|
||||||
self.is_dirty = True
|
self.is_dirty = True
|
||||||
@@ -1082,6 +1340,61 @@ class PasswordManager:
|
|||||||
logging.error(f"Error during entry deletion: {e}", exc_info=True)
|
logging.error(f"Error during entry deletion: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to delete entry: {e}", "red"))
|
print(colored(f"Error: Failed to delete entry: {e}", "red"))
|
||||||
|
|
||||||
|
def handle_display_totp_codes(self) -> None:
|
||||||
|
"""Display all stored TOTP codes with a countdown progress bar."""
|
||||||
|
try:
|
||||||
|
data = self.entry_manager.vault.load_index()
|
||||||
|
entries = data.get("entries", {})
|
||||||
|
totp_list: list[tuple[str, int, int, bool]] = []
|
||||||
|
for idx_str, entry in entries.items():
|
||||||
|
if entry.get("type") == EntryType.TOTP.value and not entry.get(
|
||||||
|
"blacklisted", False
|
||||||
|
):
|
||||||
|
label = entry.get("label", "")
|
||||||
|
period = int(entry.get("period", 30))
|
||||||
|
imported = "secret" in entry
|
||||||
|
totp_list.append((label, int(idx_str), period, imported))
|
||||||
|
|
||||||
|
if not totp_list:
|
||||||
|
print(colored("No 2FA entries found.", "yellow"))
|
||||||
|
return
|
||||||
|
|
||||||
|
totp_list.sort(key=lambda t: t[0].lower())
|
||||||
|
print(colored("Press 'b' then Enter to return to the menu.", "cyan"))
|
||||||
|
while True:
|
||||||
|
print("\033c", end="")
|
||||||
|
print(colored("Press 'b' then Enter to return to the menu.", "cyan"))
|
||||||
|
generated = [t for t in totp_list if not t[3]]
|
||||||
|
imported_list = [t for t in totp_list if t[3]]
|
||||||
|
if generated:
|
||||||
|
print(colored("\nGenerated 2FA Codes:", "green"))
|
||||||
|
for label, idx, period, _ in generated:
|
||||||
|
code = self.entry_manager.get_totp_code(idx, self.parent_seed)
|
||||||
|
remaining = self.entry_manager.get_totp_time_remaining(idx)
|
||||||
|
filled = int(20 * (period - remaining) / period)
|
||||||
|
bar = "[" + "#" * filled + "-" * (20 - filled) + "]"
|
||||||
|
print(f"[{idx}] {label}: {code} {bar} {remaining:2d}s")
|
||||||
|
if imported_list:
|
||||||
|
print(colored("\nImported 2FA Codes:", "green"))
|
||||||
|
for label, idx, period, _ in imported_list:
|
||||||
|
code = self.entry_manager.get_totp_code(idx, self.parent_seed)
|
||||||
|
remaining = self.entry_manager.get_totp_time_remaining(idx)
|
||||||
|
filled = int(20 * (period - remaining) / period)
|
||||||
|
bar = "[" + "#" * filled + "-" * (20 - filled) + "]"
|
||||||
|
print(f"[{idx}] {label}: {code} {bar} {remaining:2d}s")
|
||||||
|
sys.stdout.flush()
|
||||||
|
try:
|
||||||
|
if sys.stdin in select.select([sys.stdin], [], [], 1)[0]:
|
||||||
|
user_input = sys.stdin.readline().strip().lower()
|
||||||
|
if user_input == "b":
|
||||||
|
break
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print()
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error displaying TOTP codes: {e}", exc_info=True)
|
||||||
|
print(colored(f"Error: Failed to display TOTP codes: {e}", "red"))
|
||||||
|
|
||||||
def handle_verify_checksum(self) -> None:
|
def handle_verify_checksum(self) -> None:
|
||||||
"""
|
"""
|
||||||
Handles verifying the script's checksum against the stored checksum to ensure integrity.
|
Handles verifying the script's checksum against the stored checksum to ensure integrity.
|
||||||
@@ -1237,6 +1550,63 @@ class PasswordManager:
|
|||||||
logging.error(f"Failed to import database: {e}", exc_info=True)
|
logging.error(f"Failed to import database: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to import database: {e}", "red"))
|
print(colored(f"Error: Failed to import database: {e}", "red"))
|
||||||
|
|
||||||
|
def handle_export_totp_codes(self) -> Path | None:
|
||||||
|
"""Export all 2FA codes to a JSON file for other authenticator apps."""
|
||||||
|
try:
|
||||||
|
data = self.entry_manager.vault.load_index()
|
||||||
|
entries = data.get("entries", {})
|
||||||
|
|
||||||
|
totp_entries = []
|
||||||
|
for entry in entries.values():
|
||||||
|
if entry.get("type") == EntryType.TOTP.value:
|
||||||
|
label = entry.get("label", "")
|
||||||
|
period = int(entry.get("period", 30))
|
||||||
|
digits = int(entry.get("digits", 6))
|
||||||
|
if "secret" in entry:
|
||||||
|
secret = entry["secret"]
|
||||||
|
else:
|
||||||
|
idx = int(entry.get("index", 0))
|
||||||
|
secret = TotpManager.derive_secret(self.parent_seed, idx)
|
||||||
|
uri = TotpManager.make_otpauth_uri(label, secret, period, digits)
|
||||||
|
totp_entries.append(
|
||||||
|
{
|
||||||
|
"label": label,
|
||||||
|
"secret": secret,
|
||||||
|
"period": period,
|
||||||
|
"digits": digits,
|
||||||
|
"uri": uri,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not totp_entries:
|
||||||
|
print(colored("No 2FA codes to export.", "yellow"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
dest_str = input(
|
||||||
|
"Enter destination file path (default: totp_export.json): "
|
||||||
|
).strip()
|
||||||
|
dest = Path(dest_str) if dest_str else Path("totp_export.json")
|
||||||
|
|
||||||
|
json_data = json.dumps({"entries": totp_entries}, indent=2)
|
||||||
|
|
||||||
|
if confirm_action("Encrypt export with a password? (Y/N): "):
|
||||||
|
password = prompt_new_password()
|
||||||
|
key = derive_key_from_password(password)
|
||||||
|
enc_mgr = EncryptionManager(key, dest.parent)
|
||||||
|
data_bytes = enc_mgr.encrypt_data(json_data.encode("utf-8"))
|
||||||
|
dest = dest.with_suffix(dest.suffix + ".enc")
|
||||||
|
dest.write_bytes(data_bytes)
|
||||||
|
else:
|
||||||
|
dest.write_text(json_data)
|
||||||
|
|
||||||
|
os.chmod(dest, 0o600)
|
||||||
|
print(colored(f"2FA codes exported to '{dest}'.", "green"))
|
||||||
|
return dest
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to export TOTP codes: {e}", exc_info=True)
|
||||||
|
print(colored(f"Error: Failed to export 2FA codes: {e}", "red"))
|
||||||
|
return None
|
||||||
|
|
||||||
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.
|
||||||
@@ -1456,3 +1826,93 @@ class PasswordManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to change password: {e}", exc_info=True)
|
logging.error(f"Failed to change password: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to change password: {e}", "red"))
|
print(colored(f"Error: Failed to change password: {e}", "red"))
|
||||||
|
|
||||||
|
def get_profile_stats(self) -> dict:
|
||||||
|
"""Return various statistics about the current seed profile."""
|
||||||
|
if not all([self.entry_manager, self.config_manager, self.backup_manager]):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
stats: dict[str, object] = {}
|
||||||
|
|
||||||
|
# Entry counts by type
|
||||||
|
data = self.entry_manager.vault.load_index()
|
||||||
|
entries = data.get("entries", {})
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for entry in entries.values():
|
||||||
|
etype = entry.get("type", EntryType.PASSWORD.value)
|
||||||
|
counts[etype] = counts.get(etype, 0) + 1
|
||||||
|
stats["entries"] = counts
|
||||||
|
stats["total_entries"] = len(entries)
|
||||||
|
|
||||||
|
# Schema version and checksum status
|
||||||
|
stats["schema_version"] = data.get("schema_version")
|
||||||
|
current_checksum = json_checksum(data)
|
||||||
|
chk_path = self.entry_manager.checksum_file
|
||||||
|
if chk_path.exists():
|
||||||
|
stored = chk_path.read_text().strip()
|
||||||
|
stats["checksum_ok"] = stored == current_checksum
|
||||||
|
else:
|
||||||
|
stored = None
|
||||||
|
stats["checksum_ok"] = False
|
||||||
|
stats["checksum"] = stored
|
||||||
|
|
||||||
|
# Relay info
|
||||||
|
cfg = self.config_manager.load_config(require_pin=False)
|
||||||
|
relays = cfg.get("relays", [])
|
||||||
|
stats["relays"] = relays
|
||||||
|
stats["relay_count"] = len(relays)
|
||||||
|
|
||||||
|
# Backup info
|
||||||
|
backups = list(
|
||||||
|
self.backup_manager.backup_dir.glob("entries_db_backup_*.json.enc")
|
||||||
|
)
|
||||||
|
stats["backup_count"] = len(backups)
|
||||||
|
stats["backup_dir"] = str(self.backup_manager.backup_dir)
|
||||||
|
stats["additional_backup_path"] = (
|
||||||
|
self.config_manager.get_additional_backup_path()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nostr sync info
|
||||||
|
manifest = getattr(self.nostr_client, "current_manifest", None)
|
||||||
|
if manifest is not None:
|
||||||
|
stats["chunk_count"] = len(manifest.chunks)
|
||||||
|
stats["delta_since"] = manifest.delta_since
|
||||||
|
else:
|
||||||
|
stats["chunk_count"] = 0
|
||||||
|
stats["delta_since"] = None
|
||||||
|
stats["pending_deltas"] = len(getattr(self.nostr_client, "_delta_events", []))
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def display_stats(self) -> None:
|
||||||
|
"""Print a summary of :meth:`get_profile_stats` to the console."""
|
||||||
|
stats = self.get_profile_stats()
|
||||||
|
if not stats:
|
||||||
|
print(colored("No statistics available.", "red"))
|
||||||
|
return
|
||||||
|
|
||||||
|
print(colored("\n=== Seed Profile Stats ===", "yellow"))
|
||||||
|
print(colored(f"Total entries: {stats['total_entries']}", "cyan"))
|
||||||
|
for etype, count in stats["entries"].items():
|
||||||
|
print(colored(f" {etype}: {count}", "cyan"))
|
||||||
|
print(colored(f"Relays configured: {stats['relay_count']}", "cyan"))
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
f"Backups: {stats['backup_count']} (dir: {stats['backup_dir']})", "cyan"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if stats.get("additional_backup_path"):
|
||||||
|
print(
|
||||||
|
colored(f"Additional backup: {stats['additional_backup_path']}", "cyan")
|
||||||
|
)
|
||||||
|
print(colored(f"Schema version: {stats['schema_version']}", "cyan"))
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
f"Checksum ok: {'yes' if stats['checksum_ok'] else 'no'}",
|
||||||
|
"cyan",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print(colored(f"Snapshot chunks: {stats['chunk_count']}", "cyan"))
|
||||||
|
print(colored(f"Pending deltas: {stats['pending_deltas']}", "cyan"))
|
||||||
|
if stats.get("delta_since"):
|
||||||
|
print(colored(f"Latest delta id: {stats['delta_since']}", "cyan"))
|
||||||
|
@@ -335,12 +335,6 @@ class PasswordGenerator:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def derive_totp_secret(bip85: BIP85, idx: int) -> str:
|
|
||||||
"""Derive a TOTP secret for the given index using BIP85."""
|
|
||||||
entropy = bip85.derive_entropy(index=idx, bytes_len=10, app_no=2)
|
|
||||||
return base64.b32encode(entropy).decode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def derive_ssh_key(bip85: BIP85, idx: int) -> bytes:
|
def derive_ssh_key(bip85: BIP85, idx: int) -> bytes:
|
||||||
"""Derive 32 bytes of entropy suitable for an SSH key."""
|
"""Derive 32 bytes of entropy suitable for an SSH key."""
|
||||||
return bip85.derive_entropy(index=idx, bytes_len=32, app_no=32)
|
return bip85.derive_entropy(index=idx, bytes_len=32, app_no=32)
|
||||||
|
83
src/password_manager/totp.py
Normal file
83
src/password_manager/totp.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""TOTP management utilities for SeedPass."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.parse import urlparse, parse_qs, unquote
|
||||||
|
|
||||||
|
import pyotp
|
||||||
|
|
||||||
|
from utils import key_derivation
|
||||||
|
|
||||||
|
|
||||||
|
class TotpManager:
|
||||||
|
"""Helper methods for TOTP secrets and codes."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def derive_secret(seed: str, index: int) -> str:
|
||||||
|
"""Derive a TOTP secret from a BIP39 seed and index."""
|
||||||
|
return key_derivation.derive_totp_secret(seed, index)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def current_code(cls, seed: str, index: int, timestamp: int | None = None) -> str:
|
||||||
|
"""Return the TOTP code for the given seed and index."""
|
||||||
|
secret = cls.derive_secret(seed, index)
|
||||||
|
totp = pyotp.TOTP(secret)
|
||||||
|
if timestamp is None:
|
||||||
|
return totp.now()
|
||||||
|
return totp.at(timestamp)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def current_code_from_secret(secret: str, timestamp: int | None = None) -> str:
|
||||||
|
"""Return the TOTP code for a raw secret."""
|
||||||
|
totp = pyotp.TOTP(secret)
|
||||||
|
return totp.now() if timestamp is None else totp.at(timestamp)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_otpauth(uri: str) -> tuple[str, str, int, int]:
|
||||||
|
"""Parse an otpauth URI and return (label, secret, period, digits)."""
|
||||||
|
if not uri.startswith("otpauth://"):
|
||||||
|
raise ValueError("Not an otpauth URI")
|
||||||
|
parsed = urlparse(uri)
|
||||||
|
label = unquote(parsed.path.lstrip("/"))
|
||||||
|
qs = parse_qs(parsed.query)
|
||||||
|
secret = qs.get("secret", [""])[0].upper()
|
||||||
|
period = int(qs.get("period", ["30"])[0])
|
||||||
|
digits = int(qs.get("digits", ["6"])[0])
|
||||||
|
if not secret:
|
||||||
|
raise ValueError("Missing secret in URI")
|
||||||
|
return label, secret, period, digits
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def make_otpauth_uri(
|
||||||
|
label: str, secret: str, period: int = 30, digits: int = 6
|
||||||
|
) -> str:
|
||||||
|
"""Construct an otpauth:// URI for use with authenticator apps."""
|
||||||
|
label_enc = quote(label)
|
||||||
|
return f"otpauth://totp/{label_enc}?secret={secret}&period={period}&digits={digits}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def time_remaining(period: int = 30, timestamp: int | None = None) -> int:
|
||||||
|
"""Return seconds remaining until the current TOTP period resets."""
|
||||||
|
if timestamp is None:
|
||||||
|
timestamp = int(time.time())
|
||||||
|
return period - (timestamp % period)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def print_progress_bar(cls, period: int = 30) -> None:
|
||||||
|
"""Print a simple progress bar for the current TOTP period."""
|
||||||
|
remaining = cls.time_remaining(period)
|
||||||
|
total = period
|
||||||
|
bar_len = 20
|
||||||
|
while remaining > 0:
|
||||||
|
progress = total - remaining
|
||||||
|
filled = int(bar_len * progress / total)
|
||||||
|
bar = "[" + "#" * filled + "-" * (bar_len - filled) + "]"
|
||||||
|
sys.stdout.write(f"\r{bar} {remaining:2d}s")
|
||||||
|
sys.stdout.flush()
|
||||||
|
time.sleep(1)
|
||||||
|
remaining -= 1
|
||||||
|
sys.stdout.write("\n")
|
||||||
|
sys.stdout.flush()
|
@@ -18,3 +18,6 @@ websockets>=15.0.0
|
|||||||
tomli
|
tomli
|
||||||
hypothesis
|
hypothesis
|
||||||
mutmut==2.4.4
|
mutmut==2.4.4
|
||||||
|
pyotp>=2.8.0
|
||||||
|
|
||||||
|
freezegun
|
||||||
|
37
src/tests/test_additional_backup.py
Normal file
37
src/tests/test_additional_backup.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||||
|
|
||||||
|
from password_manager.entry_management import EntryManager
|
||||||
|
from password_manager.backup import BackupManager
|
||||||
|
from password_manager.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_manager_additional_backup(monkeypatch):
|
||||||
|
with TemporaryDirectory() as tmpdir, TemporaryDirectory() as extra:
|
||||||
|
fp_dir = Path(tmpdir)
|
||||||
|
vault, _ = create_vault(fp_dir, TEST_SEED, TEST_PASSWORD)
|
||||||
|
cfg_mgr = ConfigManager(vault, fp_dir)
|
||||||
|
cfg_mgr.set_additional_backup_path(extra)
|
||||||
|
backup_mgr = BackupManager(fp_dir, cfg_mgr)
|
||||||
|
entry_mgr = EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
|
monkeypatch.setattr(time, "time", lambda: 1111)
|
||||||
|
entry_mgr.add_entry("example.com", 12)
|
||||||
|
|
||||||
|
backup = fp_dir / "backups" / "entries_db_backup_1111.json.enc"
|
||||||
|
extra_file = Path(extra) / f"{fp_dir.name}_entries_db_backup_1111.json.enc"
|
||||||
|
assert backup.exists()
|
||||||
|
assert extra_file.exists()
|
||||||
|
|
||||||
|
cfg_mgr.set_additional_backup_path(None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(time, "time", lambda: 2222)
|
||||||
|
entry_mgr.add_entry("example.org", 8)
|
||||||
|
|
||||||
|
backup2 = fp_dir / "backups" / "entries_db_backup_2222.json.enc"
|
||||||
|
assert backup2.exists()
|
||||||
|
extra_file2 = Path(extra) / f"{fp_dir.name}_entries_db_backup_2222.json.enc"
|
||||||
|
assert not extra_file2.exists()
|
@@ -31,7 +31,7 @@ def test_auto_sync_triggers_post(monkeypatch):
|
|||||||
called = True
|
called = True
|
||||||
|
|
||||||
monkeypatch.setattr(main, "handle_post_to_nostr", fake_post)
|
monkeypatch.setattr(main, "handle_post_to_nostr", fake_post)
|
||||||
monkeypatch.setattr(main, "timed_input", lambda *_: "5")
|
monkeypatch.setattr(main, "timed_input", lambda *_: "6")
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
main.display_menu(pm, sync_interval=0.1)
|
main.display_menu(pm, sync_interval=0.1)
|
||||||
|
@@ -9,13 +9,15 @@ 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.backup import BackupManager
|
from password_manager.backup import BackupManager
|
||||||
|
from password_manager.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
vault, enc_mgr = create_vault(fp_dir, TEST_SEED, TEST_PASSWORD)
|
vault, enc_mgr = create_vault(fp_dir, TEST_SEED, TEST_PASSWORD)
|
||||||
backup_mgr = BackupManager(fp_dir)
|
cfg_mgr = ConfigManager(vault, fp_dir)
|
||||||
|
backup_mgr = BackupManager(fp_dir, cfg_mgr)
|
||||||
|
|
||||||
index_file = fp_dir / "seedpass_entries_db.json.enc"
|
index_file = fp_dir / "seedpass_entries_db.json.enc"
|
||||||
|
|
||||||
@@ -61,3 +63,24 @@ def test_backup_restore_workflow(monkeypatch):
|
|||||||
current = vault.load_index()
|
current = vault.load_index()
|
||||||
backup_mgr.restore_backup_by_timestamp(1111)
|
backup_mgr.restore_backup_by_timestamp(1111)
|
||||||
assert vault.load_index() == current
|
assert vault.load_index() == current
|
||||||
|
|
||||||
|
|
||||||
|
def test_additional_backup_location(monkeypatch):
|
||||||
|
with TemporaryDirectory() as tmpdir, TemporaryDirectory() as extra:
|
||||||
|
fp_dir = Path(tmpdir)
|
||||||
|
vault, enc_mgr = create_vault(fp_dir, TEST_SEED, TEST_PASSWORD)
|
||||||
|
cfg_mgr = ConfigManager(vault, fp_dir)
|
||||||
|
cfg_mgr.set_additional_backup_path(extra)
|
||||||
|
backup_mgr = BackupManager(fp_dir, cfg_mgr)
|
||||||
|
|
||||||
|
vault.save_index({"schema_version": 2, "entries": {"a": {}}})
|
||||||
|
|
||||||
|
monkeypatch.setattr(time, "time", lambda: 3333)
|
||||||
|
backup_mgr.create_backup()
|
||||||
|
|
||||||
|
backup = fp_dir / "backups" / "entries_db_backup_3333.json.enc"
|
||||||
|
assert backup.exists()
|
||||||
|
|
||||||
|
extra_file = Path(extra) / f"{fp_dir.name}_entries_db_backup_3333.json.enc"
|
||||||
|
assert extra_file.exists()
|
||||||
|
assert extra_file.stat().st_mode & 0o777 == 0o600
|
||||||
|
@@ -6,10 +6,10 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
|
|||||||
|
|
||||||
from local_bip85.bip85 import BIP85, Bip85Error
|
from local_bip85.bip85 import BIP85, Bip85Error
|
||||||
from password_manager.password_generation import (
|
from password_manager.password_generation import (
|
||||||
derive_totp_secret,
|
|
||||||
derive_ssh_key,
|
derive_ssh_key,
|
||||||
derive_seed_phrase,
|
derive_seed_phrase,
|
||||||
)
|
)
|
||||||
|
from utils.key_derivation import derive_totp_secret
|
||||||
|
|
||||||
MASTER_XPRV = "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb"
|
MASTER_XPRV = "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb"
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ EXPECTED_12 = "girl mad pet galaxy egg matter matrix prison refuse sense ordinar
|
|||||||
EXPECTED_24 = "puppy ocean match cereal symbol another shed magic wrap hammer bulb intact gadget divorce twin tonight reason outdoor destroy simple truth cigar social volcano"
|
EXPECTED_24 = "puppy ocean match cereal symbol another shed magic wrap hammer bulb intact gadget divorce twin tonight reason outdoor destroy simple truth cigar social volcano"
|
||||||
|
|
||||||
EXPECTED_SYMM_KEY = "7040bb53104f27367f317558e78a994ada7296c6fde36a364e5baf206e502bb1"
|
EXPECTED_SYMM_KEY = "7040bb53104f27367f317558e78a994ada7296c6fde36a364e5baf206e502bb1"
|
||||||
EXPECTED_TOTP_SECRET = "OBALWUYQJ4TTM7ZR"
|
EXPECTED_TOTP_SECRET = "VQYTWDNEWYBY2G3LOGGCEKR4LZ3LNEYY"
|
||||||
EXPECTED_SSH_KEY = "52405cd0dd21c5be78314a7c1a3c65ffd8d896536cc7dee3157db5824f0c92e2"
|
EXPECTED_SSH_KEY = "52405cd0dd21c5be78314a7c1a3c65ffd8d896536cc7dee3157db5824f0c92e2"
|
||||||
|
|
||||||
|
|
||||||
@@ -39,8 +39,8 @@ def test_bip85_symmetric_key(bip85):
|
|||||||
assert bip85.derive_symmetric_key(index=0).hex() == EXPECTED_SYMM_KEY
|
assert bip85.derive_symmetric_key(index=0).hex() == EXPECTED_SYMM_KEY
|
||||||
|
|
||||||
|
|
||||||
def test_derive_totp_secret(bip85):
|
def test_derive_totp_secret():
|
||||||
assert derive_totp_secret(bip85, 0) == EXPECTED_TOTP_SECRET
|
assert derive_totp_secret(EXPECTED_24, 0) == EXPECTED_TOTP_SECRET
|
||||||
|
|
||||||
|
|
||||||
def test_derive_ssh_key(bip85):
|
def test_derive_ssh_key(bip85):
|
||||||
|
@@ -39,6 +39,7 @@ def _make_pm(called, locked=None):
|
|||||||
last_activity=time.time(),
|
last_activity=time.time(),
|
||||||
nostr_client=SimpleNamespace(close_client_pool=lambda: None),
|
nostr_client=SimpleNamespace(close_client_pool=lambda: None),
|
||||||
handle_add_password=add,
|
handle_add_password=add,
|
||||||
|
handle_add_totp=lambda: None,
|
||||||
handle_retrieve_entry=retrieve,
|
handle_retrieve_entry=retrieve,
|
||||||
handle_modify_entry=modify,
|
handle_modify_entry=modify,
|
||||||
update_activity=update,
|
update_activity=update,
|
||||||
@@ -51,7 +52,7 @@ def _make_pm(called, locked=None):
|
|||||||
def test_empty_and_non_numeric_choice(monkeypatch, capsys):
|
def test_empty_and_non_numeric_choice(monkeypatch, capsys):
|
||||||
called = {"add": False, "retrieve": False, "modify": False}
|
called = {"add": False, "retrieve": False, "modify": False}
|
||||||
pm, _ = _make_pm(called)
|
pm, _ = _make_pm(called)
|
||||||
inputs = iter(["", "abc", "5"])
|
inputs = iter(["", "abc", "6"])
|
||||||
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
|
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000)
|
main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000)
|
||||||
@@ -64,7 +65,7 @@ def test_empty_and_non_numeric_choice(monkeypatch, capsys):
|
|||||||
def test_out_of_range_menu(monkeypatch, capsys):
|
def test_out_of_range_menu(monkeypatch, capsys):
|
||||||
called = {"add": False, "retrieve": False, "modify": False}
|
called = {"add": False, "retrieve": False, "modify": False}
|
||||||
pm, _ = _make_pm(called)
|
pm, _ = _make_pm(called)
|
||||||
inputs = iter(["9", "5"])
|
inputs = iter(["9", "6"])
|
||||||
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
|
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000)
|
main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000)
|
||||||
@@ -76,7 +77,7 @@ def test_out_of_range_menu(monkeypatch, capsys):
|
|||||||
def test_invalid_add_entry_submenu(monkeypatch, capsys):
|
def test_invalid_add_entry_submenu(monkeypatch, capsys):
|
||||||
called = {"add": False, "retrieve": False, "modify": False}
|
called = {"add": False, "retrieve": False, "modify": False}
|
||||||
pm, _ = _make_pm(called)
|
pm, _ = _make_pm(called)
|
||||||
inputs = iter(["1", "3", "2", "5"])
|
inputs = iter(["1", "4", "3", "6"])
|
||||||
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
|
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
|
||||||
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
|
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
@@ -91,7 +92,7 @@ def test_inactivity_timeout_loop(monkeypatch, capsys):
|
|||||||
pm, locked = _make_pm(called)
|
pm, locked = _make_pm(called)
|
||||||
pm.last_activity = 0
|
pm.last_activity = 0
|
||||||
monkeypatch.setattr(time, "time", lambda: 100.0)
|
monkeypatch.setattr(time, "time", lambda: 100.0)
|
||||||
monkeypatch.setattr(main, "timed_input", lambda *_: "5")
|
monkeypatch.setattr(main, "timed_input", lambda *_: "6")
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1)
|
main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1)
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
|
@@ -9,6 +9,7 @@ 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 password_manager.config_manager import ConfigManager
|
||||||
from utils.key_derivation import derive_index_key, derive_key_from_password
|
from utils.key_derivation import derive_index_key, derive_key_from_password
|
||||||
|
|
||||||
|
|
||||||
@@ -34,9 +35,12 @@ def _reader(index_key: bytes, dir_path: Path, loops: int, out: Queue) -> None:
|
|||||||
out.put(repr(e))
|
out.put(repr(e))
|
||||||
|
|
||||||
|
|
||||||
def _backup(dir_path: Path, loops: int, out: Queue) -> None:
|
def _backup(index_key: bytes, dir_path: Path, loops: int, out: Queue) -> None:
|
||||||
try:
|
try:
|
||||||
bm = BackupManager(dir_path)
|
enc = EncryptionManager(index_key, dir_path)
|
||||||
|
vault = Vault(enc, dir_path)
|
||||||
|
cfg = ConfigManager(vault, dir_path)
|
||||||
|
bm = BackupManager(dir_path, cfg)
|
||||||
for _ in range(loops):
|
for _ in range(loops):
|
||||||
bm.create_backup()
|
bm.create_backup()
|
||||||
except Exception as e: # pragma: no cover - capture
|
except Exception as e: # pragma: no cover - capture
|
||||||
@@ -58,7 +62,7 @@ def test_concurrency_stress(tmp_path: Path, loops: int, _):
|
|||||||
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=_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)),
|
Process(target=_backup, args=(index_key, tmp_path, loops, q)),
|
||||||
]
|
]
|
||||||
|
|
||||||
for p in procs:
|
for p in procs:
|
||||||
|
@@ -22,6 +22,7 @@ def test_config_defaults_and_round_trip():
|
|||||||
assert cfg["relays"] == list(DEFAULT_RELAYS)
|
assert cfg["relays"] == list(DEFAULT_RELAYS)
|
||||||
assert cfg["pin_hash"] == ""
|
assert cfg["pin_hash"] == ""
|
||||||
assert cfg["password_hash"] == ""
|
assert cfg["password_hash"] == ""
|
||||||
|
assert cfg["additional_backup_path"] == ""
|
||||||
|
|
||||||
cfg_mgr.set_pin("1234")
|
cfg_mgr.set_pin("1234")
|
||||||
cfg_mgr.set_relays(["wss://example.com"], require_pin=False)
|
cfg_mgr.set_relays(["wss://example.com"], require_pin=False)
|
||||||
@@ -111,3 +112,21 @@ def test_password_hash_migrates_from_file(tmp_path):
|
|||||||
(tmp_path / "hashed_password.enc").unlink()
|
(tmp_path / "hashed_password.enc").unlink()
|
||||||
cfg2 = cfg_mgr.load_config(require_pin=False)
|
cfg2 = cfg_mgr.load_config(require_pin=False)
|
||||||
assert cfg2["password_hash"] == hashed.decode()
|
assert cfg2["password_hash"] == hashed.decode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_additional_backup_path_round_trip():
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
|
||||||
|
cfg_mgr = ConfigManager(vault, Path(tmpdir))
|
||||||
|
|
||||||
|
# default should be empty string
|
||||||
|
assert cfg_mgr.load_config(require_pin=False)["additional_backup_path"] == ""
|
||||||
|
|
||||||
|
cfg_mgr.set_additional_backup_path("/tmp/my_backups")
|
||||||
|
cfg = cfg_mgr.load_config(require_pin=False)
|
||||||
|
assert cfg["additional_backup_path"] == "/tmp/my_backups"
|
||||||
|
assert cfg_mgr.get_additional_backup_path() == "/tmp/my_backups"
|
||||||
|
|
||||||
|
cfg_mgr.set_additional_backup_path(None)
|
||||||
|
cfg2 = cfg_mgr.load_config(require_pin=False)
|
||||||
|
assert cfg2["additional_backup_path"] == ""
|
||||||
|
@@ -6,13 +6,17 @@ 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.entry_management import EntryManager
|
from password_manager.entry_management import EntryManager
|
||||||
|
from password_manager.backup import BackupManager
|
||||||
from password_manager.vault import Vault
|
from password_manager.vault import Vault
|
||||||
|
from password_manager.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
def test_list_entries_empty():
|
def test_list_entries_empty():
|
||||||
with TemporaryDirectory() as tmpdir:
|
with TemporaryDirectory() as tmpdir:
|
||||||
vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
|
vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
|
||||||
entry_mgr = EntryManager(vault, Path(tmpdir))
|
cfg_mgr = ConfigManager(vault, Path(tmpdir))
|
||||||
|
backup_mgr = BackupManager(Path(tmpdir), cfg_mgr)
|
||||||
|
entry_mgr = EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
entries = entry_mgr.list_entries()
|
entries = entry_mgr.list_entries()
|
||||||
assert entries == []
|
assert entries == []
|
||||||
|
@@ -1,19 +1,25 @@
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
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.entry_management import EntryManager
|
from password_manager.entry_management import EntryManager
|
||||||
|
from password_manager.backup import BackupManager
|
||||||
from password_manager.vault import Vault
|
from password_manager.vault import Vault
|
||||||
|
from password_manager.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
def test_add_and_retrieve_entry():
|
def test_add_and_retrieve_entry():
|
||||||
with TemporaryDirectory() as tmpdir:
|
with TemporaryDirectory() as tmpdir:
|
||||||
vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
|
vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
|
||||||
entry_mgr = EntryManager(vault, Path(tmpdir))
|
cfg_mgr = ConfigManager(vault, Path(tmpdir))
|
||||||
|
backup_mgr = BackupManager(Path(tmpdir), cfg_mgr)
|
||||||
|
entry_mgr = EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
index = entry_mgr.add_entry("example.com", 12, "user")
|
index = entry_mgr.add_entry("example.com", 12, "user")
|
||||||
entry = entry_mgr.retrieve_entry(index)
|
entry = entry_mgr.retrieve_entry(index)
|
||||||
@@ -45,10 +51,15 @@ def test_add_and_retrieve_entry():
|
|||||||
def test_round_trip_entry_types(method, expected_type):
|
def test_round_trip_entry_types(method, expected_type):
|
||||||
with TemporaryDirectory() as tmpdir:
|
with TemporaryDirectory() as tmpdir:
|
||||||
vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
|
vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
|
||||||
entry_mgr = EntryManager(vault, Path(tmpdir))
|
cfg_mgr = ConfigManager(vault, Path(tmpdir))
|
||||||
|
backup_mgr = BackupManager(Path(tmpdir), cfg_mgr)
|
||||||
|
entry_mgr = EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
if method == "add_entry":
|
if method == "add_entry":
|
||||||
index = entry_mgr.add_entry("example.com", 8)
|
index = entry_mgr.add_entry("example.com", 8)
|
||||||
|
elif method == "add_totp":
|
||||||
|
entry_mgr.add_totp("example", TEST_SEED)
|
||||||
|
index = 0
|
||||||
else:
|
else:
|
||||||
with pytest.raises(NotImplementedError):
|
with pytest.raises(NotImplementedError):
|
||||||
getattr(entry_mgr, method)()
|
getattr(entry_mgr, method)()
|
||||||
@@ -58,3 +69,20 @@ def test_round_trip_entry_types(method, expected_type):
|
|||||||
assert entry["type"] == expected_type
|
assert entry["type"] == expected_type
|
||||||
data = enc_mgr.load_json_data(entry_mgr.index_file)
|
data = enc_mgr.load_json_data(entry_mgr.index_file)
|
||||||
assert data["entries"][str(index)]["type"] == expected_type
|
assert data["entries"][str(index)]["type"] == expected_type
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_entry_defaults_to_password():
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
|
||||||
|
cfg_mgr = ConfigManager(vault, Path(tmpdir))
|
||||||
|
backup_mgr = BackupManager(Path(tmpdir), cfg_mgr)
|
||||||
|
entry_mgr = EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
|
index = entry_mgr.add_entry("example.com", 8)
|
||||||
|
|
||||||
|
data = enc_mgr.load_json_data(entry_mgr.index_file)
|
||||||
|
data["entries"][str(index)].pop("type", None)
|
||||||
|
enc_mgr.save_json_data(data, entry_mgr.index_file)
|
||||||
|
|
||||||
|
loaded = entry_mgr._load_index()
|
||||||
|
assert loaded["entries"][str(index)]["type"] == "password"
|
||||||
|
@@ -6,14 +6,18 @@ 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.entry_management import EntryManager
|
from password_manager.entry_management import EntryManager
|
||||||
|
from password_manager.backup import BackupManager
|
||||||
from password_manager.vault import Vault
|
from password_manager.vault import Vault
|
||||||
|
from password_manager.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
|
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
|
||||||
entry_mgr = EntryManager(vault, tmp_path)
|
cfg_mgr = ConfigManager(vault, tmp_path)
|
||||||
|
backup_mgr = BackupManager(tmp_path, cfg_mgr)
|
||||||
|
entry_mgr = EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
# create an empty index file
|
# create an empty index file
|
||||||
vault.save_index({"entries": {}})
|
vault.save_index({"entries": {}})
|
||||||
@@ -27,10 +31,13 @@ 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)
|
||||||
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
|
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
|
||||||
entry_mgr = EntryManager(vault, tmp_path)
|
cfg_mgr = ConfigManager(vault, tmp_path)
|
||||||
|
backup_mgr = BackupManager(tmp_path, cfg_mgr)
|
||||||
|
entry_mgr = EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
vault.save_index({"entries": {}})
|
vault.save_index({"entries": {}})
|
||||||
entry_mgr.backup_index_file()
|
entry_mgr.backup_manager.create_backup()
|
||||||
|
|
||||||
backups = list(tmp_path.glob("entries_db_backup_*.json.enc"))
|
backup_dir = tmp_path / "backups"
|
||||||
|
backups = list(backup_dir.glob("entries_db_backup_*.json.enc"))
|
||||||
assert len(backups) == 1
|
assert len(backups) == 1
|
||||||
|
43
src/tests/test_event_handler.py
Normal file
43
src/tests/test_event_handler.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from nostr import event_handler
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleEvent:
|
||||||
|
def __init__(self, id: str, created_at: int, content: str) -> None:
|
||||||
|
self.id = id
|
||||||
|
self.created_at = created_at
|
||||||
|
self.content = content
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_new_event_logs(caplog):
|
||||||
|
handler = event_handler.EventHandler()
|
||||||
|
evt = SimpleEvent("1", 0, "hello")
|
||||||
|
|
||||||
|
caplog.set_level(logging.INFO, logger=event_handler.logger.name)
|
||||||
|
handler.handle_new_event(evt)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"[New Event] ID: 1 | Created At: 1970-01-01 00:00:00 | Content: hello"
|
||||||
|
in caplog.text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_new_event_error(monkeypatch, caplog):
|
||||||
|
handler = event_handler.EventHandler()
|
||||||
|
evt = SimpleEvent("2", 0, "boom")
|
||||||
|
|
||||||
|
def raise_info(*args, **kwargs):
|
||||||
|
raise RuntimeError("fail")
|
||||||
|
|
||||||
|
monkeypatch.setattr(event_handler.logger, "info", raise_info)
|
||||||
|
caplog.set_level(logging.ERROR, logger=event_handler.logger.name)
|
||||||
|
|
||||||
|
handler.handle_new_event(evt)
|
||||||
|
|
||||||
|
assert "Error handling new event: fail" in caplog.text
|
57
src/tests/test_export_totp_codes.py
Normal file
57
src/tests/test_export_totp_codes.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||||
|
|
||||||
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from password_manager.entry_management import EntryManager
|
||||||
|
from password_manager.backup import BackupManager
|
||||||
|
from password_manager.manager import PasswordManager, EncryptionMode
|
||||||
|
from password_manager.config_manager import ConfigManager
|
||||||
|
from password_manager.totp import TotpManager
|
||||||
|
|
||||||
|
|
||||||
|
class FakeNostrClient:
|
||||||
|
def publish_snapshot(self, data: bytes):
|
||||||
|
return None, "abcd"
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_export_totp_codes(monkeypatch, tmp_path):
|
||||||
|
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
|
||||||
|
cfg_mgr = ConfigManager(vault, tmp_path)
|
||||||
|
backup_mgr = BackupManager(tmp_path, cfg_mgr)
|
||||||
|
entry_mgr = EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
|
pm = PasswordManager.__new__(PasswordManager)
|
||||||
|
pm.encryption_mode = EncryptionMode.SEED_ONLY
|
||||||
|
pm.encryption_manager = enc_mgr
|
||||||
|
pm.vault = vault
|
||||||
|
pm.entry_manager = entry_mgr
|
||||||
|
pm.backup_manager = backup_mgr
|
||||||
|
pm.parent_seed = TEST_SEED
|
||||||
|
pm.nostr_client = FakeNostrClient()
|
||||||
|
pm.fingerprint_dir = tmp_path
|
||||||
|
|
||||||
|
# add totp entries
|
||||||
|
entry_mgr.add_totp("Example", TEST_SEED)
|
||||||
|
entry_mgr.add_totp("Imported", TEST_SEED, secret="JBSWY3DPEHPK3PXP")
|
||||||
|
|
||||||
|
export_path = tmp_path / "out.json"
|
||||||
|
monkeypatch.setattr("builtins.input", lambda *a, **k: str(export_path))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"password_manager.manager.confirm_action", lambda *_a, **_k: False
|
||||||
|
)
|
||||||
|
|
||||||
|
pm.handle_export_totp_codes()
|
||||||
|
|
||||||
|
data = json.loads(export_path.read_text())
|
||||||
|
assert len(data["entries"]) == 2
|
||||||
|
labels = {e["label"] for e in data["entries"]}
|
||||||
|
assert {"Example", "Imported"} == labels
|
||||||
|
# check URI format
|
||||||
|
uri = data["entries"][0]["uri"]
|
||||||
|
assert uri.startswith("otpauth://totp/")
|
@@ -36,7 +36,7 @@ def test_inactivity_triggers_lock(monkeypatch):
|
|||||||
unlock_vault=unlock_vault,
|
unlock_vault=unlock_vault,
|
||||||
)
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(main, "timed_input", lambda *_: "5")
|
monkeypatch.setattr(main, "timed_input", lambda *_: "6")
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1)
|
main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1)
|
||||||
@@ -72,7 +72,7 @@ def test_input_timeout_triggers_lock(monkeypatch):
|
|||||||
unlock_vault=unlock_vault,
|
unlock_vault=unlock_vault,
|
||||||
)
|
)
|
||||||
|
|
||||||
responses = iter([TimeoutError(), "5"])
|
responses = iter([TimeoutError(), "6"])
|
||||||
|
|
||||||
def fake_input(*_args, **_kwargs):
|
def fake_input(*_args, **_kwargs):
|
||||||
val = next(responses)
|
val = next(responses)
|
||||||
|
29
src/tests/test_key_manager_helpers.py
Normal file
29
src/tests/test_key_manager_helpers.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import pytest
|
||||||
|
from bech32 import bech32_encode, convertbits
|
||||||
|
|
||||||
|
from nostr.key_manager import KeyManager
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_manager_getters(monkeypatch):
|
||||||
|
priv_hex = "1" * 64
|
||||||
|
pub_hex = "2" * 64
|
||||||
|
|
||||||
|
class DummyKeys:
|
||||||
|
def public_key_hex(self):
|
||||||
|
return pub_hex
|
||||||
|
|
||||||
|
def private_key_hex(self):
|
||||||
|
return priv_hex
|
||||||
|
|
||||||
|
monkeypatch.setattr(KeyManager, "initialize_bip85", lambda self: None)
|
||||||
|
monkeypatch.setattr(KeyManager, "generate_nostr_keys", lambda self: DummyKeys())
|
||||||
|
|
||||||
|
km = KeyManager("seed", "fp")
|
||||||
|
|
||||||
|
assert km.get_public_key_hex() == pub_hex
|
||||||
|
assert km.get_private_key_hex() == priv_hex
|
||||||
|
|
||||||
|
expected_npub = bech32_encode(
|
||||||
|
"npub", convertbits(bytes.fromhex(pub_hex), 8, 5, True)
|
||||||
|
)
|
||||||
|
assert km.get_npub() == expected_npub
|
66
src/tests/test_manager_add_totp.py
Normal file
66
src/tests/test_manager_add_totp.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||||
|
|
||||||
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from password_manager.entry_management import EntryManager
|
||||||
|
from password_manager.backup import BackupManager
|
||||||
|
from password_manager.manager import PasswordManager, EncryptionMode
|
||||||
|
from password_manager.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
|
class FakeNostrClient:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.published = []
|
||||||
|
|
||||||
|
def publish_snapshot(self, data: bytes):
|
||||||
|
self.published.append(data)
|
||||||
|
return None, "abcd"
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_add_totp(monkeypatch, capsys):
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
tmp_path = Path(tmpdir)
|
||||||
|
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
|
||||||
|
cfg_mgr = ConfigManager(vault, tmp_path)
|
||||||
|
backup_mgr = BackupManager(tmp_path, cfg_mgr)
|
||||||
|
entry_mgr = EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
|
pm = PasswordManager.__new__(PasswordManager)
|
||||||
|
pm.encryption_mode = EncryptionMode.SEED_ONLY
|
||||||
|
pm.encryption_manager = enc_mgr
|
||||||
|
pm.vault = vault
|
||||||
|
pm.entry_manager = entry_mgr
|
||||||
|
pm.backup_manager = backup_mgr
|
||||||
|
pm.parent_seed = TEST_SEED
|
||||||
|
pm.nostr_client = FakeNostrClient()
|
||||||
|
pm.fingerprint_dir = tmp_path
|
||||||
|
pm.is_dirty = False
|
||||||
|
|
||||||
|
inputs = iter(
|
||||||
|
[
|
||||||
|
"1", # choose derive
|
||||||
|
"Example", # label
|
||||||
|
"", # period
|
||||||
|
"", # digits
|
||||||
|
]
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("builtins.input", lambda *args, **kwargs: next(inputs))
|
||||||
|
monkeypatch.setattr(pm, "sync_vault", lambda: None)
|
||||||
|
|
||||||
|
pm.handle_add_totp()
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
|
||||||
|
entry = entry_mgr.retrieve_entry(0)
|
||||||
|
assert entry == {
|
||||||
|
"type": "totp",
|
||||||
|
"label": "Example",
|
||||||
|
"index": 0,
|
||||||
|
"period": 30,
|
||||||
|
"digits": 6,
|
||||||
|
}
|
||||||
|
assert "ID 0" in out
|
99
src/tests/test_manager_display_totp_codes.py
Normal file
99
src/tests/test_manager_display_totp_codes.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||||
|
|
||||||
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from password_manager.entry_management import EntryManager
|
||||||
|
from password_manager.backup import BackupManager
|
||||||
|
from password_manager.manager import PasswordManager, EncryptionMode
|
||||||
|
from password_manager.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
|
class FakeNostrClient:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.published = []
|
||||||
|
|
||||||
|
def publish_snapshot(self, data: bytes):
|
||||||
|
self.published.append(data)
|
||||||
|
return None, "abcd"
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_display_totp_codes(monkeypatch, capsys):
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
tmp_path = Path(tmpdir)
|
||||||
|
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
|
||||||
|
cfg_mgr = ConfigManager(vault, tmp_path)
|
||||||
|
backup_mgr = BackupManager(tmp_path, cfg_mgr)
|
||||||
|
entry_mgr = EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
|
pm = PasswordManager.__new__(PasswordManager)
|
||||||
|
pm.encryption_mode = EncryptionMode.SEED_ONLY
|
||||||
|
pm.encryption_manager = enc_mgr
|
||||||
|
pm.vault = vault
|
||||||
|
pm.entry_manager = entry_mgr
|
||||||
|
pm.backup_manager = backup_mgr
|
||||||
|
pm.parent_seed = TEST_SEED
|
||||||
|
pm.nostr_client = FakeNostrClient()
|
||||||
|
pm.fingerprint_dir = tmp_path
|
||||||
|
pm.is_dirty = False
|
||||||
|
|
||||||
|
entry_mgr.add_totp("Example", TEST_SEED)
|
||||||
|
|
||||||
|
monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 30
|
||||||
|
)
|
||||||
|
|
||||||
|
# interrupt the loop after first iteration
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"password_manager.manager.select.select",
|
||||||
|
lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()),
|
||||||
|
)
|
||||||
|
|
||||||
|
pm.handle_display_totp_codes()
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Generated 2FA Codes" in out
|
||||||
|
assert "[0] Example" in out
|
||||||
|
assert "123456" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_totp_codes_excludes_blacklisted(monkeypatch, capsys):
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
tmp_path = Path(tmpdir)
|
||||||
|
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
|
||||||
|
cfg_mgr = ConfigManager(vault, tmp_path)
|
||||||
|
backup_mgr = BackupManager(tmp_path, cfg_mgr)
|
||||||
|
entry_mgr = EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
|
pm = PasswordManager.__new__(PasswordManager)
|
||||||
|
pm.encryption_mode = EncryptionMode.SEED_ONLY
|
||||||
|
pm.encryption_manager = enc_mgr
|
||||||
|
pm.vault = vault
|
||||||
|
pm.entry_manager = entry_mgr
|
||||||
|
pm.backup_manager = backup_mgr
|
||||||
|
pm.parent_seed = TEST_SEED
|
||||||
|
pm.nostr_client = FakeNostrClient()
|
||||||
|
pm.fingerprint_dir = tmp_path
|
||||||
|
pm.is_dirty = False
|
||||||
|
|
||||||
|
entry_mgr.add_totp("Visible", TEST_SEED)
|
||||||
|
entry_mgr.add_totp("Hidden", TEST_SEED)
|
||||||
|
entry_mgr.modify_entry(1, blacklisted=True)
|
||||||
|
|
||||||
|
monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 30
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"password_manager.manager.select.select",
|
||||||
|
lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()),
|
||||||
|
)
|
||||||
|
|
||||||
|
pm.handle_display_totp_codes()
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Visible" in out
|
||||||
|
assert "Hidden" not in out
|
60
src/tests/test_manager_retrieve_totp.py
Normal file
60
src/tests/test_manager_retrieve_totp.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||||
|
|
||||||
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from password_manager.entry_management import EntryManager
|
||||||
|
from password_manager.backup import BackupManager
|
||||||
|
from password_manager.manager import PasswordManager, EncryptionMode, TotpManager
|
||||||
|
from password_manager.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
|
class FakeNostrClient:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.published = []
|
||||||
|
|
||||||
|
def publish_snapshot(self, data: bytes):
|
||||||
|
self.published.append(data)
|
||||||
|
return None, "abcd"
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_retrieve_totp_entry(monkeypatch, capsys):
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
tmp_path = Path(tmpdir)
|
||||||
|
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
|
||||||
|
cfg_mgr = ConfigManager(vault, tmp_path)
|
||||||
|
backup_mgr = BackupManager(tmp_path, cfg_mgr)
|
||||||
|
entry_mgr = EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
|
pm = PasswordManager.__new__(PasswordManager)
|
||||||
|
pm.encryption_mode = EncryptionMode.SEED_ONLY
|
||||||
|
pm.encryption_manager = enc_mgr
|
||||||
|
pm.vault = vault
|
||||||
|
pm.entry_manager = entry_mgr
|
||||||
|
pm.backup_manager = backup_mgr
|
||||||
|
pm.parent_seed = TEST_SEED
|
||||||
|
pm.nostr_client = FakeNostrClient()
|
||||||
|
pm.fingerprint_dir = tmp_path
|
||||||
|
pm.is_dirty = False
|
||||||
|
|
||||||
|
entry_mgr.add_totp("Example", TEST_SEED)
|
||||||
|
|
||||||
|
monkeypatch.setattr("builtins.input", lambda *a, **k: "0")
|
||||||
|
monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(sys.stdin, "readline", lambda *a, **k: "b\n")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"password_manager.manager.select.select",
|
||||||
|
lambda *a, **k: ([sys.stdin], [], []),
|
||||||
|
)
|
||||||
|
|
||||||
|
pm.handle_retrieve_entry()
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Retrieved 2FA Code" in out
|
||||||
|
assert "123456" in out
|
@@ -9,6 +9,7 @@ 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
|
||||||
from password_manager.manager import PasswordManager, EncryptionMode
|
from password_manager.manager import PasswordManager, EncryptionMode
|
||||||
|
from password_manager.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
class FakePasswordGenerator:
|
class FakePasswordGenerator:
|
||||||
@@ -29,8 +30,9 @@ def test_manager_workflow(monkeypatch):
|
|||||||
with TemporaryDirectory() as tmpdir:
|
with TemporaryDirectory() as tmpdir:
|
||||||
tmp_path = Path(tmpdir)
|
tmp_path = Path(tmpdir)
|
||||||
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
|
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
|
||||||
entry_mgr = EntryManager(vault, tmp_path)
|
cfg_mgr = ConfigManager(vault, tmp_path)
|
||||||
backup_mgr = BackupManager(tmp_path)
|
backup_mgr = BackupManager(tmp_path, cfg_mgr)
|
||||||
|
entry_mgr = EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
monkeypatch.setattr("password_manager.manager.NostrClient", FakeNostrClient)
|
monkeypatch.setattr("password_manager.manager.NostrClient", FakeNostrClient)
|
||||||
|
|
||||||
@@ -64,7 +66,7 @@ def test_manager_workflow(monkeypatch):
|
|||||||
|
|
||||||
pm.handle_add_password()
|
pm.handle_add_password()
|
||||||
assert pm.is_dirty is False
|
assert pm.is_dirty is False
|
||||||
backups = list(tmp_path.glob("entries_db_backup_*.json.enc"))
|
backups = list((tmp_path / "backups").glob("entries_db_backup_*.json.enc"))
|
||||||
assert len(backups) == 1
|
assert len(backups) == 1
|
||||||
checksum_file = tmp_path / "seedpass_entries_db_checksum.txt"
|
checksum_file = tmp_path / "seedpass_entries_db_checksum.txt"
|
||||||
assert checksum_file.exists()
|
assert checksum_file.exists()
|
||||||
|
29
src/tests/test_memory_protection.py
Normal file
29
src/tests/test_memory_protection.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from utils.memory_protection import InMemorySecret
|
||||||
|
|
||||||
|
|
||||||
|
def test_inmemory_secret_round_trip_bytes_and_str():
|
||||||
|
plaintext = b"super secret"
|
||||||
|
secret = InMemorySecret(plaintext)
|
||||||
|
assert secret.get_bytes() == plaintext
|
||||||
|
assert secret.get_str() == plaintext.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_inmemory_secret_invalid_type():
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
InMemorySecret("not bytes")
|
||||||
|
|
||||||
|
|
||||||
|
def test_inmemory_secret_wipe_clears_attributes():
|
||||||
|
secret = InMemorySecret(b"wipe me")
|
||||||
|
secret.wipe()
|
||||||
|
assert secret._key is None
|
||||||
|
assert secret._nonce is None
|
||||||
|
assert secret._cipher is None
|
||||||
|
assert secret._encrypted is None
|
@@ -8,7 +8,9 @@ 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.entry_management import EntryManager
|
from password_manager.entry_management import EntryManager
|
||||||
|
from password_manager.backup import BackupManager
|
||||||
from password_manager.vault import Vault
|
from password_manager.vault import Vault
|
||||||
|
from password_manager.config_manager import ConfigManager
|
||||||
from nostr.client import NostrClient
|
from nostr.client import NostrClient
|
||||||
|
|
||||||
|
|
||||||
@@ -16,7 +18,9 @@ def test_backup_and_publish_to_nostr():
|
|||||||
with TemporaryDirectory() as tmpdir:
|
with TemporaryDirectory() as tmpdir:
|
||||||
tmp_path = Path(tmpdir)
|
tmp_path = Path(tmpdir)
|
||||||
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
|
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
|
||||||
entry_mgr = EntryManager(vault, tmp_path)
|
cfg_mgr = ConfigManager(vault, tmp_path)
|
||||||
|
backup_mgr = BackupManager(tmp_path, cfg_mgr)
|
||||||
|
entry_mgr = EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
# create an index by adding an entry
|
# create an index by adding an entry
|
||||||
entry_mgr.add_entry("example.com", 12)
|
entry_mgr.add_entry("example.com", 12)
|
||||||
@@ -34,7 +38,7 @@ def test_backup_and_publish_to_nostr():
|
|||||||
enc_mgr, "decrypt_parent_seed", return_value="seed"
|
enc_mgr, "decrypt_parent_seed", return_value="seed"
|
||||||
):
|
):
|
||||||
nostr_client = NostrClient(enc_mgr, "fp")
|
nostr_client = NostrClient(enc_mgr, "fp")
|
||||||
entry_mgr.backup_index_file()
|
entry_mgr.backup_manager.create_backup()
|
||||||
result = asyncio.run(nostr_client.publish_snapshot(encrypted_index))
|
result = asyncio.run(nostr_client.publish_snapshot(encrypted_index))
|
||||||
|
|
||||||
mock_publish.assert_awaited_with(encrypted_index)
|
mock_publish.assert_awaited_with(encrypted_index)
|
||||||
|
@@ -75,3 +75,19 @@ def test_initialize_client_pool_add_relay_fallback(tmp_path):
|
|||||||
fc = client.client
|
fc = client.client
|
||||||
assert fc.added == client.relays
|
assert fc.added == client.relays
|
||||||
assert fc.connected is True
|
assert fc.connected is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_relay_health_runs_async(tmp_path, monkeypatch):
|
||||||
|
client = _setup_client(tmp_path, FakeAddRelayClient)
|
||||||
|
|
||||||
|
recorded = {}
|
||||||
|
|
||||||
|
async def fake_check(min_relays, timeout):
|
||||||
|
recorded["args"] = (min_relays, timeout)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
monkeypatch.setattr(client, "_check_relay_health", fake_check)
|
||||||
|
result = client.check_relay_health(3, timeout=2)
|
||||||
|
|
||||||
|
assert result == 1
|
||||||
|
assert recorded["args"] == (3, 2)
|
||||||
|
@@ -4,12 +4,16 @@ import math
|
|||||||
|
|
||||||
from helpers import create_vault, dummy_nostr_client
|
from helpers import create_vault, dummy_nostr_client
|
||||||
from password_manager.entry_management import EntryManager
|
from password_manager.entry_management import EntryManager
|
||||||
|
from password_manager.backup import BackupManager
|
||||||
|
from password_manager.config_manager import ConfigManager
|
||||||
from nostr.client import prepare_snapshot
|
from nostr.client import prepare_snapshot
|
||||||
|
|
||||||
|
|
||||||
def test_manifest_generation(tmp_path):
|
def test_manifest_generation(tmp_path):
|
||||||
vault, enc_mgr = create_vault(tmp_path)
|
vault, enc_mgr = create_vault(tmp_path)
|
||||||
entry_mgr = EntryManager(vault, tmp_path)
|
cfg_mgr = ConfigManager(vault, tmp_path)
|
||||||
|
backup_mgr = BackupManager(tmp_path, cfg_mgr)
|
||||||
|
entry_mgr = EntryManager(vault, backup_mgr)
|
||||||
entry_mgr.add_entry("example.com", 12)
|
entry_mgr.add_entry("example.com", 12)
|
||||||
entry_mgr.add_entry("test.com", 12)
|
entry_mgr.add_entry("test.com", 12)
|
||||||
encrypted = vault.get_encrypted_index()
|
encrypted = vault.get_encrypted_index()
|
||||||
|
@@ -16,7 +16,9 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
|
|||||||
|
|
||||||
from password_manager.encryption import EncryptionManager
|
from password_manager.encryption import EncryptionManager
|
||||||
from password_manager.entry_management import EntryManager
|
from password_manager.entry_management import EntryManager
|
||||||
|
from password_manager.backup import BackupManager
|
||||||
from password_manager.vault import Vault
|
from password_manager.vault import Vault
|
||||||
|
from password_manager.config_manager import ConfigManager
|
||||||
from nostr.client import NostrClient, Kind, KindStandard
|
from nostr.client import NostrClient, Kind, KindStandard
|
||||||
|
|
||||||
|
|
||||||
@@ -40,7 +42,9 @@ def test_nostr_index_size_limits():
|
|||||||
)
|
)
|
||||||
npub = client.key_manager.get_npub()
|
npub = client.key_manager.get_npub()
|
||||||
vault = Vault(enc_mgr, tmpdir)
|
vault = Vault(enc_mgr, tmpdir)
|
||||||
entry_mgr = EntryManager(vault, Path(tmpdir))
|
cfg_mgr = ConfigManager(vault, Path(tmpdir))
|
||||||
|
backup_mgr = BackupManager(Path(tmpdir), cfg_mgr)
|
||||||
|
entry_mgr = EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
delay = float(os.getenv("NOSTR_TEST_DELAY", "5"))
|
delay = float(os.getenv("NOSTR_TEST_DELAY", "5"))
|
||||||
size = 16
|
size = 16
|
||||||
|
@@ -10,6 +10,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
|
|||||||
|
|
||||||
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.backup import BackupManager
|
||||||
from password_manager.vault import Vault
|
from password_manager.vault import Vault
|
||||||
from password_manager.manager import PasswordManager, EncryptionMode
|
from password_manager.manager import PasswordManager, EncryptionMode
|
||||||
|
|
||||||
@@ -18,8 +19,9 @@ def test_change_password_triggers_nostr_backup(monkeypatch):
|
|||||||
with TemporaryDirectory() as tmpdir:
|
with TemporaryDirectory() as tmpdir:
|
||||||
fp = Path(tmpdir)
|
fp = Path(tmpdir)
|
||||||
vault, enc_mgr = create_vault(fp, TEST_SEED, TEST_PASSWORD)
|
vault, enc_mgr = create_vault(fp, TEST_SEED, TEST_PASSWORD)
|
||||||
entry_mgr = EntryManager(vault, fp)
|
|
||||||
cfg_mgr = ConfigManager(vault, fp)
|
cfg_mgr = ConfigManager(vault, fp)
|
||||||
|
backup_mgr = BackupManager(fp, cfg_mgr)
|
||||||
|
entry_mgr = EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
pm = PasswordManager.__new__(PasswordManager)
|
pm = PasswordManager.__new__(PasswordManager)
|
||||||
pm.encryption_mode = EncryptionMode.SEED_ONLY
|
pm.encryption_mode = EncryptionMode.SEED_ONLY
|
||||||
|
@@ -10,6 +10,7 @@ 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.entry_management import EntryManager
|
from password_manager.entry_management import EntryManager
|
||||||
|
from password_manager.backup import BackupManager
|
||||||
from password_manager.config_manager import ConfigManager
|
from password_manager.config_manager import ConfigManager
|
||||||
from password_manager.manager import PasswordManager, EncryptionMode
|
from password_manager.manager import PasswordManager, EncryptionMode
|
||||||
from utils.key_derivation import derive_index_key, derive_key_from_password
|
from utils.key_derivation import derive_index_key, derive_key_from_password
|
||||||
@@ -29,8 +30,9 @@ def test_password_change_and_unlock(monkeypatch):
|
|||||||
enc_mgr = EncryptionManager(index_key, fp)
|
enc_mgr = EncryptionManager(index_key, fp)
|
||||||
seed_mgr = EncryptionManager(seed_key, fp)
|
seed_mgr = EncryptionManager(seed_key, fp)
|
||||||
vault = Vault(enc_mgr, fp)
|
vault = Vault(enc_mgr, fp)
|
||||||
entry_mgr = EntryManager(vault, fp)
|
|
||||||
cfg_mgr = ConfigManager(vault, fp)
|
cfg_mgr = ConfigManager(vault, fp)
|
||||||
|
backup_mgr = BackupManager(fp, cfg_mgr)
|
||||||
|
entry_mgr = EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
vault.save_index({"entries": {}})
|
vault.save_index({"entries": {}})
|
||||||
cfg_mgr.save_config(
|
cfg_mgr.save_config(
|
||||||
|
@@ -11,6 +11,7 @@ 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 password_manager.config_manager import ConfigManager
|
||||||
from password_manager.portable_backup import export_backup, import_backup
|
from password_manager.portable_backup import export_backup, import_backup
|
||||||
from utils.key_derivation import derive_index_key, derive_key_from_password
|
from utils.key_derivation import derive_index_key, derive_key_from_password
|
||||||
|
|
||||||
@@ -27,7 +28,8 @@ def setup_vault(tmp: Path):
|
|||||||
index_key = derive_index_key(SEED)
|
index_key = derive_index_key(SEED)
|
||||||
enc_mgr = EncryptionManager(index_key, tmp)
|
enc_mgr = EncryptionManager(index_key, tmp)
|
||||||
vault = Vault(enc_mgr, tmp)
|
vault = Vault(enc_mgr, tmp)
|
||||||
backup = BackupManager(tmp)
|
cfg = ConfigManager(vault, tmp)
|
||||||
|
backup = BackupManager(tmp, cfg)
|
||||||
return vault, backup
|
return vault, backup
|
||||||
|
|
||||||
|
|
||||||
|
@@ -14,7 +14,9 @@ import constants
|
|||||||
import password_manager.manager as manager_module
|
import password_manager.manager as manager_module
|
||||||
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
|
||||||
|
from password_manager.backup import BackupManager
|
||||||
from password_manager.manager import EncryptionMode
|
from password_manager.manager import EncryptionMode
|
||||||
|
from password_manager.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
def test_add_and_delete_entry(monkeypatch):
|
def test_add_and_delete_entry(monkeypatch):
|
||||||
@@ -51,7 +53,9 @@ def test_add_and_delete_entry(monkeypatch):
|
|||||||
assert pm.fingerprint_manager.current_fingerprint == fingerprint
|
assert pm.fingerprint_manager.current_fingerprint == fingerprint
|
||||||
|
|
||||||
vault, enc_mgr = create_vault(fingerprint_dir, TEST_SEED, TEST_PASSWORD)
|
vault, enc_mgr = create_vault(fingerprint_dir, TEST_SEED, TEST_PASSWORD)
|
||||||
entry_mgr = EntryManager(vault, fingerprint_dir)
|
cfg_mgr = ConfigManager(vault, fingerprint_dir)
|
||||||
|
backup_mgr = BackupManager(fingerprint_dir, cfg_mgr)
|
||||||
|
entry_mgr = EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
pm.encryption_manager = enc_mgr
|
pm.encryption_manager = enc_mgr
|
||||||
pm.vault = vault
|
pm.vault = vault
|
||||||
|
@@ -86,3 +86,15 @@ def test_relay_and_profile_actions(monkeypatch, capsys):
|
|||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert fp1 in out
|
assert fp1 in out
|
||||||
assert fp2 in out
|
assert fp2 in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_menu_additional_backup(monkeypatch):
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
tmp_path = Path(tmpdir)
|
||||||
|
pm, cfg_mgr, fp_mgr = setup_pm(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
inputs = iter(["9", "12"])
|
||||||
|
with patch("main.handle_set_additional_backup_location") as handler:
|
||||||
|
with patch("builtins.input", side_effect=lambda *_: next(inputs)):
|
||||||
|
main.handle_settings(pm)
|
||||||
|
handler.assert_called_once_with(pm)
|
||||||
|
30
src/tests/test_totp.py
Normal file
30
src/tests/test_totp.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pyotp
|
||||||
|
from freezegun import freeze_time
|
||||||
|
|
||||||
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from helpers import TEST_SEED
|
||||||
|
from password_manager.totp import TotpManager
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("1970-01-01 00:16:40")
|
||||||
|
def test_current_code_matches_pyotp():
|
||||||
|
secret = TotpManager.derive_secret(TEST_SEED, 0)
|
||||||
|
expected = pyotp.TOTP(secret).now()
|
||||||
|
assert TotpManager.current_code(TEST_SEED, 0) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("1970-01-01 00:00:15")
|
||||||
|
def test_time_remaining():
|
||||||
|
assert TotpManager.time_remaining(period=30) == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_print_progress_bar_terminates(monkeypatch):
|
||||||
|
monkeypatch.setattr(TotpManager, "time_remaining", lambda period: 0)
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr("password_manager.totp.time.sleep", lambda s: calls.append(s))
|
||||||
|
TotpManager.print_progress_bar(period=30)
|
||||||
|
assert calls == []
|
75
src/tests/test_totp_entry.py
Normal file
75
src/tests/test_totp_entry.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||||
|
|
||||||
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from password_manager.entry_management import EntryManager
|
||||||
|
from password_manager.backup import BackupManager
|
||||||
|
from password_manager.vault import Vault
|
||||||
|
from password_manager.config_manager import ConfigManager
|
||||||
|
from password_manager.totp import TotpManager
|
||||||
|
import pyotp
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_totp_and_get_code():
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
|
||||||
|
cfg_mgr = ConfigManager(vault, Path(tmpdir))
|
||||||
|
backup_mgr = BackupManager(Path(tmpdir), cfg_mgr)
|
||||||
|
entry_mgr = EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
|
uri = entry_mgr.add_totp("Example", TEST_SEED)
|
||||||
|
assert uri.startswith("otpauth://totp/")
|
||||||
|
|
||||||
|
entry = entry_mgr.retrieve_entry(0)
|
||||||
|
assert entry == {
|
||||||
|
"type": "totp",
|
||||||
|
"label": "Example",
|
||||||
|
"index": 0,
|
||||||
|
"period": 30,
|
||||||
|
"digits": 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
code = entry_mgr.get_totp_code(0, TEST_SEED, timestamp=0)
|
||||||
|
|
||||||
|
expected = TotpManager.current_code(TEST_SEED, 0, timestamp=0)
|
||||||
|
assert code == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_totp_time_remaining(monkeypatch):
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
|
||||||
|
cfg_mgr = ConfigManager(vault, Path(tmpdir))
|
||||||
|
backup_mgr = BackupManager(Path(tmpdir), cfg_mgr)
|
||||||
|
entry_mgr = EntryManager(vault, backup_mgr)
|
||||||
|
|
||||||
|
entry_mgr.add_totp("Example", TEST_SEED)
|
||||||
|
|
||||||
|
monkeypatch.setattr(TotpManager, "time_remaining", lambda period: 7)
|
||||||
|
remaining = entry_mgr.get_totp_time_remaining(0)
|
||||||
|
assert remaining == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_totp_imported(tmp_path):
|
||||||
|
vault, enc = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
|
||||||
|
cfg_mgr = ConfigManager(vault, tmp_path)
|
||||||
|
backup_mgr = BackupManager(tmp_path, cfg_mgr)
|
||||||
|
em = EntryManager(vault, backup_mgr)
|
||||||
|
secret = "JBSWY3DPEHPK3PXP"
|
||||||
|
em.add_totp("Imported", TEST_SEED, secret=secret)
|
||||||
|
entry = em.retrieve_entry(0)
|
||||||
|
assert entry == {
|
||||||
|
"type": "totp",
|
||||||
|
"label": "Imported",
|
||||||
|
"secret": secret,
|
||||||
|
"period": 30,
|
||||||
|
"digits": 6,
|
||||||
|
}
|
||||||
|
code = em.get_totp_code(0, timestamp=0)
|
||||||
|
assert code == pyotp.TOTP(secret).at(0)
|
48
src/tests/test_totp_uri.py
Normal file
48
src/tests/test_totp_uri.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from password_manager.totp import TotpManager
|
||||||
|
|
||||||
|
|
||||||
|
# Test parsing a normal otpauth URI with custom period and digits
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_otpauth_normal():
|
||||||
|
uri = "otpauth://totp/Example?secret=JBSWY3DPEHPK3PXP&period=45&digits=8"
|
||||||
|
label, secret, period, digits = TotpManager.parse_otpauth(uri)
|
||||||
|
assert label == "Example"
|
||||||
|
assert secret == "JBSWY3DPEHPK3PXP"
|
||||||
|
assert period == 45
|
||||||
|
assert digits == 8
|
||||||
|
|
||||||
|
|
||||||
|
# URI missing the otpauth:// prefix should raise ValueError
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_otpauth_missing_prefix():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
TotpManager.parse_otpauth("totp/Example?secret=ABC")
|
||||||
|
|
||||||
|
|
||||||
|
# URI without a secret parameter should raise ValueError
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_otpauth_missing_secret():
|
||||||
|
uri = "otpauth://totp/Example?period=30"
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
TotpManager.parse_otpauth(uri)
|
||||||
|
|
||||||
|
|
||||||
|
# Round-trip make_otpauth_uri -> parse_otpauth with label containing spaces
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_otpauth_uri_roundtrip():
|
||||||
|
label = "Example Label"
|
||||||
|
secret = "JBSWY3DPEHPK3PXP"
|
||||||
|
uri = TotpManager.make_otpauth_uri(label, secret, period=30, digits=6)
|
||||||
|
parsed = TotpManager.parse_otpauth(uri)
|
||||||
|
assert parsed == (label, secret, 30, 6)
|
@@ -11,8 +11,10 @@ try:
|
|||||||
derive_key_from_password,
|
derive_key_from_password,
|
||||||
derive_key_from_parent_seed,
|
derive_key_from_parent_seed,
|
||||||
derive_index_key,
|
derive_index_key,
|
||||||
|
derive_totp_secret,
|
||||||
EncryptionMode,
|
EncryptionMode,
|
||||||
DEFAULT_ENCRYPTION_MODE,
|
DEFAULT_ENCRYPTION_MODE,
|
||||||
|
TOTP_PURPOSE,
|
||||||
)
|
)
|
||||||
from .checksum import (
|
from .checksum import (
|
||||||
calculate_checksum,
|
calculate_checksum,
|
||||||
@@ -22,6 +24,7 @@ try:
|
|||||||
)
|
)
|
||||||
from .password_prompt import prompt_for_password
|
from .password_prompt import prompt_for_password
|
||||||
from .input_utils import timed_input
|
from .input_utils import timed_input
|
||||||
|
from .memory_protection import InMemorySecret
|
||||||
|
|
||||||
if logger.isEnabledFor(logging.DEBUG):
|
if logger.isEnabledFor(logging.DEBUG):
|
||||||
logger.info("Modules imported successfully.")
|
logger.info("Modules imported successfully.")
|
||||||
@@ -33,8 +36,10 @@ __all__ = [
|
|||||||
"derive_key_from_password",
|
"derive_key_from_password",
|
||||||
"derive_key_from_parent_seed",
|
"derive_key_from_parent_seed",
|
||||||
"derive_index_key",
|
"derive_index_key",
|
||||||
|
"derive_totp_secret",
|
||||||
"EncryptionMode",
|
"EncryptionMode",
|
||||||
"DEFAULT_ENCRYPTION_MODE",
|
"DEFAULT_ENCRYPTION_MODE",
|
||||||
|
"TOTP_PURPOSE",
|
||||||
"calculate_checksum",
|
"calculate_checksum",
|
||||||
"verify_checksum",
|
"verify_checksum",
|
||||||
"json_checksum",
|
"json_checksum",
|
||||||
@@ -43,4 +48,5 @@ __all__ = [
|
|||||||
"shared_lock",
|
"shared_lock",
|
||||||
"prompt_for_password",
|
"prompt_for_password",
|
||||||
"timed_input",
|
"timed_input",
|
||||||
|
"InMemorySecret",
|
||||||
]
|
]
|
||||||
|
@@ -20,6 +20,7 @@ import base64
|
|||||||
import unicodedata
|
import unicodedata
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
|
import hmac
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
from bip_utils import Bip39SeedGenerator
|
from bip_utils import Bip39SeedGenerator
|
||||||
@@ -40,6 +41,9 @@ class EncryptionMode(Enum):
|
|||||||
|
|
||||||
DEFAULT_ENCRYPTION_MODE = EncryptionMode.SEED_ONLY
|
DEFAULT_ENCRYPTION_MODE = EncryptionMode.SEED_ONLY
|
||||||
|
|
||||||
|
# Purpose constant for TOTP secret derivation using BIP85
|
||||||
|
TOTP_PURPOSE = 39
|
||||||
|
|
||||||
|
|
||||||
def derive_key_from_password(password: str, iterations: int = 100_000) -> bytes:
|
def derive_key_from_password(password: str, iterations: int = 100_000) -> bytes:
|
||||||
"""
|
"""
|
||||||
@@ -152,3 +156,31 @@ def derive_index_key_seed_only(seed: str) -> bytes:
|
|||||||
def derive_index_key(seed: str) -> bytes:
|
def derive_index_key(seed: str) -> bytes:
|
||||||
"""Derive the index encryption key."""
|
"""Derive the index encryption key."""
|
||||||
return derive_index_key_seed_only(seed)
|
return derive_index_key_seed_only(seed)
|
||||||
|
|
||||||
|
|
||||||
|
def derive_totp_secret(seed: str, index: int) -> str:
|
||||||
|
"""Derive a base32-encoded TOTP secret from a BIP39 seed."""
|
||||||
|
try:
|
||||||
|
from local_bip85 import BIP85
|
||||||
|
|
||||||
|
# Initialize BIP85 from the BIP39 seed bytes
|
||||||
|
seed_bytes = Bip39SeedGenerator(seed).Generate()
|
||||||
|
bip85 = BIP85(seed_bytes)
|
||||||
|
|
||||||
|
# Build the BIP32 path m/83696968'/39'/TOTP'/{index}'
|
||||||
|
totp_int = int.from_bytes(b"TOTP", "big")
|
||||||
|
path = f"m/83696968'/{TOTP_PURPOSE}'/{totp_int}'/{index}'"
|
||||||
|
|
||||||
|
# Derive entropy using the same scheme as BIP85
|
||||||
|
child_key = bip85.bip32_ctx.DerivePath(path)
|
||||||
|
key_bytes = child_key.PrivateKey().Raw().ToBytes()
|
||||||
|
entropy = hmac.new(b"bip-entropy-from-k", key_bytes, hashlib.sha512).digest()
|
||||||
|
|
||||||
|
# Hash the first 32 bytes of entropy and encode the first 20 bytes
|
||||||
|
hashed = hashlib.sha256(entropy[:32]).digest()
|
||||||
|
secret = base64.b32encode(hashed[:20]).decode("utf-8")
|
||||||
|
logger.debug(f"Derived TOTP secret for index {index}: {secret}")
|
||||||
|
return secret
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to derive TOTP secret: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
31
src/utils/memory_protection.py
Normal file
31
src/utils/memory_protection.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
|
|
||||||
|
class InMemorySecret:
|
||||||
|
"""Store sensitive data encrypted in RAM using AES-GCM."""
|
||||||
|
|
||||||
|
def __init__(self, data: bytes) -> None:
|
||||||
|
if not isinstance(data, (bytes, bytearray)):
|
||||||
|
raise TypeError("data must be bytes")
|
||||||
|
self._key = AESGCM.generate_key(bit_length=128)
|
||||||
|
self._nonce = os.urandom(12)
|
||||||
|
self._cipher = AESGCM(self._key)
|
||||||
|
self._encrypted = self._cipher.encrypt(self._nonce, bytes(data), None)
|
||||||
|
|
||||||
|
def get_bytes(self) -> bytes:
|
||||||
|
"""Decrypt and return the plaintext bytes."""
|
||||||
|
return self._cipher.decrypt(self._nonce, self._encrypted, None)
|
||||||
|
|
||||||
|
def wipe(self) -> None:
|
||||||
|
"""Zero out internal data."""
|
||||||
|
self._key = None
|
||||||
|
self._nonce = None
|
||||||
|
self._cipher = None
|
||||||
|
self._encrypted = None
|
||||||
|
|
||||||
|
def get_str(self) -> str:
|
||||||
|
"""Return the decrypted plaintext as a UTF-8 string."""
|
||||||
|
return self.get_bytes().decode("utf-8")
|
Reference in New Issue
Block a user