Add PGP key support

This commit is contained in:
thePR0M3TH3AN
2025-07-04 18:16:15 -04:00
parent f9f2c14f56
commit e421dbe4c3
7 changed files with 197 additions and 0 deletions

View File

@@ -33,6 +33,7 @@ mutmut==2.4.4
nostr-sdk==0.42.1
packaging==25.0
parso==0.8.4
pgpy==0.6.0
pluggy==1.6.0
pony==0.7.19
portalocker==3.2.0

View File

@@ -259,6 +259,55 @@ class EntryManager:
key_index = int(entry.get("index", index))
return derive_ssh_key_pair(parent_seed, key_index)
def add_pgp_key(
self,
parent_seed: str,
index: int | None = None,
key_type: str = "ed25519",
user_id: str = "",
notes: str = "",
) -> int:
"""Add a new PGP key entry."""
if index is None:
index = self.get_next_index()
data = self.vault.load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {
"type": EntryType.PGP.value,
"kind": EntryType.PGP.value,
"index": index,
"key_type": key_type,
"user_id": user_id,
"notes": notes,
}
self._save_index(data)
self.update_checksum()
self.backup_manager.create_backup()
return index
def get_pgp_key(self, index: int, parent_seed: str) -> tuple[str, str]:
"""Return the armored PGP private key and fingerprint for the entry."""
entry = self.retrieve_entry(index)
etype = entry.get("type") if entry else None
kind = entry.get("kind") if entry else None
if not entry or (etype != EntryType.PGP.value and kind != EntryType.PGP.value):
raise ValueError("Entry is not a PGP key entry")
from password_manager.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)
key_idx = int(entry.get("index", index))
key_type = entry.get("key_type", "ed25519")
user_id = entry.get("user_id", "")
return derive_pgp_key(bip85, key_idx, key_type, user_id)
def add_seed(
self,
parent_seed: str,

View File

@@ -11,3 +11,4 @@ class EntryType(str, Enum):
TOTP = "totp"
SSH = "ssh"
SEED = "seed"
PGP = "pgp"

View File

@@ -1212,6 +1212,30 @@ class PasswordManager:
logging.error(f"Error deriving seed phrase: {e}", exc_info=True)
print(colored(f"Error: Failed to derive seed phrase: {e}", "red"))
return
if entry_type == EntryType.PGP.value:
notes = entry.get("notes", "")
try:
priv_key, fingerprint = self.entry_manager.get_pgp_key(
index, self.parent_seed
)
if self.secret_mode_enabled:
copy_to_clipboard(priv_key, self.clipboard_clear_delay)
print(
colored(
f"[+] PGP key copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.",
"green",
)
)
else:
print(colored("\n[+] Retrieved PGP Key:\n", "green"))
print(colored(f"Fingerprint: {fingerprint}", "cyan"))
print(priv_key)
if notes:
print(colored(f"Notes: {notes}", "cyan"))
except Exception as e:
logging.error(f"Error deriving PGP key: {e}", exc_info=True)
print(colored(f"Error: Failed to derive PGP key: {e}", "red"))
return
website_name = entry.get("website")
length = entry.get("length")

View File

@@ -368,3 +368,97 @@ def derive_ssh_key_pair(parent_seed: str, index: int) -> tuple[str, str]:
def derive_seed_phrase(bip85: BIP85, idx: int, words: int = 24) -> str:
"""Derive a new BIP39 seed phrase using BIP85."""
return bip85.derive_mnemonic(index=idx, words_num=words)
def derive_pgp_key(
bip85: BIP85, idx: int, key_type: str = "ed25519", user_id: str = ""
) -> tuple[str, str]:
"""Derive a deterministic PGP private key and return it with its fingerprint."""
from pgpy import PGPKey, PGPUID
from pgpy.packet.packets import PrivKeyV4
from pgpy.packet.fields import (
EdDSAPriv,
RSAPriv,
ECPoint,
ECPointFormat,
EllipticCurveOID,
MPI,
)
from pgpy.constants import (
PubKeyAlgorithm,
KeyFlags,
HashAlgorithm,
SymmetricKeyAlgorithm,
CompressionAlgorithm,
)
from Crypto.PublicKey import RSA
from Crypto.Util.number import inverse
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives import serialization
import hashlib
import datetime
entropy = bip85.derive_entropy(index=idx, bytes_len=32, app_no=32)
created = datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc)
if key_type.lower() == "rsa":
class DRNG:
def __init__(self, seed: bytes) -> None:
self.seed = seed
def __call__(self, n: int) -> bytes: # pragma: no cover - deterministic
out = b""
while len(out) < n:
self.seed = hashlib.sha256(self.seed).digest()
out += self.seed
return out[:n]
rsa_key = RSA.generate(2048, randfunc=DRNG(entropy))
keymat = RSAPriv()
keymat.n = MPI(rsa_key.n)
keymat.e = MPI(rsa_key.e)
keymat.d = MPI(rsa_key.d)
keymat.p = MPI(rsa_key.p)
keymat.q = MPI(rsa_key.q)
keymat.u = MPI(inverse(keymat.p, keymat.q))
keymat._compute_chksum()
pkt = PrivKeyV4()
pkt.pkalg = PubKeyAlgorithm.RSAEncryptOrSign
pkt.keymaterial = keymat
else:
priv = ed25519.Ed25519PrivateKey.from_private_bytes(entropy)
public = priv.public_key().public_bytes(
serialization.Encoding.Raw, serialization.PublicFormat.Raw
)
keymat = EdDSAPriv()
keymat.oid = EllipticCurveOID.Ed25519
keymat.s = MPI(int.from_bytes(entropy, "big"))
keymat.p = ECPoint.from_values(
keymat.oid.key_size, ECPointFormat.Native, public
)
keymat._compute_chksum()
pkt = PrivKeyV4()
pkt.pkalg = PubKeyAlgorithm.EdDSA
pkt.keymaterial = keymat
pkt.created = created
pkt.update_hlen()
key = PGPKey()
key._key = pkt
uid = PGPUID.new(user_id)
key.add_uid(
uid,
usage={
KeyFlags.Sign,
KeyFlags.EncryptCommunications,
KeyFlags.EncryptStorage,
},
hashes=[HashAlgorithm.SHA256],
ciphers=[SymmetricKeyAlgorithm.AES256],
compression=[CompressionAlgorithm.ZLIB],
)
return str(key), key.fingerprint

View File

@@ -18,6 +18,7 @@ websockets>=15.0.0
tomli
hypothesis
mutmut==2.4.4
pgpy==0.6.0
pyotp>=2.8.0
freezegun

View File

@@ -0,0 +1,27 @@
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.config_manager import ConfigManager
def test_pgp_key_determinism():
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)
idx = entry_mgr.add_pgp_key(TEST_SEED, key_type="ed25519", user_id="Test")
key1, fp1 = entry_mgr.get_pgp_key(idx, TEST_SEED)
key2, fp2 = entry_mgr.get_pgp_key(idx, TEST_SEED)
assert fp1 == fp2
assert key1 == key2