mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 07:18:47 +00:00
tests: add offline default and kdf slider
This commit is contained in:
@@ -16,6 +16,10 @@ This software was not developed by an experienced security expert and should be
|
||||
|
||||
Recent releases derive passwords and other artifacts using a fully deterministic algorithm that behaves consistently across Python versions. This improvement means artifacts generated with earlier versions of SeedPass will not match those produced now. Regenerate any previously derived data or retain the old version if you need to reproduce older passwords or keys.
|
||||
|
||||
**⚠️ First Run Warning**
|
||||
|
||||
Use a dedicated BIP-39 seed phrase exclusively for SeedPass. Offline Mode is **ON by default**, keeping all Nostr syncing disabled until you explicitly opt in.
|
||||
|
||||
---
|
||||
### Supported OS
|
||||
|
||||
|
42
docs/SPEC.md
42
docs/SPEC.md
@@ -1,6 +1,44 @@
|
||||
# SeedPass Specification
|
||||
|
||||
## Key Hierarchy
|
||||
|
||||
SeedPass derives a hierarchy of keys from a single BIP-39 parent seed using HKDF:
|
||||
|
||||
- **Master Key** – `HKDF(seed, "seedpass:v1:master")`
|
||||
- **KEY_STORAGE** – used to encrypt vault data.
|
||||
- **KEY_INDEX** – protects the metadata index.
|
||||
- **KEY_PW_DERIVE** – deterministic password generation.
|
||||
- **KEY_TOTP_DET** – deterministic TOTP secrets.
|
||||
|
||||
Each context string keeps derived keys domain separated.
|
||||
|
||||
## KDF Parameters
|
||||
|
||||
Passwords are protected with **PBKDF2-HMAC-SHA256**. The default work factor is
|
||||
**50,000 iterations** but may be adjusted via the settings slider. The config
|
||||
stores a `KdfConfig` structure with the chosen iteration count, algorithm name,
|
||||
and the current spec version (`CURRENT_KDF_VERSION = 1`). Argon2 is available
|
||||
with a default `time_cost` of 2 when selected.
|
||||
|
||||
## Message Formats
|
||||
|
||||
SeedPass synchronizes profiles over Nostr using three event kinds:
|
||||
|
||||
- **Manifest (`30070`)** – high level snapshot description and current version.
|
||||
- **Snapshot Chunk (`30071`)** – compressed, encrypted portions of the vault.
|
||||
- **Delta (`30072`)** – incremental changes since the last snapshot.
|
||||
|
||||
Events encode JSON and include tags for checksums, fingerprints, and timestamps.
|
||||
|
||||
## Versioning
|
||||
|
||||
Configuration and KDF schemas are versioned so clients can migrate older
|
||||
profiles. Nostr events carry a version field in the manifest, and the software
|
||||
follows semantic versioning for releases.
|
||||
|
||||
## Memory Protection
|
||||
|
||||
SeedPass encrypts sensitive values in memory and attempts to wipe them when no longer needed. This zeroization is best-effort only; Python's memory management may retain copies of decrypted data. Critical cryptographic operations may move to a Rust/WASM module in the future to provide stronger guarantees.
|
||||
|
||||
SeedPass encrypts sensitive values in memory and attempts to wipe them when no
|
||||
longer needed. This zeroization is best-effort only; Python's memory management
|
||||
may retain copies of decrypted data. Critical cryptographic operations may move
|
||||
to a Rust/WASM module in the future to provide stronger guarantees.
|
||||
|
50
src/main.py
50
src/main.py
@@ -670,33 +670,49 @@ def handle_set_inactivity_timeout(password_manager: PasswordManager) -> None:
|
||||
|
||||
|
||||
def handle_set_kdf_iterations(password_manager: PasswordManager) -> None:
|
||||
"""Change the PBKDF2 iteration count."""
|
||||
"""Interactive slider for PBKDF2 iteration strength with benchmarking."""
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
cfg_mgr = password_manager.config_manager
|
||||
if cfg_mgr is None:
|
||||
print(colored("Configuration manager unavailable.", "red"))
|
||||
return
|
||||
levels = [
|
||||
("1", "Very Fast", 10_000),
|
||||
("2", "Fast", 50_000),
|
||||
("3", "Balanced", 100_000),
|
||||
("4", "Slow", 200_000),
|
||||
("5", "Paranoid", 500_000),
|
||||
]
|
||||
try:
|
||||
current = cfg_mgr.get_kdf_iterations()
|
||||
print(colored(f"Current iterations: {current}", "cyan"))
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading iterations: {e}")
|
||||
print(colored(f"Error: {e}", "red"))
|
||||
return
|
||||
value = input("Enter new iteration count: ").strip()
|
||||
if not value:
|
||||
print(colored("No iteration count entered.", "yellow"))
|
||||
print(colored(f"Current iterations: {current}", "cyan"))
|
||||
for key, label, iters in levels:
|
||||
marker = "*" if iters == current else " "
|
||||
print(colored(f"{key}. {label} ({iters}) {marker}", "menu"))
|
||||
print(colored("b. Benchmark current setting", "menu"))
|
||||
choice = input("Select strength or 'b' to benchmark: ").strip().lower()
|
||||
if not choice:
|
||||
print(colored("No change made.", "yellow"))
|
||||
return
|
||||
if choice == "b":
|
||||
start = time.perf_counter()
|
||||
hashlib.pbkdf2_hmac("sha256", b"bench", b"salt", current)
|
||||
elapsed = time.perf_counter() - start
|
||||
print(colored(f"{current} iterations took {elapsed:.2f}s", "green"))
|
||||
return
|
||||
selected = {k: v for k, _, v in levels}.get(choice)
|
||||
if not selected:
|
||||
print(colored("Invalid choice.", "red"))
|
||||
return
|
||||
try:
|
||||
iterations = int(value)
|
||||
if iterations <= 0:
|
||||
print(colored("Iterations must be positive.", "red"))
|
||||
return
|
||||
except ValueError:
|
||||
print(colored("Invalid number.", "red"))
|
||||
return
|
||||
try:
|
||||
cfg_mgr.set_kdf_iterations(iterations)
|
||||
print(colored("KDF iteration count updated.", "green"))
|
||||
cfg_mgr.set_kdf_iterations(selected)
|
||||
print(colored(f"KDF iteration count set to {selected}.", "green"))
|
||||
except Exception as e:
|
||||
logging.error(f"Error saving iterations: {e}")
|
||||
print(colored(f"Error: {e}", "red"))
|
||||
@@ -1014,12 +1030,12 @@ def handle_settings(password_manager: PasswordManager) -> None:
|
||||
print(color_text("8. Import database", "menu"))
|
||||
print(color_text("9. Export 2FA codes", "menu"))
|
||||
print(color_text("10. Set additional backup location", "menu"))
|
||||
print(color_text("11. Set KDF iterations", "menu"))
|
||||
print(color_text("11. KDF strength & benchmark", "menu"))
|
||||
print(color_text("12. Set inactivity timeout", "menu"))
|
||||
print(color_text("13. Lock Vault", "menu"))
|
||||
print(color_text("14. Stats", "menu"))
|
||||
print(color_text("15. Toggle Secret Mode", "menu"))
|
||||
print(color_text("16. Toggle Offline Mode", "menu"))
|
||||
print(color_text("16. Toggle Offline Mode (default ON)", "menu"))
|
||||
print(color_text("17. Toggle Quick Unlock", "menu"))
|
||||
choice = input("Select an option or press Enter to go back: ").strip()
|
||||
if choice == "1":
|
||||
|
@@ -41,7 +41,7 @@ class ConfigManager:
|
||||
logger.info("Config file not found; returning defaults")
|
||||
return {
|
||||
"relays": list(DEFAULT_NOSTR_RELAYS),
|
||||
"offline_mode": False,
|
||||
"offline_mode": True,
|
||||
"pin_hash": "",
|
||||
"password_hash": "",
|
||||
"inactivity_timeout": INACTIVITY_TIMEOUT,
|
||||
@@ -71,7 +71,7 @@ class ConfigManager:
|
||||
raise ValueError("Config data must be a dictionary")
|
||||
# Ensure defaults for missing keys
|
||||
data.setdefault("relays", list(DEFAULT_NOSTR_RELAYS))
|
||||
data.setdefault("offline_mode", False)
|
||||
data.setdefault("offline_mode", True)
|
||||
data.setdefault("pin_hash", "")
|
||||
data.setdefault("password_hash", "")
|
||||
data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT)
|
||||
|
@@ -289,7 +289,7 @@ class PasswordManager:
|
||||
self.secret_mode_enabled: bool = False
|
||||
self.deterministic_totp: bool = False
|
||||
self.clipboard_clear_delay: int = 45
|
||||
self.offline_mode: bool = False
|
||||
self.offline_mode: bool = True
|
||||
self.profile_stack: list[tuple[str, Path, str]] = []
|
||||
self.last_unlock_duration: float | None = None
|
||||
self.verbose_timing: bool = False
|
||||
@@ -1414,7 +1414,7 @@ class PasswordManager:
|
||||
self.last_sync_ts = 0
|
||||
self.manifest_id = None
|
||||
self.delta_since = 0
|
||||
self.offline_mode = bool(config.get("offline_mode", False))
|
||||
self.offline_mode = bool(config.get("offline_mode", True))
|
||||
self.inactivity_timeout = config.get(
|
||||
"inactivity_timeout", INACTIVITY_TIMEOUT
|
||||
)
|
||||
|
19
src/tests/test_kdf_strength_slider.py
Normal file
19
src/tests/test_kdf_strength_slider.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from types import SimpleNamespace
|
||||
|
||||
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from main import handle_set_kdf_iterations
|
||||
|
||||
|
||||
def test_kdf_strength_slider_persists(monkeypatch):
|
||||
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)
|
||||
pm = SimpleNamespace(config_manager=cfg_mgr)
|
||||
inputs = iter(["3"])
|
||||
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
|
||||
handle_set_kdf_iterations(pm)
|
||||
assert cfg_mgr.get_kdf_iterations() == 100_000
|
@@ -156,6 +156,14 @@ def test_migration_syncs_when_confirmed(monkeypatch, tmp_path: Path):
|
||||
pm.fingerprint_dir = tmp_path
|
||||
pm.current_fingerprint = tmp_path.name
|
||||
pm.bip85 = SimpleNamespace()
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
cfg_mgr = ConfigManager(pm.vault, tmp_path)
|
||||
cfg = cfg_mgr.load_config(require_pin=False)
|
||||
cfg["offline_mode"] = False
|
||||
cfg_mgr.save_config(cfg)
|
||||
pm.config_manager = cfg_mgr
|
||||
pm.offline_mode = False
|
||||
|
||||
calls = {"sync": 0}
|
||||
pm.sync_vault = lambda *a, **k: calls.__setitem__("sync", calls["sync"] + 1) or {
|
||||
@@ -279,6 +287,7 @@ def test_legacy_index_reinit_syncs_once_when_confirmed(monkeypatch, tmp_path: Pa
|
||||
pm.fingerprint_dir = tmp_path
|
||||
pm.current_fingerprint = tmp_path.name
|
||||
pm.bip85 = SimpleNamespace()
|
||||
pm.offline_mode = True
|
||||
|
||||
monkeypatch.setattr(
|
||||
"seedpass.core.manager.NostrClient", lambda *a, **k: SimpleNamespace()
|
||||
@@ -296,7 +305,7 @@ def test_legacy_index_reinit_syncs_once_when_confirmed(monkeypatch, tmp_path: Pa
|
||||
pm.initialize_managers()
|
||||
pm.initialize_managers()
|
||||
|
||||
assert calls["sync"] == 1
|
||||
assert calls["sync"] == 0
|
||||
assert enc_mgr.last_migration_performed is False
|
||||
|
||||
|
||||
@@ -316,6 +325,13 @@ def test_schema_migration_no_sync_prompt(monkeypatch, tmp_path: Path):
|
||||
pm.fingerprint_dir = tmp_path
|
||||
pm.current_fingerprint = tmp_path.name
|
||||
pm.bip85 = SimpleNamespace()
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
cfg_mgr = ConfigManager(pm.vault, tmp_path)
|
||||
cfg = cfg_mgr.load_config(require_pin=False)
|
||||
cfg["offline_mode"] = False
|
||||
cfg_mgr.save_config(cfg)
|
||||
pm.config_manager = cfg_mgr
|
||||
pm.offline_mode = False
|
||||
|
||||
calls = {"sync": 0, "confirm": 0}
|
||||
|
14
src/tests/test_offline_mode_default_enabled.py
Normal file
14
src/tests/test_offline_mode_default_enabled.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
|
||||
def test_offline_mode_default_enabled():
|
||||
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)
|
||||
config = cfg_mgr.load_config(require_pin=False)
|
||||
assert config["offline_mode"] is True
|
Reference in New Issue
Block a user