From e421dbe4c31d6cf701986a2c40a95fa4119a24d8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 4 Jul 2025 18:16:15 -0400 Subject: [PATCH] Add PGP key support --- requirements.lock | 1 + src/password_manager/entry_management.py | 49 +++++++++++ src/password_manager/entry_types.py | 1 + src/password_manager/manager.py | 24 ++++++ src/password_manager/password_generation.py | 94 +++++++++++++++++++++ src/requirements.txt | 1 + src/tests/test_pgp_entry.py | 27 ++++++ 7 files changed, 197 insertions(+) create mode 100644 src/tests/test_pgp_entry.py diff --git a/requirements.lock b/requirements.lock index 8463281..478be5c 100644 --- a/requirements.lock +++ b/requirements.lock @@ -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 diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index c39ebbb..7f1aee7 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -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, diff --git a/src/password_manager/entry_types.py b/src/password_manager/entry_types.py index bfdc5c5..186180b 100644 --- a/src/password_manager/entry_types.py +++ b/src/password_manager/entry_types.py @@ -11,3 +11,4 @@ class EntryType(str, Enum): TOTP = "totp" SSH = "ssh" SEED = "seed" + PGP = "pgp" diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 126540d..61159bb 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -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") diff --git a/src/password_manager/password_generation.py b/src/password_manager/password_generation.py index 41d7a93..d312d72 100644 --- a/src/password_manager/password_generation.py +++ b/src/password_manager/password_generation.py @@ -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 diff --git a/src/requirements.txt b/src/requirements.txt index 22a2dfd..2a41d78 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -18,6 +18,7 @@ websockets>=15.0.0 tomli hypothesis mutmut==2.4.4 +pgpy==0.6.0 pyotp>=2.8.0 freezegun diff --git a/src/tests/test_pgp_entry.py b/src/tests/test_pgp_entry.py new file mode 100644 index 0000000..b68db84 --- /dev/null +++ b/src/tests/test_pgp_entry.py @@ -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