Merge pull request #709 from PR0M3TH3AN/codex/add-key-validation-module-and-update-entrymanager

Add key validation helpers and enforce in entry management
This commit is contained in:
thePR0M3TH3AN
2025-08-01 10:43:47 -04:00
committed by GitHub
14 changed files with 206 additions and 12 deletions

View File

@@ -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),

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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()

View File

@@ -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,

View File

@@ -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",
),

View File

@@ -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)),

View File

@@ -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

View File

@@ -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)

View File

@@ -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":

View File

@@ -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",

View File

@@ -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

View File

@@ -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):

View File

@@ -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