diff --git a/src/seedpass/api.py b/src/seedpass/api.py index 095427f..c7083be 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -177,6 +177,7 @@ def create_entry( if etype == "nostr": index = _pm.entry_manager.add_nostr_key( entry.get("label"), + _pm.parent_seed, index=entry.get("index"), notes=entry.get("notes", ""), archived=entry.get("archived", False), diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py index 2479896..8b8916c 100644 --- a/src/seedpass/core/api.py +++ b/src/seedpass/core/api.py @@ -420,6 +420,7 @@ class EntryService: with self._lock: idx = self._manager.entry_manager.add_nostr_key( label, + self._manager.parent_seed, index=index, notes=notes, ) diff --git a/src/seedpass/core/entry_management.py b/src/seedpass/core/entry_management.py index c4e6d53..b1437ed 100644 --- a/src/seedpass/core/entry_management.py +++ b/src/seedpass/core/entry_management.py @@ -37,6 +37,13 @@ from .entry_types import EntryType from .totp import TotpManager from utils.fingerprint import generate_fingerprint from utils.checksum import canonical_json_dumps +from utils.key_validation import ( + validate_totp_secret, + validate_ssh_key_pair, + validate_pgp_private_key, + validate_nostr_keys, + validate_seed_phrase, +) from .vault import Vault from .backup import BackupManager @@ -266,6 +273,8 @@ class EntryManager: if index is None: index = self.get_next_totp_index() secret = TotpManager.derive_secret(parent_seed, index) + if not validate_totp_secret(secret): + raise ValueError("Invalid derived TOTP secret") entry = { "type": EntryType.TOTP.value, "kind": EntryType.TOTP.value, @@ -279,6 +288,8 @@ class EntryManager: "tags": tags or [], } else: + if not validate_totp_secret(secret): + raise ValueError("Invalid TOTP secret") entry = { "type": EntryType.TOTP.value, "kind": EntryType.TOTP.value, @@ -323,6 +334,12 @@ class EntryManager: if index is None: index = self.get_next_index() + from .password_generation import derive_ssh_key_pair + + priv_pem, pub_pem = derive_ssh_key_pair(parent_seed, index) + if not validate_ssh_key_pair(priv_pem, pub_pem): + raise ValueError("Derived SSH key pair failed validation") + data = self._load_index() data.setdefault("entries", {}) data["entries"][str(index)] = { @@ -370,6 +387,17 @@ class EntryManager: if index is None: index = self.get_next_index() + from .password_generation import derive_pgp_key + from local_bip85.bip85 import BIP85 + from bip_utils import Bip39SeedGenerator + + seed_bytes = Bip39SeedGenerator(parent_seed).Generate() + bip85 = BIP85(seed_bytes) + + priv_key, fp = derive_pgp_key(bip85, index, key_type, user_id) + if not validate_pgp_private_key(priv_key, fp): + raise ValueError("Derived PGP key failed validation") + data = self._load_index() data.setdefault("entries", {}) data["entries"][str(index)] = { @@ -413,6 +441,7 @@ class EntryManager: def add_nostr_key( self, label: str, + parent_seed: str, index: int | None = None, notes: str = "", archived: bool = False, @@ -423,6 +452,19 @@ class EntryManager: if index is None: index = self.get_next_index() + from local_bip85.bip85 import BIP85 + from bip_utils import Bip39SeedGenerator + from nostr.coincurve_keys import Keys + + seed_bytes = Bip39SeedGenerator(parent_seed).Generate() + bip85 = BIP85(seed_bytes) + entropy = bip85.derive_entropy(index=index, bytes_len=32) + keys = Keys(priv_k=entropy.hex()) + npub = Keys.hex_to_bech32(keys.public_key_hex(), "npub") + nsec = Keys.hex_to_bech32(keys.private_key_hex(), "nsec") + if not validate_nostr_keys(npub, nsec): + raise ValueError("Derived Nostr keys failed validation") + data = self._load_index() data.setdefault("entries", {}) data["entries"][str(index)] = { @@ -515,6 +557,16 @@ class EntryManager: if index is None: index = self.get_next_index() + from .password_generation import derive_seed_phrase + from local_bip85.bip85 import BIP85 + from bip_utils import Bip39SeedGenerator + + seed_bytes = Bip39SeedGenerator(parent_seed).Generate() + bip85 = BIP85(seed_bytes) + phrase = derive_seed_phrase(bip85, index, words_num) + if not validate_seed_phrase(phrase): + raise ValueError("Derived seed phrase failed validation") + data = self._load_index() data.setdefault("entries", {}) data["entries"][str(index)] = { @@ -583,6 +635,8 @@ class EntryManager: word_count = 12 seed_phrase = derive_seed_phrase(bip85, index, word_count) + if not validate_seed_phrase(seed_phrase): + raise ValueError("Derived managed account seed failed validation") fingerprint = generate_fingerprint(seed_phrase) account_dir = self.fingerprint_dir / "accounts" / fingerprint diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 3d6d7bb..a65dce8 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -1970,7 +1970,9 @@ class PasswordManager: if tags_input else [] ) - index = self.entry_manager.add_nostr_key(label, notes=notes, tags=tags) + index = self.entry_manager.add_nostr_key( + label, self.parent_seed, notes=notes, tags=tags + ) npub, nsec = self.entry_manager.get_nostr_key_pair(index, self.parent_seed) self.is_dirty = True self.last_update = time.time() diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py index ee553a6..3c77d08 100644 --- a/src/tests/test_cli_doc_examples.py +++ b/src/tests/test_cli_doc_examples.py @@ -24,7 +24,7 @@ class DummyPM: add_totp=lambda label, seed, index=None, secret=None, period=30, digits=6: "totp://", add_ssh_key=lambda label, seed, index=None, notes="": 2, add_pgp_key=lambda label, seed, index=None, key_type="ed25519", user_id="", notes="": 3, - add_nostr_key=lambda label, index=None, notes="": 4, + add_nostr_key=lambda label, seed, index=None, notes="": 4, add_seed=lambda label, seed, index=None, words_num=24, notes="": 5, add_key_value=lambda label, key, value, notes="": 6, add_managed_account=lambda label, seed, index=None, notes="": 7, diff --git a/src/tests/test_cli_entry_add_commands.py b/src/tests/test_cli_entry_add_commands.py index ab230b4..e27a03d 100644 --- a/src/tests/test_cli_entry_add_commands.py +++ b/src/tests/test_cli_entry_add_commands.py @@ -4,6 +4,7 @@ from typer.testing import CliRunner from seedpass.cli import app from seedpass import cli +from helpers import TEST_SEED runner = CliRunner() @@ -98,7 +99,7 @@ runner = CliRunner() "add-nostr", "add_nostr_key", ["Label", "--index", "4", "--notes", "n"], - ("Label",), + ("Label", "seed"), {"index": 4, "notes": "n"}, "5", ), diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index 6ea223c..7e1f20f 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -116,7 +116,7 @@ def test_legacy_entry_defaults_to_password(): ("add_totp", ("totp", TEST_SEED)), ("add_ssh_key", ("ssh", TEST_SEED)), ("add_pgp_key", ("pgp", TEST_SEED)), - ("add_nostr_key", ("nostr",)), + ("add_nostr_key", ("nostr", TEST_SEED)), ("add_seed", ("seed", TEST_SEED)), ("add_key_value", ("label", "k1", "val")), ("add_managed_account", ("acct", TEST_SEED)), diff --git a/src/tests/test_gui_headless.py b/src/tests/test_gui_headless.py index 635148a..d7b0129 100644 --- a/src/tests/test_gui_headless.py +++ b/src/tests/test_gui_headless.py @@ -49,7 +49,7 @@ class FakeEntries: self.added.append(("pgp", label)) return 1 - def add_nostr_key(self, label): + def add_nostr_key(self, label, seed=None): self.added.append(("nostr", label)) return 1 diff --git a/src/tests/test_key_validation_failures.py b/src/tests/test_key_validation_failures.py new file mode 100644 index 0000000..3a8ae0d --- /dev/null +++ b/src/tests/test_key_validation_failures.py @@ -0,0 +1,66 @@ +import pytest +from pathlib import Path + +from helpers import create_vault, TEST_SEED, TEST_PASSWORD + +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager + + +def setup_mgr(tmp_path: Path) -> EntryManager: + vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg = ConfigManager(vault, tmp_path) + backup = BackupManager(tmp_path, cfg) + return EntryManager(vault, backup) + + +def test_add_totp_invalid_secret(tmp_path: Path): + mgr = setup_mgr(tmp_path) + with pytest.raises(ValueError): + mgr.add_totp("bad", TEST_SEED, secret="notbase32!") + + +def test_add_ssh_key_validation_failure(monkeypatch, tmp_path: Path): + mgr = setup_mgr(tmp_path) + monkeypatch.setattr( + "seedpass.core.entry_management.validate_ssh_key_pair", lambda p, q: False + ) + with pytest.raises(ValueError): + mgr.add_ssh_key("ssh", TEST_SEED) + + +def test_add_pgp_key_validation_failure(monkeypatch, tmp_path: Path): + mgr = setup_mgr(tmp_path) + monkeypatch.setattr( + "seedpass.core.entry_management.validate_pgp_private_key", lambda p, q: False + ) + with pytest.raises(ValueError): + mgr.add_pgp_key("pgp", TEST_SEED, user_id="test") + + +def test_add_nostr_key_validation_failure(monkeypatch, tmp_path: Path): + mgr = setup_mgr(tmp_path) + monkeypatch.setattr( + "seedpass.core.entry_management.validate_nostr_keys", lambda p, q: False + ) + with pytest.raises(ValueError): + mgr.add_nostr_key("nostr", TEST_SEED) + + +def test_add_seed_validation_failure(monkeypatch, tmp_path: Path): + mgr = setup_mgr(tmp_path) + monkeypatch.setattr( + "seedpass.core.entry_management.validate_seed_phrase", lambda p: False + ) + with pytest.raises(ValueError): + mgr.add_seed("seed", TEST_SEED) + + +def test_add_managed_account_validation_failure(monkeypatch, tmp_path: Path): + mgr = setup_mgr(tmp_path) + monkeypatch.setattr( + "seedpass.core.entry_management.validate_seed_phrase", lambda p: False + ) + with pytest.raises(ValueError): + mgr.add_managed_account("acct", TEST_SEED) diff --git a/src/tests/test_manager_list_entries.py b/src/tests/test_manager_list_entries.py index 58dca43..b687c24 100644 --- a/src/tests/test_manager_list_entries.py +++ b/src/tests/test_manager_list_entries.py @@ -246,7 +246,7 @@ def test_show_nostr_entry_details(monkeypatch, capsys): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) pm, entry_mgr = _setup_manager(tmp_path) - idx = entry_mgr.add_nostr_key("nostr") + idx = entry_mgr.add_nostr_key("nostr", TEST_SEED) called = _detail_common(monkeypatch, pm) @@ -339,7 +339,7 @@ def test_show_entry_details_sensitive(monkeypatch, capsys, entry_type): expected = priv extra = fp elif entry_type == "nostr": - idx = entry_mgr.add_nostr_key("nostr") + idx = entry_mgr.add_nostr_key("nostr", TEST_SEED) _npub, nsec = entry_mgr.get_nostr_key_pair(idx, TEST_SEED) expected = nsec elif entry_type == "totp": diff --git a/src/tests/test_nostr_entry.py b/src/tests/test_nostr_entry.py index b8e1edb..80c36bc 100644 --- a/src/tests/test_nostr_entry.py +++ b/src/tests/test_nostr_entry.py @@ -22,7 +22,7 @@ def test_nostr_key_determinism(): backup_mgr = BackupManager(tmp_path, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) - idx = entry_mgr.add_nostr_key("main") + idx = entry_mgr.add_nostr_key("main", TEST_SEED) entry = entry_mgr.retrieve_entry(idx) assert entry == { "type": "nostr", diff --git a/src/tests/test_nostr_qr.py b/src/tests/test_nostr_qr.py index 1d032aa..c4bcca3 100644 --- a/src/tests/test_nostr_qr.py +++ b/src/tests/test_nostr_qr.py @@ -42,7 +42,7 @@ def test_show_qr_for_nostr_keys(monkeypatch): pm.is_dirty = False pm.secret_mode_enabled = False - idx = entry_mgr.add_nostr_key("main") + idx = entry_mgr.add_nostr_key("main", TEST_SEED) npub, _ = entry_mgr.get_nostr_key_pair(idx, TEST_SEED) inputs = iter([str(idx), "q", "p", ""]) @@ -78,7 +78,7 @@ def test_show_private_key_qr(monkeypatch, capsys): pm.is_dirty = False pm.secret_mode_enabled = False - idx = entry_mgr.add_nostr_key("main") + idx = entry_mgr.add_nostr_key("main", TEST_SEED) _, nsec = entry_mgr.get_nostr_key_pair(idx, TEST_SEED) inputs = iter([str(idx), "q", "k", ""]) @@ -116,7 +116,7 @@ def test_qr_menu_case_insensitive(monkeypatch): pm.is_dirty = False pm.secret_mode_enabled = False - idx = entry_mgr.add_nostr_key("main") + idx = entry_mgr.add_nostr_key("main", TEST_SEED) npub, _ = entry_mgr.get_nostr_key_pair(idx, TEST_SEED) # Modify index to use uppercase type/kind diff --git a/src/tests/test_retrieve_pause_sensitive_entries.py b/src/tests/test_retrieve_pause_sensitive_entries.py index 09d3852..7cdaa5b 100644 --- a/src/tests/test_retrieve_pause_sensitive_entries.py +++ b/src/tests/test_retrieve_pause_sensitive_entries.py @@ -20,7 +20,7 @@ import pytest (lambda mgr: mgr.add_seed("seed", TEST_SEED), True), (lambda mgr: mgr.add_pgp_key("pgp", TEST_SEED, user_id="test"), True), (lambda mgr: mgr.add_ssh_key("ssh", TEST_SEED), True), - (lambda mgr: mgr.add_nostr_key("nostr"), False), + (lambda mgr: mgr.add_nostr_key("nostr", TEST_SEED), False), ], ) def test_pause_before_entry_actions(monkeypatch, adder, needs_confirm): diff --git a/src/utils/key_validation.py b/src/utils/key_validation.py new file mode 100644 index 0000000..67dc654 --- /dev/null +++ b/src/utils/key_validation.py @@ -0,0 +1,69 @@ +"""Key validation helper functions.""" + +import logging +from cryptography.hazmat.primitives import serialization +from pgpy import PGPKey +import pyotp +from nostr.coincurve_keys import Keys +from mnemonic import Mnemonic + +logger = logging.getLogger(__name__) + + +def validate_totp_secret(secret: str) -> bool: + """Return True if ``secret`` is a valid Base32 TOTP secret.""" + try: + pyotp.TOTP(secret).at(0) + return True + except Exception as e: # pragma: no cover - pyotp errors vary + logger.debug(f"Invalid TOTP secret: {e}") + return False + + +def validate_ssh_key_pair(priv_pem: str, pub_pem: str) -> bool: + """Ensure ``priv_pem`` corresponds to ``pub_pem``.""" + try: + priv = serialization.load_pem_private_key(priv_pem.encode(), password=None) + derived = ( + priv.public_key() + .public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode() + ) + return derived == pub_pem + except Exception as e: # pragma: no cover - serialization errors vary + logger.debug(f"SSH key validation failed: {e}") + return False + + +def validate_pgp_private_key(priv_key: str, fingerprint: str) -> bool: + """Return True if ``priv_key`` matches ``fingerprint``.""" + try: + key, _ = PGPKey.from_blob(priv_key) + return key.fingerprint == fingerprint + except Exception as e: # pragma: no cover - pgpy errors vary + logger.debug(f"PGP key validation failed: {e}") + return False + + +def validate_nostr_keys(npub: str, nsec: str) -> bool: + """Return True if ``nsec`` decodes to ``npub``.""" + try: + priv_hex = Keys.bech32_to_hex(nsec) + derived = Keys(priv_k=priv_hex) + encoded = Keys.hex_to_bech32(derived.public_key_hex(), "npub") + return encoded == npub + except Exception as e: # pragma: no cover - nostr errors vary + logger.debug(f"Nostr key validation failed: {e}") + return False + + +def validate_seed_phrase(mnemonic: str) -> bool: + """Return True if ``mnemonic`` is a valid BIP-39 seed phrase.""" + try: + return Mnemonic("english").check(mnemonic) + except Exception as e: # pragma: no cover - mnemonic errors vary + logger.debug(f"Seed phrase validation failed: {e}") + return False