mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-07 06:48:52 +00:00
Add key validation utilities and integrate
This commit is contained in:
@@ -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),
|
||||
|
@@ -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,
|
||||
)
|
||||
|
@@ -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
|
||||
|
@@ -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()
|
||||
|
@@ -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,
|
||||
|
@@ -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",
|
||||
),
|
||||
|
@@ -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)),
|
||||
|
@@ -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
|
||||
|
||||
|
66
src/tests/test_key_validation_failures.py
Normal file
66
src/tests/test_key_validation_failures.py
Normal 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)
|
@@ -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":
|
||||
|
@@ -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",
|
||||
|
@@ -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
|
||||
|
@@ -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):
|
||||
|
69
src/utils/key_validation.py
Normal file
69
src/utils/key_validation.py
Normal 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
|
Reference in New Issue
Block a user