diff --git a/AGENTS.md b/AGENTS.md index c5823933d..dfa217194 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,26 @@ Because this project handles private key material for an air-gapped signer, **se - Do not introduce new crypto dependencies or algorithms without explicit justification in the PR description. - Keep reproducibility and deterministic builds in mind for security-sensitive changes. +### Seed type differences — `seed_bytes` vs `get_root()` + +The codebase supports multiple seed types (see `src/seedsigner/models/seed.py`). They share a `Seed` base class but differ in critical ways. **Always use `seed.get_root(network)` to obtain the BIP-32 root key** — never call `bip32.HDKey.from_seed(seed.seed_bytes)` directly, because some seed types have `seed_bytes = None`. + +| Type | `seed_bytes` | `get_root(network)` | Notes | +|------|-------------|---------------------|-------| +| **`Seed`** (BIP39) | 64-byte BIP39 seed (from mnemonic + passphrase) | Derives root from `seed_bytes` with network version | Standard path; passphrase changes `seed_bytes` | +| **`XprvSeed`** | **`None`** | Returns pre-parsed `_root` HDKey (network param ignored) | No mnemonic, no seed bytes — root key is the only secret | +| **`ElectrumSeed`** | PBKDF2-derived bytes | Inherited from `Seed` | Overrides `script_override`, `derivation_override`, `detect_version` | +| **`AezeedSeed`** | Aezeed entropy | Inherited from `Seed` | Decrypted from aezeed ciphertext | +| **`Slip39Seed`** | SLIP-39 master secret | Inherited from `Seed` | Recovered from share combination | + +**Rules when writing code that handles seeds:** + +- **Never access `seed.seed_bytes` directly for key derivation.** Call `seed.get_root(network)` instead. `XprvSeed.seed_bytes` is `None` and will crash `bip32.HDKey.from_seed()`. +- **Check for feature support before using seed-type-specific features.** For example, `seed.mnemonic_list` is empty for `XprvSeed`; `seed.seedqr_supported` is `False` for `XprvSeed`, `ElectrumSeed`, and `Slip39Seed`. +- **Respect method overrides.** `ElectrumSeed` overrides `derivation_override()`, `script_override`, and `detect_version()`. Always call these through the seed object rather than assuming BIP39 defaults. +- **When working with non-Seed-like objects** (e.g. in helper functions that may receive mock objects or raw HDKeys), use the pattern `if hasattr(seed, "get_root"): root = seed.get_root()` with a fallback to `bip32.HDKey.from_seed(seed.seed_bytes)`. +- **Test with multiple seed types.** Any new feature touching key derivation, BIP85, address verification, or PSBT signing should be tested with at least `Seed` (BIP39) and `XprvSeed` to catch `seed_bytes = None` issues. + ### Code review expectations for sensitive changes For changes touching entropy, seed generation/import, key derivation, signing, or secret storage: - Add/extend tests for both success and failure/cleanup paths. @@ -70,3 +90,41 @@ For changes touching entropy, seed generation/import, key derivation, signing, o - Prefer shared code paths across workflows (scan/manual/import) instead of duplicating seed-handling logic. - Document threat assumptions and failure modes in code comments or PR notes. - Call out any remaining risk tradeoffs explicitly. + +## Unicode and locale-safe string handling + +SeedSigner must produce identical results regardless of the host locale or input method. Follow these rules when processing user-supplied or externally-sourced strings: + +### Normalization +- **BIP39 / SLIP39 / Electrum passphrases and mnemonics** must be NFKD-normalized (already done in `seed.py`). Do not change the normalization form. +- **Encrypted QR code passwords** (`kef.py` `Cipher` class) are NFKD-normalized before PBKDF2 key derivation, ensuring the same password produces the same encryption key regardless of platform (macOS typically stores NFD, Linux/Windows use NFC). +- **Display strings** shown to the user should be NFC-normalized (also already done in `seed.py` via `passphrase_display` / `mnemonic_display_str`). + +### Normalization audit summary + +The following table lists every code path where user-supplied strings feed into key derivation, encryption, or deterministic output, and whether NFKD normalization is applied: + +| Code path | Normalized? | Where | +|-----------|-------------|-------| +| BIP-39 passphrase | ✅ NFKD | `Seed.set_passphrase()` in `seed.py` | +| BIP-39 mnemonic | ✅ NFKD | `Seed.__init__()` in `seed.py` | +| SLIP-39 passphrase | ✅ NFKD | `Slip39Seed.set_slip39_passphrase()` in `seed.py` | +| Electrum passphrase | ✅ NFKD + lower() | `ElectrumSeed.normalize_electrum_passphrase()` in `seed.py` | +| Aezeed passphrase | ✅ NFKD (inherited) | Via `Seed.set_passphrase()` before reaching `aezeed.decode_mnemonic()` | +| Encrypted QR password | ✅ NFKD | `Cipher.__init__()` in `kef.py` | +| Dice / coin-flip entropy | N/A (ASCII-only) | Input constrained to `1-6` / `0-1` by keyboard UI | +| GPG name / email | N/A (ASCII keyboard) | Input constrained to ASCII by on-screen keyboard | +| GPG expiration dates | ✅ dash-normalized | `_normalize_date_input()` in `tools_views.py` | + +When adding a **new** code path that derives keys or produces deterministic output from user-supplied strings, always NFKD-normalize the input before encoding to bytes. + +### Date and numeric input +- When parsing dates from user input, always use `_normalize_date_input()` (in `tools_views.py`) to replace non-ASCII dashes (fullwidth `\uff0d`, en-dash `\u2013`, em-dash `\u2014`, Unicode minus `\u2212`) with ASCII hyphen-minus before calling `strptime` / `fromisoformat`. +- When converting user-provided numeric strings use `try/except ValueError` around `int()` / `float()` instead of pre-checking with `.isdigit()`. Python's `.isdigit()` returns `True` for non-ASCII Unicode digit characters (e.g. superscript `¹²³`) that `int()` / `float()` cannot convert, so the pre-check gives a false positive and the subsequent conversion raises `ValueError`. +- If an ASCII-only digit check is truly needed, combine `.isascii()` and `.isdigit()`, or test membership in `"0123456789"`. + +### General rules +- Never rely on locale-dependent behaviour (`str.lower()` with Turkish İ, `strftime` with locale month names, etc.) for data that affects derivation, signing, or deterministic output. +- QR-scanned data, settings QRs, and file-imported data should all be treated as untrusted byte strings; decode as UTF-8 with error handling before further processing. +- When adding new user-input parsing, add tests that exercise at least one non-ASCII variant (e.g. a fullwidth digit, a non-ASCII dash) to catch locale-dependent regressions. +- Be aware that the same Unicode character can have multiple representations (e.g. `é` can be U+00E9 [NFC] or U+0065 U+0301 [NFD]). macOS file-system APIs and some input methods produce NFD; most other systems produce NFC. NFKD normalization collapses both forms into a single canonical byte sequence. diff --git a/requirements.txt b/requirements.txt index 3dc0c7baa..00afaaca1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,11 @@ embit==0.8.0 Pillow==10.4.0 -pyzbar @ https://github.com/seedsigner/pyzbar/archive/c3c237821c6a20b17953efe59b90df0b514a1c03.zip#sha256=74c8330077013d17fb56d9677292dd0b854e44659111f26e210061bda53e72e0 +pyzbar @ https://github.com/seedsigner/pyzbar/archive/c3c237821c6a20b17953efe59b90df0b514a1c03.zip qrcode==7.3.1 colorama==0.4.6 ; platform_system == "Windows" -urtypes @ https://github.com/selfcustody/urtypes/archive/7fb280eab3b3563dfc57d2733b0bf5cbc0a96a6a.zip#sha256=a44046378f6f1bca21cee40ce941d7509e1ec1e8677b5b3b146a918fa6fd475d +urtypes @ https://github.com/selfcustody/urtypes/archive/7fb280eab3b3563dfc57d2733b0bf5cbc0a96a6a.zip pycryptodomex==3.23.0 -pgpy==0.6.0 +pgpy @ https://github.com/3rdIteration/PGPy/archive/1c8d881f84c455472114e5acf1ccdbc8809dd72f.zip # v0.6.0 fork: cryptography primary backend, pycryptodomex/ecdsa/embit fallbacks pyasn1==0.6.2 pysatochip==0.17.0 pyscard==2.3.1 @@ -18,4 +18,5 @@ certifi==2025.7.14 six==1.17.0 cffi==1.17.1 pycparser==2.22 +smbus2==0.4.3 ; platform_system != "Windows" shamir-mnemonic==0.3.0 diff --git a/src/seedsigner/helpers/ec_point.py b/src/seedsigner/helpers/ec_point.py new file mode 100644 index 000000000..ee7c83e5a --- /dev/null +++ b/src/seedsigner/helpers/ec_point.py @@ -0,0 +1,286 @@ +"""EC public-key derivation with multiple crypto backend support. + +Supports automatic fail-over between backends: + +* **python-cryptography** — preferred when available (C-accelerated) +* **PyCryptodome** (pycryptodomex) — NIST curves, Ed25519, Curve25519 +* **embit** — secp256k1 (native Bitcoin library) +* **ecdsa** — pure-Python ECDSA for NIST, secp256k1, and Brainpool +* **pure_python** — minimal double-and-add for Brainpool curves + +The module auto-detects which libraries are installed and uses the best +available backend. Use :func:`set_backend` to force a specific backend +for testing or compatibility. +""" +from __future__ import annotations + + +# --------------------------------------------------------------------------- +# Backend name constants +# --------------------------------------------------------------------------- + +CRYPTOGRAPHY = "cryptography" +PYCRYPTODOME = "pycryptodome" +EMBIT = "embit" +ECDSA_LIB = "ecdsa" +PURE_PYTHON = "pure_python" + + +# --------------------------------------------------------------------------- +# Backend detection +# --------------------------------------------------------------------------- + +def _detect_backends() -> set[str]: + """Detect which crypto backends are importable.""" + found: set[str] = {PURE_PYTHON} + try: + import ecdsa as _ecdsa # noqa: F841 + found.add(ECDSA_LIB) + except ImportError: + pass + try: + from embit import ec as _ec # noqa: F841 + found.add(EMBIT) + except ImportError: + pass + try: + from Cryptodome.PublicKey import ECC as _ECC # noqa: F841 + found.add(PYCRYPTODOME) + except ImportError: + pass + try: + from cryptography.hazmat.primitives.asymmetric import ec as _cry # noqa: F841 + found.add(CRYPTOGRAPHY) + except ImportError: + pass + return found + + +_available: set[str] = _detect_backends() +_forced_backend: str | None = None + + +def available_backends() -> frozenset[str]: + """Return the set of detected crypto backend names.""" + return frozenset(_available) + + +def set_backend(name: str | None) -> None: + """Force a specific backend, or ``None`` for auto-detection. + + Raises :exc:`ValueError` if *name* is not installed. + """ + global _forced_backend + if name is not None and name not in _available: + raise ValueError( + f"Backend {name!r} is not available. " + f"Installed: {sorted(_available)}" + ) + _forced_backend = name + + +def get_backend() -> str | None: + """Return the forced backend name, or ``None`` if auto-detecting.""" + return _forced_backend + + +# --------------------------------------------------------------------------- +# Helper: check if a backend should be tried +# --------------------------------------------------------------------------- + +def _should_try(name: str) -> bool: + """Return True if *name* should be attempted given current settings.""" + return name in _available and (_forced_backend is None or _forced_backend == name) + + +# --------------------------------------------------------------------------- +# Pure-Python EC point multiplication (for Brainpool curves) +# --------------------------------------------------------------------------- + +def _ec_point_mul(p: int, a: int, Gx: int, Gy: int, d: int) -> tuple[int, int]: + """Compute ``d * G`` on the curve ``y² = x³ + ax + b (mod p)``. + + Uses the standard double-and-add algorithm. The ``b`` coefficient is + not needed for point operations — only ``p`` (field prime) and ``a`` + are required. + + Returns the affine coordinates ``(x, y)`` of the result. + """ + + def _add(px: int, py: int, qx: int, qy: int) -> tuple[int, int]: + # Identity element represented as (0, 0). + if px == 0 and py == 0: + return qx, qy + if qx == 0 and qy == 0: + return px, py + if px == qx: + if py == qy and py != 0: + lam = (3 * px * px + a) * pow(2 * py, -1, p) % p + else: + return 0, 0 + else: + lam = (qy - py) * pow(qx - px, -1, p) % p + rx = (lam * lam - px - qx) % p + ry = (lam * (px - rx) - py) % p + return rx, ry + + rx, ry = 0, 0 + tx, ty = Gx, Gy + while d > 0: + if d & 1: + rx, ry = _add(rx, ry, tx, ty) + tx, ty = _add(tx, ty, tx, ty) + d >>= 1 + return rx, ry + + +# --------------------------------------------------------------------------- +# Brainpool curve parameters (field prime, coefficient a, generator point) +# from RFC 5639 — verified against OpenSSL ``ecparam -param_enc explicit``. +# --------------------------------------------------------------------------- + +_BRAINPOOL_P256R1 = ( # (p, a, Gx, Gy) + 0xA9FB57DBA1EEA9BC3E660A909D838D726E3BF623D52620282013481D1F6E5377, + 0x7D5A0975FC2C3057EEF67530417AFFE7FB8055C126DC5C6CE94A4B44F330B5D9, + 0x8BD2AEB9CB7E57CB2C4B482FFC81B7AFB9DE27E1E3BD23C23A4453BD9ACE3262, + 0x547EF835C3DAC4FD97F8461A14611DC9C27745132DED8E545C1D54C72F046997, +) + +_BRAINPOOL_P384R1 = ( # (p, a, Gx, Gy) + 0x8CB91E82A3386D280F5D6F7E50E641DF152F7109ED5456B412B1DA197FB71123ACD3A729901D1A71874700133107EC53, + 0x7BC382C63D8C150C3C72080ACE05AFA0C2BEA28E4FB22787139165EFBA91F90F8AA5814A503AD4EB04A8C7DD22CE2826, + 0x1D1C64F068CF45FFA2A63A81B7C13F6B8847A3E77EF14FE3DB7FCAFE0CBD10E8E826E03436D646AAEF87B2E247D4AF1E, + 0x8ABE1D7520F9C2A45CB1EB8E95CFD55262B70B29FEEC5864E19C054FF99129280E4646217791811142820341263C5315, +) + +_BRAINPOOL_P512R1 = ( # (p, a, Gx, Gy) + 0xAADD9DB8DBE9C48B3FD4E6AE33C9FC07CB308DB3B3C9D20ED6639CCA703308717D4D9B009BC66842AECDA12AE6A380E62881FF2F2D82C68528AA6056583A48F3, + 0x7830A3318B603B89E2327145AC234CC594CBDD8D3DF91610A83441CAEA9863BC2DED5D5AA8253AA10A2EF1C98B9AC8B57F1117A72BF2C7B9E7C1AC4D77FC94CA, + 0x81AEE4BDD82ED9645A21322E9C4C6A9385ED9F70B5D916C1B43B62EEF4D0098EFF3B1F78E2D0D48D50D1687B93B97D5F7C6D5047406A5E688B352209BCB9F822, + 0x7DDE385D566332ECC0EABFA9CF7822FDF209F70024A57B1AA000C55B881F8111B2DCDE494A5F485E5BCA4BD88A2763AED1CA2B2FA8F0540678CD1E0F3AD80892, +) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def ed25519_pub_from_seed(seed: bytes) -> bytes: + """Derive the 32-byte Ed25519 public key from a 32-byte seed.""" + if _should_try(CRYPTOGRAPHY): + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + return Ed25519PrivateKey.from_private_bytes(seed).public_key().public_bytes( + Encoding.Raw, PublicFormat.Raw) + + if _should_try(PYCRYPTODOME): + from Cryptodome.PublicKey import ECC + return ECC.construct(curve="Ed25519", seed=seed).public_key().export_key(format="raw") + + raise ImportError( + f"No backend for Ed25519 (forced={_forced_backend}, available={sorted(_available)})") + + +def curve25519_pub_from_seed(seed: bytes) -> bytes: + """Derive the 32-byte Curve25519 (X25519) public key from a 32-byte seed.""" + if _should_try(CRYPTOGRAPHY): + from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + return X25519PrivateKey.from_private_bytes(seed).public_key().public_bytes( + Encoding.Raw, PublicFormat.Raw) + + if _should_try(PYCRYPTODOME): + from Cryptodome.PublicKey import ECC + return ECC.construct(curve="Curve25519", seed=seed).public_key().export_key(format="raw") + + raise ImportError( + f"No backend for Curve25519 (forced={_forced_backend}, available={sorted(_available)})") + + +def secp256k1_pub_xy(d: int) -> tuple[int, int]: + """Derive the secp256k1 public key ``(x, y)`` from private scalar *d*.""" + if _should_try(CRYPTOGRAPHY): + from cryptography.hazmat.primitives.asymmetric import ec + key = ec.derive_private_key(d, ec.SECP256K1()) + nums = key.public_key().public_numbers() + return nums.x, nums.y + + if _should_try(EMBIT): + from embit import ec as embit_ec + priv = embit_ec.PrivateKey(d.to_bytes(32, "big")) + pub = priv.get_public_key() + pub.compressed = False + raw = pub.sec() # 0x04 || x (32 bytes) || y (32 bytes) + return int.from_bytes(raw[1:33], "big"), int.from_bytes(raw[33:65], "big") + + if _should_try(ECDSA_LIB): + import ecdsa + sk = ecdsa.SigningKey.from_secret_exponent(d, curve=ecdsa.SECP256k1) + raw = sk.get_verifying_key().to_string() + sz = len(raw) // 2 + return int.from_bytes(raw[:sz], "big"), int.from_bytes(raw[sz:], "big") + + raise ImportError( + f"No backend for secp256k1 (forced={_forced_backend}, available={sorted(_available)})") + + +def nist_pub_xy(curve_name: str, d: int) -> tuple[int, int]: + """Derive NIST public key ``(x, y)`` from private scalar *d*. + + *curve_name* must be one of ``"P-256"``, ``"P-384"``, or ``"P-521"``. + """ + if _should_try(CRYPTOGRAPHY): + from cryptography.hazmat.primitives.asymmetric import ec + _CURVES = {"P-256": ec.SECP256R1(), "P-384": ec.SECP384R1(), "P-521": ec.SECP521R1()} + key = ec.derive_private_key(d, _CURVES[curve_name]) + nums = key.public_key().public_numbers() + return nums.x, nums.y + + if _should_try(PYCRYPTODOME): + from Cryptodome.PublicKey import ECC + key = ECC.construct(curve=curve_name, d=d) + return int(key.pointQ.x), int(key.pointQ.y) + + if _should_try(ECDSA_LIB): + import ecdsa + _CURVES = {"P-256": ecdsa.NIST256p, "P-384": ecdsa.NIST384p, "P-521": ecdsa.NIST521p} + sk = ecdsa.SigningKey.from_secret_exponent(d, curve=_CURVES[curve_name]) + raw = sk.get_verifying_key().to_string() + sz = len(raw) // 2 + return int.from_bytes(raw[:sz], "big"), int.from_bytes(raw[sz:], "big") + + raise ImportError( + f"No backend for {curve_name} (forced={_forced_backend}, available={sorted(_available)})") + + +def brainpool_pub_xy(bits: int, d: int) -> tuple[int, int]: + """Derive a Brainpool public key ``(x, y)`` from private scalar *d*. + + *bits* must be ``256``, ``384``, or ``512``. + """ + if _should_try(CRYPTOGRAPHY): + from cryptography.hazmat.primitives.asymmetric import ec + _CURVES = {256: ec.BrainpoolP256R1(), 384: ec.BrainpoolP384R1(), 512: ec.BrainpoolP512R1()} + key = ec.derive_private_key(d, _CURVES[bits]) + nums = key.public_key().public_numbers() + return nums.x, nums.y + + if _should_try(ECDSA_LIB): + import ecdsa + _CURVES = {256: ecdsa.BRAINPOOLP256r1, 384: ecdsa.BRAINPOOLP384r1, 512: ecdsa.BRAINPOOLP512r1} + sk = ecdsa.SigningKey.from_secret_exponent(d, curve=_CURVES[bits]) + raw = sk.get_verifying_key().to_string() + sz = len(raw) // 2 + return int.from_bytes(raw[:sz], "big"), int.from_bytes(raw[sz:], "big") + + if _should_try(PURE_PYTHON): + params = { + 256: _BRAINPOOL_P256R1, + 384: _BRAINPOOL_P384R1, + 512: _BRAINPOOL_P512R1, + } + p, a, Gx, Gy = params[bits] + return _ec_point_mul(p, a, Gx, Gy, d) + + raise ImportError( + f"No backend for Brainpool P-{bits} (forced={_forced_backend}, available={sorted(_available)})") diff --git a/src/seedsigner/helpers/embit_utils.py b/src/seedsigner/helpers/embit_utils.py index 565d3ac0f..26ec6115a 100644 --- a/src/seedsigner/helpers/embit_utils.py +++ b/src/seedsigner/helpers/embit_utils.py @@ -230,8 +230,9 @@ def parse_derivation_path(derivation_path: str) -> dict: else: details["is_change"] = None - # Check if there's a standard address index - if sections[-1].isdigit(): + # Check if there's a standard address index (ASCII digits only; + # .isdigit() would also match non-ASCII Unicode digits). + if sections[-1].isascii() and sections[-1].isdigit(): details["index"] = int(sections[-1]) else: details["index"] = None @@ -253,18 +254,20 @@ def parse_derivation_path(derivation_path: str) -> dict: -def sign_message(seed_bytes: bytes, derivation: str, msg: bytes, compressed: bool = True, embit_network: str = "main") -> bytes: +def sign_message(root: HDKey, derivation: str, msg: bytes, compressed: bool = True) -> bytes: """ from: https://github.com/cryptoadvance/specter-diy/blob/b58a819ef09b2bca880a82c7e122618944355118/src/apps/signmessage/signmessage.py + + Sign a Bitcoin message using a BIP-32 root key and derivation path. + Use seed.get_root(network) to obtain the root key — never + bip32.HDKey.from_seed(seed.seed_bytes) directly (see AGENTS.md). """ - """Sign message with private key""" msghash = sha256( sha256( b"\x18Bitcoin Signed Message:\n" + compact.to_bytes(len(msg)) + msg ).digest() ).digest() - root = bip32.HDKey.from_seed(seed_bytes, version=NETWORKS[embit_network]["xprv"]) prv = root.derive(derivation).key sig = secp256k1.ecdsa_sign_recoverable(msghash, prv._secret) flag = sig[64] diff --git a/src/seedsigner/helpers/kef.py b/src/seedsigner/helpers/kef.py index 5e619413c..d31dbd73a 100644 --- a/src/seedsigner/helpers/kef.py +++ b/src/seedsigner/helpers/kef.py @@ -22,6 +22,7 @@ from Cryptodome.Cipher import AES import hashlib +import unicodedata # KEF: AES, MODEs VERSIONS, MODE_NUMBERS, and MODE_IVS are defined here @@ -141,8 +142,12 @@ class Cipher: """More than just a helper for AES encrypt/decrypt. Enforces KEF VERSIONS rules""" def __init__(self, key, salt, iterations): - key = key if isinstance(key, bytes) else key.encode() - salt = salt if isinstance(salt, bytes) else salt.encode() + # NFKD-normalize string inputs so that the same passphrase typed on + # different platforms (macOS NFD vs Linux NFC) always derives the same + # PBKDF2 key. This is consistent with BIP-39/SLIP-39 passphrase + # handling elsewhere in the codebase. + key = key if isinstance(key, bytes) else unicodedata.normalize("NFKD", key).encode() + salt = salt if isinstance(salt, bytes) else unicodedata.normalize("NFKD", salt).encode() self._key = hashlib.pbkdf2_hmac("sha256", key, salt, iterations) def encrypt(self, plain, version, iv=b"", fail_unsafe=True): diff --git a/src/seedsigner/helpers/passport_backup.py b/src/seedsigner/helpers/passport_backup.py index 4b336b10a..63869c195 100644 --- a/src/seedsigner/helpers/passport_backup.py +++ b/src/seedsigner/helpers/passport_backup.py @@ -18,7 +18,7 @@ class PassportBackupDetails: def _format_backup_code(code: str) -> str: - digits = [c for c in code if c.isdigit()] + digits = [c for c in code if c in "0123456789"] if len(digits) != 20: raise PassportBackupError("Backup code must include exactly 20 digits.") diff --git a/src/seedsigner/models/settings.py b/src/seedsigner/models/settings.py index f69d3e0f3..3660541a6 100644 --- a/src/seedsigner/models/settings.py +++ b/src/seedsigner/models/settings.py @@ -227,16 +227,24 @@ def parse_settingsqr(cls, data: str) -> tuple[str, dict]: for entry in data.split()[split_index:]: abbreviated_name, value = entry.split("=") - # Parse multi-value settings; numeric-ize where needed + # Parse multi-value settings; numeric-ize where needed. + # Use try/except instead of .isdigit() because .isdigit() returns + # True for non-ASCII Unicode digit characters (e.g. superscript ¹²³) + # that int()/float() cannot convert, causing a ValueError. if "," in value: values_updated = [] for v in value.split(","): - if v.replace(".", "", 1).isdigit(): + try: v = float(v) if "." in v else int(v) + except ValueError: + pass values_updated.append(v) value = values_updated - elif value.replace(".", "", 1).isdigit(): - value = float(value) if "." in value else int(value) + else: + try: + value = float(value) if "." in value else int(value) + except ValueError: + pass # Replace abbreviated name with full attr_name settings_entry = SettingsDefinition.get_settings_entry_by_abbreviated_name(abbreviated_name) diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index deaf23bc1..17d1237f6 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -451,7 +451,6 @@ def map_network_to_embit(cls, network) -> str: SETTING__CAMERA_DEVICE = "camera_device" SETTING__COMPACT_SEEDQR = "compact_seedqr" SETTING__BIP85_CHILD_SEEDS = "bip85_child_seeds" - SETTING__BIP85_ECC_KEYS = "bip85_ecc_keys" SETTING__SLIP39_SEEDS = "slip39_seeds" SETTING__AEZEED_SEEDS = "aezeed_seeds" SETTING__SLIP39_EXTENDABLE = "slip39_extendable" @@ -470,6 +469,7 @@ def map_network_to_embit(cls, network) -> str: SETTING__ENCRYPTION_ITER = "pbkdf2_iterations" SETTING__WIF_KEYS = "wif_keys" SETTING__BIP38_KEYS = "bip38_keys" + SETTING__GPG_KEY_TYPES = "gpg_key_types" SETTING__SATOCHIP_SIGN_TIMEOUT = "satochip_sign_timeout" SETTING__SATOCHIP_MSG_SIGN_TIMEOUT = "satochip_msg_sign_timeout" @@ -576,6 +576,44 @@ def map_network_to_embit(cls, network) -> str: (24, "24 words"), ] + # GPG key type constants + GPG_KEY_TYPE__ED25519 = "ed25519" + GPG_KEY_TYPE__P256 = "p256" + GPG_KEY_TYPE__P384 = "p384" + GPG_KEY_TYPE__P521 = "p521" + GPG_KEY_TYPE__BRAINPOOL_P256 = "brainpoolp256r1" + GPG_KEY_TYPE__BRAINPOOL_P384 = "brainpoolp384r1" + GPG_KEY_TYPE__BRAINPOOL_P512 = "brainpoolp512r1" + GPG_KEY_TYPE__RSA2048 = "rsa2048" + GPG_KEY_TYPE__RSA3072 = "rsa3072" + GPG_KEY_TYPE__RSA4096 = "rsa4096" + GPG_KEY_TYPE__SECP256K1 = "secp256k1" + + ALL_GPG_KEY_TYPES = [ + (GPG_KEY_TYPE__ED25519, "ECC Ed25519"), + (GPG_KEY_TYPE__P256, "ECC NIST P-256"), + (GPG_KEY_TYPE__P384, "ECC NIST P-384"), + (GPG_KEY_TYPE__P521, "ECC NIST P-521"), + (GPG_KEY_TYPE__BRAINPOOL_P256, "ECC Brainpool P-256"), + (GPG_KEY_TYPE__BRAINPOOL_P384, "ECC Brainpool P-384"), + (GPG_KEY_TYPE__BRAINPOOL_P512, "ECC Brainpool P-512"), + (GPG_KEY_TYPE__RSA2048, "RSA 2048"), + (GPG_KEY_TYPE__RSA3072, "RSA 3072"), + (GPG_KEY_TYPE__RSA4096, "RSA 4096"), + (GPG_KEY_TYPE__SECP256K1, "ECC secp256k1"), + ] + + # Default GPG key types match the "Generate New" menu + DEFAULT_GPG_KEY_TYPES = [ + GPG_KEY_TYPE__ED25519, + GPG_KEY_TYPE__P256, + GPG_KEY_TYPE__BRAINPOOL_P256, + GPG_KEY_TYPE__RSA2048, + GPG_KEY_TYPE__RSA3072, + GPG_KEY_TYPE__RSA4096, + GPG_KEY_TYPE__SECP256K1, + ] + @dataclass class SettingsEntry: @@ -917,6 +955,15 @@ class SettingsDefinition: visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__DISABLED), + SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, + attr_name=SettingsConstants.SETTING__GPG_KEY_TYPES, + abbreviated_name="gpgkeys", + display_name=_mft("GPG key types"), + type=SettingsConstants.TYPE__MULTISELECT, + visibility=SettingsConstants.VISIBILITY__ADVANCED, + selection_options=SettingsConstants.ALL_GPG_KEY_TYPES, + default_value=SettingsConstants.DEFAULT_GPG_KEY_TYPES), + SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__BIP85_CHILD_SEEDS, abbreviated_name="bip85", @@ -924,13 +971,6 @@ class SettingsDefinition: visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__ENABLED), - SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, - attr_name=SettingsConstants.SETTING__BIP85_ECC_KEYS, - abbreviated_name="bip85_ecc", - display_name=_mft("BIP85 ECC curves"), - visibility=SettingsConstants.VISIBILITY__ADVANCED, - default_value=SettingsConstants.OPTION__DISABLED), - SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__SLIP39_SEEDS, abbreviated_name="slip39", diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 5dc88c59f..c20d32bde 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -4992,7 +4992,7 @@ def __init__(self): self.seed_num = data["seed_num"] seed = self.controller.get_seed(self.seed_num) self.signed_message = embit_utils.sign_message( - seed_bytes=seed.get_root().secret, + root=seed.get_root(), derivation=derivation_path, msg=message.encode(), ) diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index dca2907aa..fcb3d4a96 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -3712,7 +3712,7 @@ def run(self): ) except Exception as e: self.loading_screen.stop() - logger.info("Satochip Import Failed:",str(e)) + logger.exception("Satochip Import Failed: %s", e) self.run_screen( WarningScreen, title="Failed", @@ -5791,14 +5791,31 @@ def parse_subkey_list(colon_output: str): # human-readable typo of "2009-01-03 18:05:05" UTC, which has propagated elsewhere. BIP85_GPG_CREATED_TS = 1231006505 -# BIP85 application numbers for supported GPG key types -BIP85_GPG_APP_RSA = 828365 -BIP85_GPG_APP_CURVE25519 = 828366 -BIP85_GPG_APP_SECP256K1 = 828367 -BIP85_GPG_APP_NIST_P256 = 828368 -BIP85_GPG_APP_BRAINPOOL_P256 = 828369 -BIP85_GPG_ECC_KEY_BITS = 256 +def _normalize_date_input(s: str) -> str: + """Strip whitespace and replace common non-ASCII dashes with ASCII hyphen. + + Locale or input-method differences can produce fullwidth hyphens (\uff0d), + en-dashes (\u2013), em-dashes (\u2014), or Unicode minus signs (\u2212) + instead of the expected ASCII hyphen-minus (U+002D). Normalizing these + prevents ``datetime.strptime`` / ``date.fromisoformat`` from raising + ``ValueError`` on otherwise-valid date strings. + """ + s = s.strip() + for ch in "\uff0d\u2013\u2014\u2212": + s = s.replace(ch, "-") + return s + +# Single BIP85 GPG application number per updated spec. +# Derivation path: m/83696968'/828365'/{key_type}'/{key_bits}'/{key_index}'[/{sub_key}'] +BIP85_GPG_APP = 828365 + +# BIP85 GPG key_type codes +BIP85_GPG_KEY_TYPE_RSA = 0 +BIP85_GPG_KEY_TYPE_CURVE25519 = 1 +BIP85_GPG_KEY_TYPE_SECP256K1 = 2 +BIP85_GPG_KEY_TYPE_NIST = 3 +BIP85_GPG_KEY_TYPE_BRAINPOOL = 4 # In-memory registry of BIP85-derived keys BIP85_DATA = {} @@ -5918,13 +5935,33 @@ class ToolsGPGMenuView(View): FILE_OPS = ButtonOption("File Operations") IMPORT = ButtonOption("Import Keys") EXPORT = ButtonOption("Export Keys") + VIEW_KEYS = ButtonOption("View Keys") MESSAGE = ButtonOption("Secure Messaging") SMART_GPG = ButtonOption("SmartGPG") ADVANCED = ButtonOption("Advanced") def run(self): + import shutil from seedsigner.controller import Controller + # Check that required dependencies are available + missing = [] + try: + import pgpy # noqa: F401 + except ImportError: + missing.append("pgpy") + if not shutil.which("gpg"): + missing.append("gnupg2") + if missing: + self.run_screen( + ErrorScreen, + title=_("GPG Tools"), + status_headline=_("Missing packages"), + text=_("Required but not installed:\n") + ", ".join(missing), + button_data=[ButtonOption("OK")], + ) + return Destination(BackStackView) + if self.controller.resume_main_flow == Controller.FLOW__GPG_MESSAGE: self.controller.resume_main_flow = None return Destination(ToolsGPGDecryptMessageView, skip_current_view=True) @@ -5933,6 +5970,7 @@ def run(self): self.FILE_OPS, self.IMPORT, self.EXPORT, + self.VIEW_KEYS, self.MESSAGE, self.SMART_GPG, self.ADVANCED, @@ -5955,6 +5993,8 @@ def run(self): return Destination(ToolsGPGImportMenuView) elif button_data[selected_menu_num] == self.EXPORT: return Destination(ToolsGPGExportMenuView) + elif button_data[selected_menu_num] == self.VIEW_KEYS: + return Destination(ToolsGPGViewKeysView) elif button_data[selected_menu_num] == self.MESSAGE: return Destination(ToolsGPGMessageMenuView) elif button_data[selected_menu_num] == self.SMART_GPG: @@ -6002,6 +6042,288 @@ def run(self): return Destination(ToolsGPGMenuView) +# ---- GPG algorithm code → human-readable name map --------------------------- +_GPG_ALGO_NAMES = { + "1": "RSA", + "16": "Elgamal", + "17": "DSA", + "18": "ECDH", + "19": "ECDSA", + "22": "EdDSA", +} + + +def _format_fpr_blocks(fpr: str) -> str: + """Format a hex fingerprint in blocks of 4 characters separated by spaces.""" + return " ".join(fpr[i:i + 4] for i in range(0, len(fpr), 4)) + + +def _gpg_algo_label(algo_code: str, curve: str, bits: str) -> str: + """Return a short human-readable description for a GPG key algorithm.""" + name = _GPG_ALGO_NAMES.get(algo_code, f"Algo {algo_code}") + if curve: + return f"{name} {curve}" + if bits: + return f"{name} {bits}" + return name + + +class ToolsGPGViewKeysView(View): + """List GPG secret keys and show fingerprint / metadata.""" + + def run(self): + from subprocess import run as _run + from seedsigner.gui.screens.screen import ( + ButtonListScreen, + WarningScreen, + ) + + result = _run( + ["gpg", "--list-secret-keys", "--with-colons"], + capture_output=True, + text=True, + ) + keys = parse_secret_key_list(result.stdout) + + # Collect all subkey fingerprints so we can exclude them from the + # primary key list. Some GPG configurations may list a subkey with + # its own ``sec`` record; filtering by fingerprint prevents those + # entries from appearing as separate keys in the UI. + all_subkey_fprs = { + sk["fpr"] + for sk in parse_subkey_list(result.stdout) + if sk.get("fpr") + } + keys = [k for k in keys if k.get("fpr") not in all_subkey_fprs] + + if not keys: + self.run_screen( + WarningScreen, + title="View Keys", + status_headline=None, + text="No secret keys found\nin the GPG keyring.", + show_back_button=False, + button_data=[ButtonOption("OK")], + ) + return Destination(BackStackView) + + buttons = [] + for k in keys: + label = k["uid"] if k["uid"] else k["fpr"][-16:] + buttons.append(ButtonOption(label)) + + selected = self.run_screen( + ButtonListScreen, + title="View Keys", + is_button_text_centered=False, + button_data=buttons, + ) + + if selected == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + key = keys[selected] + return Destination( + ToolsGPGKeyDetailsView, + view_args=dict(fpr=key["fpr"]), + ) + + +class ToolsGPGKeyDetailsView(View): + """Show details for a single GPG primary key.""" + + SUBKEYS = ButtonOption("Subkeys") + + def __init__(self, fpr: str): + super().__init__() + self.fpr = fpr + + def run(self): + from subprocess import run as _run + from seedsigner.gui.screens.screen import LargeIconStatusScreen + + detail = _run( + ["gpg", "--list-secret-keys", "--with-colons", self.fpr], + capture_output=True, + text=True, + ) + + # Primary key algorithm info from the ``sec`` line. + primary_algo = "" + uid = "" + for line in detail.stdout.splitlines(): + parts = line.split(":") + if parts[0] == "sec": + algo_code = parts[3] if len(parts) > 3 else "" + bits = parts[2] + curve = parts[16].lower() if len(parts) > 16 and parts[16] else "" + primary_algo = _gpg_algo_label(algo_code, curve, bits) + elif parts[0] == "uid" and not uid: + uid = parts[9] if len(parts) > 9 and parts[9] else "" + if not uid: + uid = self.fpr[-16:] + + subkeys = parse_subkey_list(detail.stdout) + + fpr_display = _format_fpr_blocks(self.fpr) + lines = [ + uid, + f"Fpr: {fpr_display}", + f"Type: {primary_algo}", + ] + + button_data = [] + if subkeys: + button_data.append(self.SUBKEYS) + + selected = self.run_screen( + LargeIconStatusScreen, + title="Key Details", + status_icon_size=0, + status_headline=None, + text="\n".join(lines), + show_back_button=True, + button_data=button_data if button_data else [ButtonOption("Back")], + ) + + if selected == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + if button_data and button_data[selected] == self.SUBKEYS: + return Destination( + ToolsGPGKeySubkeysView, + view_args=dict(fpr=self.fpr), + ) + + return Destination(BackStackView) + + +class ToolsGPGKeySubkeysView(View): + """List subkeys of a GPG primary key.""" + + def __init__(self, fpr: str): + super().__init__() + self.fpr = fpr + + def run(self): + from subprocess import run as _run + from seedsigner.gui.screens.screen import ButtonListScreen, WarningScreen + + detail = _run( + ["gpg", "--list-secret-keys", "--with-colons", self.fpr], + capture_output=True, + text=True, + ) + subkeys = parse_subkey_list(detail.stdout) + + if not subkeys: + self.run_screen( + WarningScreen, + title="Subkeys", + status_headline=None, + text="No subkeys found.", + show_back_button=False, + button_data=[ButtonOption("OK")], + ) + return Destination(BackStackView) + + buttons = [] + for sk in subkeys: + sk_algo = _gpg_algo_label( + sk["algo"], sk.get("curve", ""), sk["bits"] + ) + caps = sk.get("caps", "") + cap_labels = [] + if "s" in caps: + cap_labels.append("S") + if "e" in caps: + cap_labels.append("E") + if "a" in caps: + cap_labels.append("A") + cap_str = ",".join(cap_labels) if cap_labels else caps + sk_fpr_short = sk["fpr"][-8:] if sk.get("fpr") else "?" + label = f"{sk_fpr_short} [{cap_str}] {sk_algo}" + buttons.append(ButtonOption(label)) + + selected = self.run_screen( + ButtonListScreen, + title="Subkeys", + is_button_text_centered=False, + button_data=buttons, + ) + + if selected == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + sk = subkeys[selected] + return Destination( + ToolsGPGSubkeyDetailsView, + view_args=dict(primary_fpr=self.fpr, subkey_fpr=sk["fpr"]), + ) + + +class ToolsGPGSubkeyDetailsView(View): + """Show details for a single GPG subkey.""" + + def __init__(self, primary_fpr: str, subkey_fpr: str): + super().__init__() + self.primary_fpr = primary_fpr + self.subkey_fpr = subkey_fpr + + def run(self): + from subprocess import run as _run + from seedsigner.gui.screens.screen import LargeIconStatusScreen + + detail = _run( + ["gpg", "--list-secret-keys", "--with-colons", self.primary_fpr], + capture_output=True, + text=True, + ) + subkeys = parse_subkey_list(detail.stdout) + + sk = None + for s in subkeys: + if s.get("fpr") == self.subkey_fpr: + sk = s + break + + if sk is None: + sk = {"fpr": self.subkey_fpr, "algo": "", "bits": "", "caps": ""} + + sk_algo = _gpg_algo_label( + sk["algo"], sk.get("curve", ""), sk["bits"] + ) + caps = sk.get("caps", "") + cap_labels = [] + if "s" in caps: + cap_labels.append("Sign") + if "e" in caps: + cap_labels.append("Encrypt") + if "a" in caps: + cap_labels.append("Auth") + cap_str = ", ".join(cap_labels) if cap_labels else caps + + fpr_display = _format_fpr_blocks(self.subkey_fpr) + lines = [ + f"Fpr: {fpr_display}", + f"Type: {sk_algo}", + ] + if cap_str: + lines.append(f"Caps: {cap_str}") + + self.run_screen( + LargeIconStatusScreen, + title="Subkey Details", + status_icon_size=0, + status_headline=None, + text="\n".join(lines), + show_back_button=True, + button_data=[ButtonOption("Back")], + ) + + return Destination(BackStackView) + + class ToolsGPGCrossCertifyView(View): def run(self): from subprocess import run @@ -6664,7 +6986,7 @@ def run(self): key_index = entry["index"] key_type_label = entry["key_type"] - key_type_lookup = dict(_bip85_key_type_choices(True)) + key_type_lookup = dict(_bip85_key_type_choices(include_all=True)) key_type = key_type_lookup[key_type_label] uid_list = entry.get("uids", []) @@ -6798,9 +7120,21 @@ def rsa_to_privpacket(rsa_key): elif key_type == "p256": pk.pkalg = PubKeyAlgorithm.ECDSA pk.keymaterial = bip85_p256_from_root(root, key_index) + elif key_type == "p384": + pk.pkalg = PubKeyAlgorithm.ECDSA + pk.keymaterial = bip85_p384_from_root(root, key_index) + elif key_type == "p521": + pk.pkalg = PubKeyAlgorithm.ECDSA + pk.keymaterial = bip85_p521_from_root(root, key_index) elif key_type == "brainpoolp256r1": pk.pkalg = PubKeyAlgorithm.ECDSA pk.keymaterial = bip85_brainpoolp256r1_from_root(root, key_index) + elif key_type == "brainpoolp384r1": + pk.pkalg = PubKeyAlgorithm.ECDSA + pk.keymaterial = bip85_brainpoolp384r1_from_root(root, key_index) + elif key_type == "brainpoolp512r1": + pk.pkalg = PubKeyAlgorithm.ECDSA + pk.keymaterial = bip85_brainpoolp512r1_from_root(root, key_index) elif key_type == "ed25519": pk.pkalg = PubKeyAlgorithm.EdDSA pk.keymaterial = bip85_ed25519_from_root(root, key_index) @@ -6836,7 +7170,11 @@ def rsa_to_privpacket(rsa_key): specs = _bip85_subkey_specs( { "p256": "nistp256", + "p384": "nistp384", + "p521": "nistp521", "brainpoolp256r1": "brainpoolP256r1", + "brainpoolp384r1": "brainpoolP384r1", + "brainpoolp512r1": "brainpoolP512r1", "secp256k1": "secp256k1", "ed25519": "ed25519", }.get(key_type, "rsa") @@ -6845,7 +7183,11 @@ def rsa_to_privpacket(rsa_key): func_map = { "secp256k1": bip85_secp256k1_from_root, "p256": bip85_p256_from_root, + "p384": bip85_p384_from_root, + "p521": bip85_p521_from_root, "brainpoolp256r1": bip85_brainpoolp256r1_from_root, + "brainpoolp384r1": bip85_brainpoolp384r1_from_root, + "brainpoolp512r1": bip85_brainpoolp512r1_from_root, "ed25519": bip85_ed25519_from_root, } subpkt = PrivSubKeyV4() @@ -6884,7 +7226,11 @@ def rsa_to_privpacket(rsa_key): func_map = { "secp256k1": bip85_secp256k1_from_root, "nist p-256": bip85_p256_from_root, + "nist p-384": bip85_p384_from_root, + "nist p-521": bip85_p521_from_root, "brainpool p-256": bip85_brainpoolp256r1_from_root, + "brainpool p-384": bip85_brainpoolp384r1_from_root, + "brainpool p-512": bip85_brainpoolp512r1_from_root, "ed25519": bip85_ed25519_from_root, } pkalg_map = { @@ -7429,7 +7775,7 @@ def run(self): re.sub(r"\\(?!u)", r"\\\\", ret_dict["textToEncode"]), encoding="raw_unicode_escape", ).decode("unicode_escape") - except UnicodeDecodeError: + except UnicodeError: passphrase = ret_dict["textToEncode"] protected = run( @@ -7597,7 +7943,7 @@ def prompt_text(title: str): re.sub(r"\\(?!u)", r"\\\\", ret_dict["textToEncode"]), encoding="raw_unicode_escape", ).decode("unicode_escape") - except UnicodeDecodeError: + except UnicodeError: return ret_dict["textToEncode"] name = prompt_text("Name") @@ -7703,7 +8049,7 @@ def prompt_text(title: str, default: str = ""): re.sub(r"\\(?!u)", r"\\\\", ret_dict["textToEncode"]), encoding="raw_unicode_escape", ).decode("unicode_escape") - except UnicodeDecodeError: + except UnicodeError: return ret_dict["textToEncode"] name = prompt_text("Name") @@ -8264,12 +8610,23 @@ def run(self): if "is_back_button" in ret_dict: return Destination(BackStackView) passphrase = ret_dict["textToEncode"] - ciphertext = encrypt_message( - pub_blob, - message, - signkey_blob=signkey_blob, - signkey_passphrase=passphrase, - ) + try: + ciphertext = encrypt_message( + pub_blob, + message, + signkey_blob=signkey_blob, + signkey_passphrase=passphrase, + ) + except Exception: + self.run_screen( + WarningScreen, + title="Error", + status_headline=None, + text="Incorrect passphrase or\nunsupported key type", + show_back_button=False, + button_data=[ButtonOption("I Understand")], + ) + return Destination(BackStackView) except Exception: self.run_screen( WarningScreen, @@ -10510,7 +10867,7 @@ def run(self): re.sub(r"\\(?!u)", r"\\\\", ret_dict["textToEncode"]), encoding="raw_unicode_escape", ).decode("unicode_escape") - except UnicodeDecodeError: + except UnicodeError: passphrase = ret_dict["textToEncode"] decrypted = run( @@ -10666,6 +11023,14 @@ def run(self): def bip85_rsa_from_root(root, bits: int, index: int, sub_index: int | None = None): + """Generate a deterministic RSA key from BIP85 entropy. + + Uses PyCryptodome's ``RSA.generate(bits, randfunc=drng.read)`` as the + reference algorithm, matching the BIP85 spec example + ``RSA.generate_key(4096, drng_reader.read)``. Alternative RSA + implementations MUST consume the DRNG byte-stream identically to + PyCryptodome to ensure cross-implementation determinism. + """ from embit import bip85 from Cryptodome.PublicKey import RSA from seedsigner.helpers.bip85_drng import BIP85DRNG @@ -10674,10 +11039,10 @@ def bip85_rsa_from_root(root, bits: int, index: int, sub_index: int | None = Non if bits < MIN_RSA_KEY_BITS: bits = MIN_RSA_KEY_BITS - path = [bits, index] + path = [BIP85_GPG_KEY_TYPE_RSA, bits, index] if sub_index is not None: path.append(sub_index) - entropy = bip85.derive_entropy(root, BIP85_GPG_APP_RSA, path) + entropy = bip85.derive_entropy(root, BIP85_GPG_APP, path) drng = BIP85DRNG.new(entropy) return RSA.generate(bits, randfunc=drng.read) @@ -10686,27 +11051,19 @@ def bip85_ed25519_from_root( root, index: int, sub_index: int | None = None, alg: str = "EdDSA" ): from embit import bip85 - from cryptography.hazmat.primitives.asymmetric import ed25519, x25519 - from cryptography.hazmat.primitives import serialization + from seedsigner.helpers.ec_point import ed25519_pub_from_seed, curve25519_pub_from_seed from pgpy.constants import EllipticCurveOID from pgpy.packet import fields - path = [BIP85_GPG_ECC_KEY_BITS, index] + path = [BIP85_GPG_KEY_TYPE_CURVE25519, 256, index] if sub_index is not None: path.append(sub_index) - entropy = bip85.derive_entropy(root, BIP85_GPG_APP_CURVE25519, path) + entropy = bip85.derive_entropy(root, BIP85_GPG_APP, path) d_bytes = entropy[:32] if alg == "EdDSA": priv = fields.EdDSAPriv() priv.oid = EllipticCurveOID.Ed25519 - pub_bytes = ( - ed25519.Ed25519PrivateKey.from_private_bytes(d_bytes) - .public_key() - .public_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PublicFormat.Raw, - ) - ) + pub_bytes = ed25519_pub_from_seed(d_bytes) priv.p = fields.ECPoint.from_values( priv.oid.key_size, fields.ECPointFormat.Native, @@ -10718,20 +11075,24 @@ def bip85_ed25519_from_root( priv.oid = EllipticCurveOID.Curve25519 priv.kdf.halg = priv.oid.kdf_halg priv.kdf.encalg = priv.oid.kek_alg - pub_bytes = ( - x25519.X25519PrivateKey.from_private_bytes(d_bytes) - .public_key() - .public_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PublicFormat.Raw, - ) - ) + pub_bytes = curve25519_pub_from_seed(d_bytes) priv.p = fields.ECPoint.from_values( priv.oid.key_size, fields.ECPointFormat.Native, pub_bytes, ) - priv.s = fields.MPI(int.from_bytes(d_bytes, "big")) + # Clamp the Cv25519 scalar per RFC 7748 §5: clear the three low + # bits of LE byte 0, clear bit 255, set bit 254. Without + # clamping gpg-agent rejects the key on export ("Bad secret + # key"). Clamping is idempotent and doesn't change the derived + # public key (from_private_bytes clamps internally). + # The MPI must use little-endian conversion (matching pgpy's + # native Cv25519 convention), NOT big-endian. + d_clamped = bytearray(d_bytes) + d_clamped[0] &= 248 # LE byte 0: clear bits 0-2 + d_clamped[31] &= 127 # LE byte 31: clear bit 255 + d_clamped[31] |= 64 # LE byte 31: set bit 254 + priv.s = fields.MPI(int.from_bytes(d_clamped, "little")) priv._compute_chksum() return priv @@ -10826,6 +11187,8 @@ def _normalize_bip85_alg(alg: str) -> str: return alg alias = { "p256": "nistp256", + "p384": "nistp384", + "p521": "nistp521", } alg_lower = alg.lower() return alias.get(alg_lower, alg_lower) @@ -10841,7 +11204,7 @@ def _bip85_subkey_specs(alg): (1, PubKeyAlgorithm.EdDSA, {KeyFlags.Authentication, KeyFlags.Sign}, "EdDSA"), (2, PubKeyAlgorithm.EdDSA, {KeyFlags.Sign}, "EdDSA"), ] - if alg in ["secp256k1", "nistp256", "brainpoolp256r1"]: + if alg in ["secp256k1", "nistp256", "nistp384", "nistp521", "brainpoolp256r1", "brainpoolp384r1", "brainpoolp512r1"]: return [ (0, PubKeyAlgorithm.ECDH, {KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage}, "ECDH"), (1, PubKeyAlgorithm.ECDSA, {KeyFlags.Authentication, KeyFlags.Sign}, "ECDSA"), @@ -10913,9 +11276,21 @@ def rsa_to_privpacket(rsa_key: RSA.RsaKey) -> fields.RSAPriv: elif primary_curve == "nistp256": pk.pkalg = PubKeyAlgorithm.ECDSA pk.keymaterial = bip85_p256_from_root(root, key_index) + elif primary_curve == "nistp384": + pk.pkalg = PubKeyAlgorithm.ECDSA + pk.keymaterial = bip85_p384_from_root(root, key_index) + elif primary_curve == "nistp521": + pk.pkalg = PubKeyAlgorithm.ECDSA + pk.keymaterial = bip85_p521_from_root(root, key_index) elif primary_curve == "brainpoolp256r1": pk.pkalg = PubKeyAlgorithm.ECDSA pk.keymaterial = bip85_brainpoolp256r1_from_root(root, key_index) + elif primary_curve == "brainpoolp384r1": + pk.pkalg = PubKeyAlgorithm.ECDSA + pk.keymaterial = bip85_brainpoolp384r1_from_root(root, key_index) + elif primary_curve == "brainpoolp512r1": + pk.pkalg = PubKeyAlgorithm.ECDSA + pk.keymaterial = bip85_brainpoolp512r1_from_root(root, key_index) elif primary_curve == "ed25519": pk.pkalg = PubKeyAlgorithm.EdDSA pk.keymaterial = bip85_ed25519_from_root(root, key_index) @@ -10958,10 +11333,26 @@ def rsa_to_privpacket(rsa_key: RSA.RsaKey) -> fields.RSAPriv: subpkt.pkalg = PubKeyAlgorithm.ECDH if algo == "18" else PubKeyAlgorithm.ECDSA alg_name = "ECDH" if algo == "18" else "ECDSA" subpkt.keymaterial = bip85_p256_from_root(root, group_idx, sub_index, alg_name) + elif curve == "nistp384": + subpkt.pkalg = PubKeyAlgorithm.ECDH if algo == "18" else PubKeyAlgorithm.ECDSA + alg_name = "ECDH" if algo == "18" else "ECDSA" + subpkt.keymaterial = bip85_p384_from_root(root, group_idx, sub_index, alg_name) + elif curve == "nistp521": + subpkt.pkalg = PubKeyAlgorithm.ECDH if algo == "18" else PubKeyAlgorithm.ECDSA + alg_name = "ECDH" if algo == "18" else "ECDSA" + subpkt.keymaterial = bip85_p521_from_root(root, group_idx, sub_index, alg_name) elif curve == "brainpoolp256r1": subpkt.pkalg = PubKeyAlgorithm.ECDH if algo == "18" else PubKeyAlgorithm.ECDSA alg_name = "ECDH" if algo == "18" else "ECDSA" subpkt.keymaterial = bip85_brainpoolp256r1_from_root(root, group_idx, sub_index, alg_name) + elif curve == "brainpoolp384r1": + subpkt.pkalg = PubKeyAlgorithm.ECDH if algo == "18" else PubKeyAlgorithm.ECDSA + alg_name = "ECDH" if algo == "18" else "ECDSA" + subpkt.keymaterial = bip85_brainpoolp384r1_from_root(root, group_idx, sub_index, alg_name) + elif curve == "brainpoolp512r1": + subpkt.pkalg = PubKeyAlgorithm.ECDH if algo == "18" else PubKeyAlgorithm.ECDSA + alg_name = "ECDH" if algo == "18" else "ECDSA" + subpkt.keymaterial = bip85_brainpoolp512r1_from_root(root, group_idx, sub_index, alg_name) elif curve == "cv25519": if algo != "18": logger.warning( @@ -11028,7 +11419,10 @@ def bip85_add_subkeys( key_index, start_index, ) - root = bip32.HDKey.from_seed(seed.seed_bytes) + if hasattr(seed, "get_root"): + root = seed.get_root() + else: + root = bip32.HDKey.from_seed(seed.seed_bytes) export = run( ["gpg", "--armor", "--export-secret-keys", fingerprint], @@ -11065,8 +11459,16 @@ def rsa_to_privpacket(rsa_key: RSA.RsaKey) -> fields.RSAPriv: type_map = { "nistp256": "ECC NIST P-256", "p256": "ECC NIST P-256", + "nistp384": "ECC NIST P-384", + "p384": "ECC NIST P-384", + "nistp521": "ECC NIST P-521", + "p521": "ECC NIST P-521", "brainpoolp256r1": "ECC Brainpool P-256", "brainpoolP256r1": "ECC Brainpool P-256", + "brainpoolp384r1": "ECC Brainpool P-384", + "brainpoolP384r1": "ECC Brainpool P-384", + "brainpoolp512r1": "ECC Brainpool P-512", + "brainpoolP512r1": "ECC Brainpool P-512", "secp256k1": "ECC secp256k1", "ed25519": "ECC Ed25519", "rsa2048": "RSA 2048", @@ -11085,8 +11487,16 @@ def rsa_to_privpacket(rsa_key: RSA.RsaKey) -> fields.RSAPriv: subpkt.keymaterial = bip85_secp256k1_from_root(root, key_index, sub_index, alg_name[0]) elif alg_canon == "nistp256": subpkt.keymaterial = bip85_p256_from_root(root, key_index, sub_index, alg_name[0]) + elif alg_canon == "nistp384": + subpkt.keymaterial = bip85_p384_from_root(root, key_index, sub_index, alg_name[0]) + elif alg_canon == "nistp521": + subpkt.keymaterial = bip85_p521_from_root(root, key_index, sub_index, alg_name[0]) elif alg_canon == "brainpoolp256r1": subpkt.keymaterial = bip85_brainpoolp256r1_from_root(root, key_index, sub_index, alg_name[0]) + elif alg_canon == "brainpoolp384r1": + subpkt.keymaterial = bip85_brainpoolp384r1_from_root(root, key_index, sub_index, alg_name[0]) + elif alg_canon == "brainpoolp512r1": + subpkt.keymaterial = bip85_brainpoolp512r1_from_root(root, key_index, sub_index, alg_name[0]) elif alg_canon == "ed25519": subpkt.keymaterial = bip85_ed25519_from_root(root, key_index, sub_index, alg_name[0]) else: @@ -11133,7 +11543,11 @@ def loose_add_subkeys(fingerprint: str, alg: str) -> bool: curve_map = { "secp256k1": EllipticCurveOID.SECP256K1, "nistp256": EllipticCurveOID.NIST_P256, + "nistp384": EllipticCurveOID.NIST_P384, + "nistp521": EllipticCurveOID.NIST_P521, "brainpoolp256r1": EllipticCurveOID.Brainpool_P256, + "brainpoolp384r1": EllipticCurveOID.Brainpool_P384, + "brainpoolp512r1": EllipticCurveOID.Brainpool_P512, } curve = curve_map[alg_canon] @@ -11174,19 +11588,21 @@ def bip85_secp256k1_from_root( root, index: int, sub_index: int | None = None, alg: str = "ECDSA" ): from embit import bip85 - from cryptography.hazmat.primitives.asymmetric import ec + from seedsigner.helpers.ec_point import secp256k1_pub_xy from pgpy.constants import EllipticCurveOID from pgpy.packet import fields - path = [BIP85_GPG_ECC_KEY_BITS, index] + path = [BIP85_GPG_KEY_TYPE_SECP256K1, 256, index] if sub_index is not None: path.append(sub_index) - entropy = bip85.derive_entropy(root, BIP85_GPG_APP_SECP256K1, path) + entropy = bip85.derive_entropy(root, BIP85_GPG_APP, path) order = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 - d = int.from_bytes(entropy[:32], "big") % order - if d == 0: - d = 1 - pn = ec.derive_private_key(d, ec.SECP256K1()).public_key().public_numbers() + # Bit-mask to curve bit length (no-op for byte-aligned curves) then + # reduce into [1, order-1] only when the masked value is out of range. + d = int.from_bytes(entropy[:32], "big") & ((1 << 256) - 1) + if d == 0 or d >= order: + d = (d % (order - 1)) + 1 + pub_x, pub_y = secp256k1_pub_xy(d) if alg == "ECDH": priv = fields.ECDHPriv() priv.oid = EllipticCurveOID.SECP256K1 @@ -11198,8 +11614,8 @@ def bip85_secp256k1_from_root( priv.p = fields.ECPoint.from_values( priv.oid.key_size, fields.ECPointFormat.Standard, - fields.MPI(pn.x), - fields.MPI(pn.y), + fields.MPI(pub_x), + fields.MPI(pub_y), ) priv.s = fields.MPI(d) priv._compute_chksum() @@ -11210,22 +11626,24 @@ def bip85_p256_from_root( root, index: int, sub_index: int | None = None, alg: str = "ECDSA" ): from embit import bip85 - from cryptography.hazmat.primitives.asymmetric import ec + from seedsigner.helpers.ec_point import nist_pub_xy from pgpy.constants import EllipticCurveOID from pgpy.packet import fields - path = [BIP85_GPG_ECC_KEY_BITS, index] + path = [BIP85_GPG_KEY_TYPE_NIST, 256, index] if sub_index is not None: path.append(sub_index) - entropy = bip85.derive_entropy(root, BIP85_GPG_APP_NIST_P256, path) + entropy = bip85.derive_entropy(root, BIP85_GPG_APP, path) # Avoid relying on cryptography's ``group_order`` attribute since # some versions (such as those bundled with seedsigner-os) do not # expose it. Instead, use the well-known group order for P-256. order = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551 - d = int.from_bytes(entropy[:32], "big") % order - if d == 0: - d = 1 - pn = ec.derive_private_key(d, ec.SECP256R1()).public_key().public_numbers() + # Bit-mask to curve bit length (no-op for byte-aligned curves) then + # reduce into [1, order-1] only when the masked value is out of range. + d = int.from_bytes(entropy[:32], "big") & ((1 << 256) - 1) + if d == 0 or d >= order: + d = (d % (order - 1)) + 1 + pub_x, pub_y = nist_pub_xy("P-256", d) if alg == "ECDH": priv = fields.ECDHPriv() priv.oid = EllipticCurveOID.NIST_P256 @@ -11237,8 +11655,8 @@ def bip85_p256_from_root( priv.p = fields.ECPoint.from_values( priv.oid.key_size, fields.ECPointFormat.Standard, - fields.MPI(pn.x), - fields.MPI(pn.y), + fields.MPI(pub_x), + fields.MPI(pub_y), ) priv.s = fields.MPI(d) priv._compute_chksum() @@ -11249,21 +11667,23 @@ def bip85_brainpoolp256r1_from_root( root, index: int, sub_index: int | None = None, alg: str = "ECDSA", ): from embit import bip85 - from cryptography.hazmat.primitives.asymmetric import ec + from seedsigner.helpers.ec_point import brainpool_pub_xy from pgpy.constants import EllipticCurveOID from pgpy.packet import fields - path = [BIP85_GPG_ECC_KEY_BITS, index] + path = [BIP85_GPG_KEY_TYPE_BRAINPOOL, 256, index] if sub_index is not None: path.append(sub_index) - entropy = bip85.derive_entropy(root, BIP85_GPG_APP_BRAINPOOL_P256, path) + entropy = bip85.derive_entropy(root, BIP85_GPG_APP, path) # Hardcode BrainpoolP256r1 group order to avoid relying on attributes # that may be missing in some cryptography builds. order = 0xA9FB57DBA1EEA9BC3E660A909D838D718C397AA3B561A6F7901E0E82974856A7 - d = int.from_bytes(entropy[:32], "big") % order - if d == 0: - d = 1 - pn = ec.derive_private_key(d, ec.BrainpoolP256R1()).public_key().public_numbers() + # Bit-mask to curve bit length (no-op for byte-aligned curves) then + # reduce into [1, order-1] only when the masked value is out of range. + d = int.from_bytes(entropy[:32], "big") & ((1 << 256) - 1) + if d == 0 or d >= order: + d = (d % (order - 1)) + 1 + pub_x, pub_y = brainpool_pub_xy(256, d) if alg == "ECDH": priv = fields.ECDHPriv() priv.oid = EllipticCurveOID.Brainpool_P256 @@ -11275,36 +11695,190 @@ def bip85_brainpoolp256r1_from_root( priv.p = fields.ECPoint.from_values( priv.oid.key_size, fields.ECPointFormat.Standard, - fields.MPI(pn.x), - fields.MPI(pn.y), + fields.MPI(pub_x), + fields.MPI(pub_y), ) priv.s = fields.MPI(d) priv._compute_chksum() return priv -def _bip85_key_type_choices(include_ecc: bool) -> list[tuple[str, str]]: - """Return available key type labels and identifiers for BIP85 GPG keys.""" +def bip85_p384_from_root( + root, index: int, sub_index: int | None = None, alg: str = "ECDSA" +): + from embit import bip85 + from seedsigner.helpers.ec_point import nist_pub_xy + from pgpy.constants import EllipticCurveOID + from pgpy.packet import fields - choices: list[tuple[str, str]] = [] - if include_ecc: - choices.extend( - [ - ("ECC Ed25519", "ed25519"), - ("ECC NIST P-256", "p256"), - ("ECC Brainpool P-256", "brainpoolp256r1"), - ] - ) - choices.extend( - [ - ("RSA 2048", "rsa2048"), - ("RSA 3072", "rsa3072"), - ("RSA 4096", "rsa4096"), - ] + path = [BIP85_GPG_KEY_TYPE_NIST, 384, index] + if sub_index is not None: + path.append(sub_index) + entropy = bip85.derive_entropy(root, BIP85_GPG_APP, path) + order = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFC7634D81F4372DDF581A0DB248B0A77AECEC196ACCC52973 + # Bit-mask to curve bit length (no-op for byte-aligned curves) then + # reduce into [1, order-1] only when the masked value is out of range. + d = int.from_bytes(entropy[:48], "big") & ((1 << 384) - 1) + if d == 0 or d >= order: + d = (d % (order - 1)) + 1 + pub_x, pub_y = nist_pub_xy("P-384", d) + if alg == "ECDH": + priv = fields.ECDHPriv() + priv.oid = EllipticCurveOID.NIST_P384 + priv.kdf.halg = priv.oid.kdf_halg + priv.kdf.encalg = priv.oid.kek_alg + else: + priv = fields.ECDSAPriv() + priv.oid = EllipticCurveOID.NIST_P384 + priv.p = fields.ECPoint.from_values( + priv.oid.key_size, + fields.ECPointFormat.Standard, + fields.MPI(pub_x), + fields.MPI(pub_y), + ) + priv.s = fields.MPI(d) + priv._compute_chksum() + return priv + + +def bip85_p521_from_root( + root, index: int, sub_index: int | None = None, alg: str = "ECDSA" +): + from embit import bip85 + from seedsigner.helpers.ec_point import nist_pub_xy + from pgpy.constants import EllipticCurveOID + from pgpy.packet import fields + from seedsigner.helpers.bip85_drng import BIP85DRNG + + path = [BIP85_GPG_KEY_TYPE_NIST, 521, index] + if sub_index is not None: + path.append(sub_index) + entropy = bip85.derive_entropy(root, BIP85_GPG_APP, path) + # P-521 needs 66 bytes which exceeds the 64-byte HMAC output; use DRNG. + drng = BIP85DRNG.new(entropy) + d_bytes = drng.read(66) + order = 0x01FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFA51868783BF2F966B7FCC0148F709A5D03BB5C9B8899C47AEBB6FB71E91386409 + # Mask to 521 bits (matching bipsea reference implementation) then + # reduce into [1, order-1] only when the masked value is out of range. + d = int.from_bytes(d_bytes, "big") & ((1 << 521) - 1) + if d == 0 or d >= order: + d = (d % (order - 1)) + 1 + pub_x, pub_y = nist_pub_xy("P-521", d) + if alg == "ECDH": + priv = fields.ECDHPriv() + priv.oid = EllipticCurveOID.NIST_P521 + priv.kdf.halg = priv.oid.kdf_halg + priv.kdf.encalg = priv.oid.kek_alg + else: + priv = fields.ECDSAPriv() + priv.oid = EllipticCurveOID.NIST_P521 + priv.p = fields.ECPoint.from_values( + priv.oid.key_size, + fields.ECPointFormat.Standard, + fields.MPI(pub_x), + fields.MPI(pub_y), + ) + priv.s = fields.MPI(d) + priv._compute_chksum() + return priv + + +def bip85_brainpoolp384r1_from_root( + root, index: int, sub_index: int | None = None, alg: str = "ECDSA", +): + from embit import bip85 + from seedsigner.helpers.ec_point import brainpool_pub_xy + from pgpy.constants import EllipticCurveOID + from pgpy.packet import fields + + path = [BIP85_GPG_KEY_TYPE_BRAINPOOL, 384, index] + if sub_index is not None: + path.append(sub_index) + entropy = bip85.derive_entropy(root, BIP85_GPG_APP, path) + order = 0x8CB91E82A3386D280F5D6F7E50E641DF152F7109ED5456B31F166E6CAC0425A7CF3AB6AF6B7FC3103B883202E9046565 + # Bit-mask to curve bit length (no-op for byte-aligned curves) then + # reduce into [1, order-1] only when the masked value is out of range. + d = int.from_bytes(entropy[:48], "big") & ((1 << 384) - 1) + if d == 0 or d >= order: + d = (d % (order - 1)) + 1 + pub_x, pub_y = brainpool_pub_xy(384, d) + if alg == "ECDH": + priv = fields.ECDHPriv() + priv.oid = EllipticCurveOID.Brainpool_P384 + priv.kdf.halg = priv.oid.kdf_halg + priv.kdf.encalg = priv.oid.kek_alg + else: + priv = fields.ECDSAPriv() + priv.oid = EllipticCurveOID.Brainpool_P384 + priv.p = fields.ECPoint.from_values( + priv.oid.key_size, + fields.ECPointFormat.Standard, + fields.MPI(pub_x), + fields.MPI(pub_y), ) - if include_ecc: - choices.append(("ECC secp256k1", "secp256k1")) - return choices + priv.s = fields.MPI(d) + priv._compute_chksum() + return priv + + +def bip85_brainpoolp512r1_from_root( + root, index: int, sub_index: int | None = None, alg: str = "ECDSA", +): + from embit import bip85 + from seedsigner.helpers.ec_point import brainpool_pub_xy + from pgpy.constants import EllipticCurveOID + from pgpy.packet import fields + + path = [BIP85_GPG_KEY_TYPE_BRAINPOOL, 512, index] + if sub_index is not None: + path.append(sub_index) + entropy = bip85.derive_entropy(root, BIP85_GPG_APP, path) + order = 0xAADD9DB8DBE9C48B3FD4E6AE33C9FC07CB308DB3B3C9D20ED6639CCA70330870553E5C414CA92619418661197FAC10471DB1D381085DDADDB58796829CA90069 + # Bit-mask to curve bit length (no-op for byte-aligned curves) then + # reduce into [1, order-1] only when the masked value is out of range. + d = int.from_bytes(entropy[:64], "big") & ((1 << 512) - 1) + if d == 0 or d >= order: + d = (d % (order - 1)) + 1 + pub_x, pub_y = brainpool_pub_xy(512, d) + if alg == "ECDH": + priv = fields.ECDHPriv() + priv.oid = EllipticCurveOID.Brainpool_P512 + priv.kdf.halg = priv.oid.kdf_halg + priv.kdf.encalg = priv.oid.kek_alg + else: + priv = fields.ECDSAPriv() + priv.oid = EllipticCurveOID.Brainpool_P512 + priv.p = fields.ECPoint.from_values( + priv.oid.key_size, + fields.ECPointFormat.Standard, + fields.MPI(pub_x), + fields.MPI(pub_y), + ) + priv.s = fields.MPI(d) + priv._compute_chksum() + return priv + + +def _bip85_key_type_choices(include_all: bool = False) -> list[tuple[str, str]]: + """Return available key type labels and identifiers for BIP85 GPG keys. + + Parameters + ---------- + include_all : bool + When ``True`` return every supported key type regardless of the + ``SETTING__GPG_KEY_TYPES`` user preference. Used by the CLI tool + and import paths that must accept any key type. + """ + all_types = [ + (label, code) + for code, label in SettingsConstants.ALL_GPG_KEY_TYPES + ] + if include_all: + return all_types + + from seedsigner.models.settings import Settings + enabled = Settings.get_instance().get_value(SettingsConstants.SETTING__GPG_KEY_TYPES) + return [(label, code) for label, code in all_types if code in enabled] class ToolsGPGLoadBIP85KeyView(View): @@ -11339,24 +11913,18 @@ def run(self): ) return Destination(BackStackView) - ecc_enabled = ( - self.settings.get_value(SettingsConstants.SETTING__BIP85_ECC_KEYS) - == SettingsConstants.OPTION__ENABLED + self.run_screen( + WarningScreen, + title="WARNING", + status_headline=None, + text=( + "BIP85 GPG key derivation\nis experimental.\n" + "Record your SeedSigner Version." + ), + show_back_button=False, + button_data=[ButtonOption("I Understand")], ) - if ecc_enabled: - self.run_screen( - WarningScreen, - title="WARNING", - status_headline=None, - text=( - "ECC Curves extend beyond current BIP85 spec..\n" - "Record your SeedSigner Version." - ), - show_back_button=False, - button_data=[ButtonOption("I Understand")], - ) - if len(self.controller.storage.seeds) > 1: seed_buttons = [] for seed in self.controller.storage.seeds: @@ -11388,7 +11956,7 @@ def run(self): return Destination(BackStackView) key_index = int(ret) - keytype_choices = _bip85_key_type_choices(ecc_enabled) + keytype_choices = _bip85_key_type_choices() keytype_buttons = [ButtonOption(label) for label, _ in keytype_choices] selected_type = self.run_screen( ButtonListScreen, @@ -11442,7 +12010,7 @@ def prompt_text(title: str, default: str = ""): re.sub(r"\\(?!u)", r"\\\\", ret_dict["textToEncode"]), encoding="raw_unicode_escape", ).decode("unicode_escape") - except UnicodeDecodeError: + except UnicodeError: return ret_dict["textToEncode"] name = prompt_text("Name") @@ -11464,13 +12032,13 @@ def prompt_text(title: str, default: str = ""): if expiration_str is None: return Destination(BackStackView) try: - if expiration_str == "": + if expiration_str.strip() == "": expiration_dt = datetime.combine( default_expiration, datetime.min.time(), tzinfo=timezone.utc ) else: expiration_dt = datetime.strptime( - expiration_str, "%Y-%m-%d" + _normalize_date_input(expiration_str), "%Y-%m-%d" ).replace(tzinfo=timezone.utc) if expiration_dt <= created: raise ValueError @@ -11485,7 +12053,8 @@ def prompt_text(title: str, default: str = ""): button_data=[ButtonOption("I Understand")], ) return Destination(BackStackView) - root = bip32.HDKey.from_seed(seed.seed_bytes) + network = self.settings.get_value(SettingsConstants.SETTING__NETWORK) + root = seed.get_root(network) KEY_BITS = ( 2048 if key_type == "rsa2048" @@ -11517,9 +12086,21 @@ def rsa_to_privpacket(rsa_key: RSA.RsaKey) -> fields.RSAPriv: elif key_type == "p256": pk.pkalg = PubKeyAlgorithm.ECDSA pk.keymaterial = bip85_p256_from_root(root, key_index) + elif key_type == "p384": + pk.pkalg = PubKeyAlgorithm.ECDSA + pk.keymaterial = bip85_p384_from_root(root, key_index) + elif key_type == "p521": + pk.pkalg = PubKeyAlgorithm.ECDSA + pk.keymaterial = bip85_p521_from_root(root, key_index) elif key_type == "brainpoolp256r1": pk.pkalg = PubKeyAlgorithm.ECDSA pk.keymaterial = bip85_brainpoolp256r1_from_root(root, key_index) + elif key_type == "brainpoolp384r1": + pk.pkalg = PubKeyAlgorithm.ECDSA + pk.keymaterial = bip85_brainpoolp384r1_from_root(root, key_index) + elif key_type == "brainpoolp512r1": + pk.pkalg = PubKeyAlgorithm.ECDSA + pk.keymaterial = bip85_brainpoolp512r1_from_root(root, key_index) elif key_type == "ed25519": pk.pkalg = PubKeyAlgorithm.EdDSA pk.keymaterial = bip85_ed25519_from_root(root, key_index) @@ -11551,7 +12132,7 @@ def rsa_to_privpacket(rsa_key: RSA.RsaKey) -> fields.RSAPriv: (1, PubKeyAlgorithm.EdDSA, {KeyFlags.Authentication}, "EdDSA"), (2, PubKeyAlgorithm.EdDSA, {KeyFlags.Sign}, "EdDSA"), ] - elif key_type in ["secp256k1", "p256", "brainpoolp256r1"]: + elif key_type in ["secp256k1", "p256", "p384", "p521", "brainpoolp256r1", "brainpoolp384r1", "brainpoolp512r1"]: subkey_specs = [ (0, PubKeyAlgorithm.ECDH, {KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage}, "ECDH"), (1, PubKeyAlgorithm.ECDSA, {KeyFlags.Authentication}, "ECDSA"), @@ -11573,8 +12154,16 @@ def rsa_to_privpacket(rsa_key: RSA.RsaKey) -> fields.RSAPriv: subpkt.keymaterial = bip85_secp256k1_from_root(root, key_index, sub_index, alg[0]) elif key_type == "p256": subpkt.keymaterial = bip85_p256_from_root(root, key_index, sub_index, alg[0]) + elif key_type == "p384": + subpkt.keymaterial = bip85_p384_from_root(root, key_index, sub_index, alg[0]) + elif key_type == "p521": + subpkt.keymaterial = bip85_p521_from_root(root, key_index, sub_index, alg[0]) elif key_type == "brainpoolp256r1": subpkt.keymaterial = bip85_brainpoolp256r1_from_root(root, key_index, sub_index, alg[0]) + elif key_type == "brainpoolp384r1": + subpkt.keymaterial = bip85_brainpoolp384r1_from_root(root, key_index, sub_index, alg[0]) + elif key_type == "brainpoolp512r1": + subpkt.keymaterial = bip85_brainpoolp512r1_from_root(root, key_index, sub_index, alg[0]) elif key_type == "ed25519": subpkt.keymaterial = bip85_ed25519_from_root(root, key_index, sub_index, alg[0]) else: @@ -11798,11 +12387,7 @@ def run(self): base_index, key_index, ) - ecc_enabled = ( - self.settings.get_value(SettingsConstants.SETTING__BIP85_ECC_KEYS) - == SettingsConstants.OPTION__ENABLED - ) - keytype_choices = _bip85_key_type_choices(ecc_enabled) + keytype_choices = _bip85_key_type_choices() keytype_buttons = [ButtonOption(label) for label, _ in keytype_choices] selected_type = self.run_screen( ButtonListScreen, @@ -11905,43 +12490,66 @@ def run(self): tools_screens, ) - keytype_data = [ + all_keytype_data = [ ( + "ed25519", "ECC Ed25519", (PubKeyAlgorithm.EdDSA, EllipticCurveOID.Ed25519), True, ), ( + "p256", "ECC NIST P-256", (PubKeyAlgorithm.ECDSA, EllipticCurveOID.NIST_P256), False, ), ( + "brainpoolp256r1", "ECC Brainpool P-256", (PubKeyAlgorithm.ECDSA, EllipticCurveOID.Brainpool_P256), False, ), ( + "rsa2048", "RSA 2048", (PubKeyAlgorithm.RSAEncryptOrSign, 2048), False, ), ( + "rsa3072", "RSA 3072", (PubKeyAlgorithm.RSAEncryptOrSign, 3072), False, ), ( + "rsa4096", "RSA 4096", (PubKeyAlgorithm.RSAEncryptOrSign, 4096), False, ), ( + "secp256k1", "ECC secp256k1", (PubKeyAlgorithm.ECDSA, EllipticCurveOID.SECP256K1), True, ), ] + enabled = self.settings.get_value(SettingsConstants.SETTING__GPG_KEY_TYPES) + keytype_data = [ + (label, params, warn) + for code, label, params, warn in all_keytype_data + if code in enabled + ] + if not keytype_data: + self.run_screen( + WarningScreen, + title="Error", + status_headline=None, + text="No GPG key types enabled.\nEnable types in Settings.", + show_back_button=False, + button_data=[ButtonOption("I Understand")], + ) + return Destination(BackStackView) keytype_buttons = [ButtonOption(label) for label, _, _ in keytype_data] selected_type = self.run_screen( ButtonListScreen, @@ -11996,7 +12604,7 @@ def prompt_text(title: str, default: str = ""): re.sub(r"\\(?!u)", r"\\\\", ret_dict["textToEncode"]), encoding="raw_unicode_escape", ).decode("unicode_escape") - except UnicodeDecodeError: + except UnicodeError: return ret_dict["textToEncode"] name = prompt_text("Name") @@ -12018,13 +12626,13 @@ def prompt_text(title: str, default: str = ""): if expiration_str is None: return Destination(BackStackView) try: - if expiration_str == "": + if expiration_str.strip() == "": expiration_dt = datetime.combine( default_expiration, datetime.min.time(), tzinfo=timezone.utc ) else: expiration_dt = datetime.strptime( - expiration_str, "%Y-%m-%d" + _normalize_date_input(expiration_str), "%Y-%m-%d" ).replace(tzinfo=timezone.utc) if expiration_dt <= created: raise ValueError @@ -12505,7 +13113,7 @@ def run(self): re.sub(r"\\(?!u)", r"\\\\", ret_dict["textToEncode"]), encoding="raw_unicode_escape", ).decode("unicode_escape") - except UnicodeDecodeError: + except UnicodeError: passphrase = ret_dict["textToEncode"] filename = key["fpr"] + "_private.gpg" @@ -13147,7 +13755,7 @@ def run(self): encoding="raw_unicode_escape" ).decode("unicode_escape") - except UnicodeDecodeError: + except UnicodeError: self.textToEncode = ret_dict["textToEncode"] if "is_back_button" in ret_dict: diff --git a/tests/BIP85_GPG_CROSS_IMPL_REPORT.md b/tests/BIP85_GPG_CROSS_IMPL_REPORT.md new file mode 100644 index 000000000..b6f223906 --- /dev/null +++ b/tests/BIP85_GPG_CROSS_IMPL_REPORT.md @@ -0,0 +1,396 @@ +# BIP85 GPG Cross-Implementation Validation Report + +## Summary + +SeedSigner's BIP85 GPG implementation was validated against the bipsea reference +test vectors (commit `d8f8d9075a7ed6677c3be993f67c5d79e4bd63e1`), OpenSSL (via +python-cryptography), and PyCryptodome (FIPS 186-4). + +**All vectors now match.** RSA vectors were updated in bipsea to use PyCryptodome +for generation. The P-521 scalar derivation was fixed in SeedSigner to use bit +masking (matching bipsea's reference implementation) instead of modular reduction. + +--- + +## RSA: ✓ RESOLVED — All vectors match + +### Background + +In the initial validation, bipsea used a pure-Python `_is_prime()` with fixed +small-prime witnesses (2, 3, 5, …, 53) for Miller-Rabin primality testing. This +consumed zero DRNG bytes for Miller-Rabin rounds, producing different primes than +PyCryptodome's FIPS 186-4 implementation (which uses random witnesses from the DRNG). + +### Resolution + +As of bipsea commit `d8f8d9075a`, the reference implementation uses PyCryptodome's +`RSA.generate(key_bits, randfunc=drng.read)` for RSA generation. All RSA test +vectors have been regenerated and now match PyCryptodome exactly. + +### Validated RSA fingerprints + +| Key size | Fingerprint | bipsea | PyCryptodome | OpenSSL cross-sign | +|----------|-------------|--------|--------------|-------------------| +| RSA-1024 | `874A 3964 4ED0 255D EEC1 8E0E 1E63 8864 9672 CF70` | ✓ | ✓ | ✓ | +| RSA-2048 | `9987 9DF6 D21E 34C8 A086 A4BD 8B44 8E5B C298 294A` | ✓ | ✓ | ✓ | +| RSA-4096 | `24C2 5A48 383E 1175 4687 1767 D9A0 5CA6 4F2F 6A85` | ✓ | ✓ | ✓ | + +--- + +## NIST P-521: ✓ RESOLVED — Scalar derivation and fingerprint match + +### Problem + +The P-521 private scalar was derived differently between SeedSigner and bipsea: + +- **bipsea**: reads 66 bytes from SHAKE256 DRNG, applies **bit mask** + `& ((1 << 521) - 1)` to truncate to 521 bits +- **SeedSigner (old)**: reads 66 bytes from SHAKE256 DRNG, applies **modular + reduction** `% order` + +The 66-byte DRNG value has 528 bits (66 × 8). The bit mask clears the top 7 bits, +while modular reduction folds them into the result. For most DRNG outputs these +produce different scalars, different public keys, and different PGP fingerprints. + +### Resolution + +SeedSigner's `bip85_p521_from_root()` was updated to use bit masking followed by +range checking (matching bipsea's approach). The scalar, public point, and PGP +fingerprint now all match the bipsea reference vector. + +--- + +## Curve25519 (Ed25519 + Cv25519): Implementation Note + +The BIP85 entropy derivation for Curve25519 keys is straightforward: 32 bytes +from the HMAC-SHA512 output. The Ed25519 primary key fingerprint is deterministic +and matches across all implementations. + +However, an OpenPGP key using Ed25519 for signing also requires a **Cv25519 (X25519) +ECDH subkey** for encryption. This subkey is derived from the same entropy source +(via BIP85 sub-index) and requires two OpenPGP-level post-processing steps that are +**not part of the BIP85 spec** but are necessary for gpg-agent compatibility: + +1. **RFC 7748 §5 clamping**: The 32-byte Cv25519 scalar must be clamped before + storage in the OpenPGP secret-key packet: + ``` + d[0] &= 248 # clear bits 0-2 + d[31] &= 127 # clear bit 255 + d[31] |= 64 # set bit 254 + ``` + The `X25519PrivateKey.from_private_bytes()` API (python-cryptography, libsodium) + clamps internally for public key derivation, so the public key is always correct. + But gpg-agent validates the stored scalar directly and rejects unclamped values + with "Bad secret key" during export. + +2. **Little-endian MPI byte order**: pgpy stores Cv25519 secret MPIs as + `int.from_bytes(native_bytes, "little")`, unlike all other curve types which use + big-endian. This matches the native X25519 wire format (RFC 7748 uses + little-endian). + +**These are OpenPGP serialization requirements, not BIP85 changes.** The BIP85 +entropy output (32 raw bytes) is identical regardless of clamping or byte order. +The Ed25519 *primary key* fingerprint is unaffected. Only the Cv25519 *subkey* +packet serialization was corrected. + +No additions to the BIP85 specification are needed for this fix, but implementors +building OpenPGP keys from BIP85-derived Cv25519 entropy should be aware of these +requirements. + +--- + +## What was validated successfully + +All key types were cross-validated against **three independent implementations**: + +| Key type | Entropy | Private key | OpenSSL pubkey | OpenSSL sign/verify | PGP fingerprint (bipsea) | +|----------|---------|-------------|----------------|---------------------|--------------------------| +| RSA-1024 | ✓ | ✓ | ✓ (cross-sign) | ✓ | ✓ | +| RSA-2048 | ✓ | ✓ | ✓ (cross-sign) | ✓ | ✓ | +| RSA-4096 | ✓ | ✓ | ✓ (cross-sign) | ✓ | ✓ | +| Curve25519 (Ed25519) | ✓ | ✓ | ✓ | ✓ | ✓ | +| secp256k1 (256) | ✓ | ✓ | ✓ | ✓ | ✓ | +| NIST P-256 | ✓ | ✓ | ✓ | ✓ | ✓ | +| NIST P-384 | ✓ | ✓ | ✓ | ✓ | ✓ | +| NIST P-521 | ✓ | ✓ | ✓ | ✓ | ✓ | +| Brainpool P-256 | ✓ | ✓ | ✓ | ✓ | ✓ | +| Brainpool P-384 | ✓ | ✓ | ✓ | ✓ | ✓ | +| Brainpool P-512 | ✓ | ✓ | ✓ | ✓ | ✓ | + +--- + +## Proposed BIP85 spec paragraph for RSA determinism + +The following paragraph should be added to the BIP85 specification under the +GPG (OpenPGP) section, after the RSA key type description: + +~~~ +### RSA Key Generation Algorithm + +RSA key generation from the BIP85-DRNG MUST use the FIPS 186-4 (§B.3.1, +§C.3.1) algorithm for probable prime generation. Specifically, the +Miller-Rabin primality test MUST use random bases drawn from the same +DRNG (randfunc) that generates prime candidates — NOT fixed or +deterministic witnesses. + +The reference algorithm is PyCryptodome's `RSA.generate(key_bits, +randfunc=drng.read)`, which implements FIPS 186-4 with random +Miller-Rabin witnesses drawn from the provided `randfunc`. + +Implementations that use fixed Miller-Rabin witnesses (e.g., small +primes 2, 3, 5, …) will consume DRNG bytes at a different rate than +the reference algorithm, producing different primes and therefore +different RSA keys from the same entropy. Such implementations are +NOT compliant with this specification. + +The use of random witnesses is also cryptographically stronger, as +fixed small-prime witnesses cannot detect Carmichael numbers that are +strong pseudoprimes to all tested bases. +~~~ + +--- + +## Proposed BIP85 spec paragraph for ECC scalar derivation (P-521 / large curves) + +The following paragraph should be added to the BIP85 specification under the +GPG (OpenPGP) section, after the ECC key type descriptions: + +~~~ +### ECC Scalar Derivation for Curves Exceeding 64 Bytes + +For elliptic curves whose base length (⌈log₂(order) / 8⌉) exceeds the +64-byte HMAC-SHA512 entropy output — currently only NIST P-521 (base +length 66 bytes) — the private scalar MUST be derived using the +BIP85-DRNG (SHAKE256) as follows: + +1. Read `baselen` bytes from the DRNG (66 bytes for P-521). +2. Interpret the bytes as a big-endian unsigned integer. +3. Mask the integer to `order.bit_length()` bits: + `scalar = raw_int & ((1 << bit_length) - 1)` +4. If `scalar == 0` or `scalar >= order`, apply the fallback: + `scalar = (scalar % (order - 1)) + 1` + +Implementations MUST use bit masking (step 3) rather than direct +modular reduction (`raw_int % order`). The two methods produce +different scalars when the raw integer has more bits than the curve +order, because bit masking discards the top bits while modular +reduction folds them in. + +For curves with base length ≤ 64 bytes (all others in this spec), +the scalar is derived directly from the first `baselen` bytes of the +64-byte HMAC-SHA512 entropy, reduced modulo the curve order with the +same fallback. +~~~ + +--- + +## Dependency analysis: pgpy removal feasibility + +SeedSigner currently depends on three crypto libraries for GPG functionality: + +| Library | Version | Purpose | +|---------|---------|---------| +| **pgpy** | 0.6.0 | OpenPGP packet construction, key assembly, serialization (ASCII armor, fingerprints), message encryption/decryption, key parsing | +| **cryptography** | 45.0.5 | EC public key derivation from scalars (Ed25519, X25519, NIST/Brainpool curves) — also a **transitive dependency of pgpy** | +| **pycryptodomex** | 3.23.0 | RSA key generation with deterministic DRNG, SHAKE256 DRNG, AES, RIPEMD160 fallback | + +### What pgpy provides (5 categories of usage) + +**1. OpenPGP packet construction** — `fields.RSAPriv`, `fields.ECDSAPriv`, +`fields.EdDSAPriv`, `fields.ECDHPriv`, `fields.ECPoint`, `fields.MPI`, +`_compute_chksum()`. Used in every `bip85_*_from_root()` function and +`_rsa_to_privpacket()`. + +**2. Key assembly** — `PGPKey`, `PrivKeyV4`, `PrivSubKeyV4`, `PGPUID.new()`, +`add_uid()`, `add_subkey()`. These build complete OpenPGP keys with +self-signatures, user IDs, and subkey binding signatures. + +**3. Serialization** — `str(key)` for ASCII armor export, `key.fingerprint` +for v4 fingerprint computation, `key.pubkey` for public key extraction. + +**4. Message encryption/decryption** — `PGPMessage.new()`, `.encrypt()`, +`.decrypt()`, `.sign()`, `.verify()` in `gpg_message.py`. + +**5. Key parsing** — `PGPKey.from_blob()`, `.subkeys`, `.key_flags` / +`._get_key_flags()`, `._key.keymaterial` in `smartpgp_import.py`. + +### What python-cryptography provides + +Used **only** for EC public key derivation from BIP85-derived scalars: +- `ed25519.Ed25519PrivateKey.from_private_bytes()` → `.public_key().public_bytes()` +- `x25519.X25519PrivateKey.from_private_bytes()` → `.public_key().public_bytes()` +- `ec.derive_private_key(scalar, curve)` → `.public_key().public_numbers()` + +These compute EC public points from private scalars (7 curve types). +python-cryptography is also a **mandatory transitive dependency of pgpy**, +so removing pgpy alone would not eliminate it. + +### Feasibility assessment: replacing pgpy with pycryptodomex only + +**Short answer: technically possible but a major undertaking (~2000+ lines).** + +What would need to be reimplemented from scratch: + +1. **OpenPGP v4 packet format** (RFC 4880 §5.5–5.12) — Binary serialization of + secret key packets, public key packets, MPI encoding (big-endian length-prefixed + integers), S2K specifiers, key material checksums. + +2. **V4 fingerprint computation** (RFC 4880 §12.2) — SHA-1 hash of specific + packet header + key material bytes. Must exactly match gpg's computation + for key identity. + +3. **Self-signature and binding signature packets** (RFC 4880 §5.2) — The + `add_uid()` and `add_subkey()` calls create signature packets with hashed + subpackets (key flags, preferred algorithms, creation time, expiration). + These require signing with the primary key's algorithm. + +4. **ASCII armor encoding** (RFC 4880 §6) — Base64 with CRC24 checksum, + headers, and packet framing. + +5. **PGP message encryption/decryption** (RFC 4880 §5.1, 5.7, 5.13) — + Session key encryption (ECDH, RSA), symmetric data encryption (AES-256), + MDC computation, literal data packets. + +6. **EC public key derivation** — This is the python-cryptography part. + PyCryptodome supports NIST P-256 and P-384 via `ECC.construct()` but does + **not** support: + - Ed25519 / X25519 (Curve25519 family) + - secp256k1 + - Brainpool curves (P-256r1, P-384r1, P-512r1) + - NIST P-521 + + For the missing curves, you'd need either a pure-Python EC implementation + or a different C library. + +### Recommendation + +The most practical approach is **not** a monolithic replacement but rather a +phased strategy: + +1. **Phase 1 (low effort)**: Keep pgpy for packet construction and + serialization. The `gpg` binary handles the heavy crypto (signing, + encryption) on SeedSignerOS. pgpy is only used to build the initial key + structure and for offline key operations. + +2. **Phase 2 (medium effort)**: Replace python-cryptography EC point derivation + with pure-Python implementations where possible. PyCryptodome's `ECC` module + handles P-256 and P-384. For Ed25519/X25519, a pure-Python implementation + (~200 lines) could replace the cryptography dependency for just public key + derivation. secp256k1 could use embit's existing implementation. + +3. **Phase 3 (high effort)**: Replace pgpy entirely with a minimal OpenPGP + packet builder. This is ~1500–2000 lines of code covering v4 key packets, + signatures, ASCII armor, and fingerprint computation. The message + encryption/decryption in `gpg_message.py` could delegate to the `gpg` + binary instead. + +The blocking issue for a pycryptodomex-only solution is that PyCryptodome +**does not support** Ed25519, X25519, secp256k1, Brainpool, or P-521 in its +`ECC` module. These curves would require either keeping python-cryptography +as a dependency or adding pure-Python curve arithmetic. + +--- + +## Follow-up: python-gnupg as an alternative to pgpy + +### What is python-gnupg? + +**python-gnupg** (`gnupg` on PyPI) is a thin Python wrapper around the `gpg` +command-line binary. It calls `gpg` via `subprocess` and parses its +`--status-fd` output. It has **zero** Python-level crypto dependencies — no +python-cryptography, no pycryptodome. All actual cryptography is performed +by the native `gpg2` binary. + +### Would it remove the python-cryptography dependency? + +**Yes, partially — but with important caveats.** + +python-gnupg itself does not depend on python-cryptography. However: + +1. **python-cryptography is currently used directly** in `tools_views.py` + (lines 10833–11624) for EC public key derivation from BIP85 scalars + (`Ed25519PrivateKey.from_private_bytes()`, `X25519PrivateKey`, + `ec.derive_private_key()`). These 9 import sites across 7 ECC key + derivation functions are **not part of pgpy** — they call + python-cryptography directly. Replacing pgpy with python-gnupg would + not eliminate these. + +2. **pgpy uses python-cryptography internally** for its signing, encryption, + and EC operations. Removing pgpy would remove this *transitive* + dependency. + +So switching to python-gnupg removes the *transitive* path through pgpy but +**does not** remove the direct usage in `bip85_ed25519_from_root()`, +`bip85_secp256k1_from_root()`, `bip85_p256_from_root()`, etc. Those would +need separate replacement (see Phase 2 in the phased strategy above). + +### What python-gnupg can and cannot replace + +| Current pgpy usage | python-gnupg replacement? | Notes | +|---|---|---| +| **Key import** (`gpg --batch --import`) | ✅ `gpg.import_keys(armored)` | Already done via subprocess; trivial to wrap | +| **Key listing** (`gpg --list-secret-keys --with-colons`) | ✅ `gpg.list_keys(secret=True)` | Already done via subprocess; python-gnupg returns structured objects | +| **Subkey add** (`gpg --quick-addkey`) | ✅ python-gnupg doesn't wrap this but can call `gpg` generically | Current code already uses subprocess | +| **UID operations** (`--quick-add-uid`, `--quick-revoke-uid`) | ✅ Same — subprocess-based | No change needed | +| **File encrypt/decrypt** (Tools → File Operations) | ✅ `gpg.encrypt()` / `gpg.decrypt()` | Already uses subprocess `gpg` binary | +| **OpenPGP packet construction** (PrivKeyV4, MPI, ECPoint) | ❌ **Not possible** | python-gnupg is a CLI wrapper; it cannot construct custom key packets | +| **BIP85 key material injection** (setting `pk.keymaterial`) | ❌ **Not possible** | The core use case: deterministically setting private key bytes from BIP85 entropy | +| **ASCII armor serialization** (`str(pgp_key)`) | ❌ Only via gpg binary round-trip | pgpy serializes in-memory; python-gnupg requires the key to exist in the gpg keyring first | +| **Fingerprint computation** (`key.fingerprint`) | ❌ Only after gpg import | pgpy computes fingerprints from raw packet data before import | +| **Message encrypt/decrypt in gpg_message.py** | ⚠️ Possible but requires `gpg` binary | Current `gpg_message.py` explicitly avoids the gpg binary for portability | +| **SmartPGP card import** (parsing key material) | ❌ **Not possible** | Needs direct access to MPI values (n, e, d, p, q) from key packets | + +### The core problem: BIP85 deterministic key construction + +The fundamental issue is that **BIP85 GPG key derivation requires constructing +OpenPGP key packets from raw entropy bytes**. This means: + +1. Deriving a private scalar from BIP85 entropy +2. Computing the corresponding public key point +3. Encoding both into OpenPGP MPI format +4. Assembling a v4 secret key packet with correct creation timestamp +5. Adding self-signatures with the correct key flags +6. Computing the v4 fingerprint from the packet bytes +7. Exporting as ASCII armor + +python-gnupg cannot do steps 1–7 because it only wraps the `gpg` CLI. +The `gpg` binary does not accept raw key material — it either generates +keys internally or imports fully-formed OpenPGP packets. + +### What would need to change + +To use python-gnupg instead of pgpy, you would need: + +1. **Keep a minimal OpenPGP packet builder** (~800–1000 lines) to construct + secret key packets from BIP85 entropy. This replaces pgpy's + `PrivKeyV4`, `fields.*Priv`, `MPI`, `ECPoint`, `PGPUID.new()`, + `add_uid()`, `add_subkey()`, and `str(key)` (ASCII armor). + +2. **Replace the ~40 subprocess.run("gpg", ...) calls** with python-gnupg's + API for key listing, import, UID management, etc. This is mostly + cosmetic since both approaches call the same binary. + +3. **Replace gpg_message.py** with python-gnupg's `encrypt()`/`decrypt()` + methods, or keep the current subprocess approach. This would make + message operations depend on the `gpg` binary (currently pgpy handles + them in pure Python). + +4. **Replace smartpgp_import.py** key parsing with the minimal packet + reader from step 1, or keep pgpy just for this module. + +### Summary + +| Approach | Removes pgpy? | Removes python-cryptography? | Effort | Risk | +|---|---|---|---|---| +| **python-gnupg only** | ✅ | ❌ (direct usage remains) | Medium | Loses pure-Python message encrypt/decrypt | +| **python-gnupg + minimal packet builder** | ✅ | ❌ (direct EC point derivation remains) | Medium-High | ~1000 LOC new code to test and maintain | +| **python-gnupg + packet builder + embit/pure-Python ECC** | ✅ | ✅ | High | Most curves need custom implementations | +| **Keep pgpy, replace direct cryptography usage** | ❌ | ❌ (pgpy depends on it) | Low | Not useful for the goal | + +**Bottom line**: python-gnupg is a good replacement for the **subprocess.run("gpg", ...)** +calls that are already in the codebase (~40+ call sites). It provides +structured output parsing and error handling. However, it **cannot** replace +pgpy's core function of constructing OpenPGP key packets from raw BIP85 +entropy. A minimal packet builder would still be needed, and +python-cryptography would still be needed for EC public key derivation +unless replaced by pure-Python or embit-based implementations. diff --git a/tests/test_bip85_bipsea_vectors.py b/tests/test_bip85_bipsea_vectors.py new file mode 100644 index 000000000..b292e383b --- /dev/null +++ b/tests/test_bip85_bipsea_vectors.py @@ -0,0 +1,571 @@ +"""Cross-implementation BIP85 GPG test vectors. + +Validates SeedSigner's BIP85 GPG implementation against: + - bipsea reference vectors (entropy, private keys) + - OpenSSL (ECC public-key derivation via ``cryptography`` library) + - PyCryptodome FIPS 186-4 (RSA key generation) + +Source for bipsea vectors (updated): + https://github.com/3rdIteration/bipsea/blob/d8f8d9075a7ed6677c3be993f67c5d79e4bd63e1/test_vectors.md + +All derivations use master key: + xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLH + RdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb + +.. rubric:: RSA determinism: PyCryptodome as canonical reference + +RSA key generation from a deterministic DRNG requires a canonical +algorithm. The BIP85 spec references PyCryptodome's +``RSA.generate(bits, randfunc=drng.read)`` which follows FIPS 186-4 +(§B.3.1, §C.3.1): random Miller-Rabin witnesses drawn from ``randfunc``. + +As of bipsea commit ``d8f8d9075a``, the reference implementation has +been updated to use PyCryptodome for RSA generation, and all RSA test +vectors now match the FIPS 186-4 output. All RSA fingerprints in the +updated bipsea vectors are validated here against PyCryptodome directly. + +.. rubric:: P-521 scalar derivation + +The NIST P-521 private key scalar is derived by reading 66 bytes from +the SHAKE256 DRNG and masking to 521 bits (clearing the top 7 bits +of the first byte). This matches bipsea's reference implementation. +If the masked value is 0 or ≥ order, it is reduced modulo ``order - 1`` +and incremented by 1. + +.. rubric:: Summary of cross-implementation agreement + +================= ======= =========== ============== ============= +Key type Entropy Private key OpenSSL pubkey PGP fingerprint +================= ======= =========== ============== ============= +RSA-1024 ✓ ✓ ✓ (cross-sign) ✓ (bipsea) +RSA-2048 ✓ ✓ ✓ (cross-sign) ✓ (bipsea) +RSA-4096 ✓ ✓ ✓ (cross-sign) ✓ (bipsea) +Curve25519 (256) ✓ ✓ ✓ ✓ (bipsea) +secp256k1 (256) ✓ ✓ ✓ ✓ (bipsea) +NIST P-256 ✓ ✓ ✓ ✓ (bipsea) +NIST P-384 ✓ ✓ ✓ ✓ (bipsea) +NIST P-521 ✓ ✓ ✓ ✓ (bipsea) +Brainpool P-256 ✓ ✓ ✓ ✓ (bipsea) +Brainpool P-384 ✓ ✓ ✓ ✓ (bipsea) +Brainpool P-512 ✓ ✓ ✓ ✓ (bipsea) +================= ======= =========== ============== ============= +""" + +import datetime +import math +import sys +import shutil + +import pytest +from embit import bip32, bip85 + +import base # noqa: F401 – ensure hardware mocks + +from seedsigner.helpers.bip85_drng import BIP85DRNG +from seedsigner.views import tools_views +from seedsigner.views.tools_views import ( + BIP85_GPG_CREATED_TS, + BIP85_GPG_APP, + BIP85_GPG_KEY_TYPE_RSA, + BIP85_GPG_KEY_TYPE_CURVE25519, + BIP85_GPG_KEY_TYPE_SECP256K1, + BIP85_GPG_KEY_TYPE_NIST, + BIP85_GPG_KEY_TYPE_BRAINPOOL, + bip85_rsa_from_root, + bip85_ed25519_from_root, + bip85_secp256k1_from_root, + bip85_p256_from_root, + bip85_p384_from_root, + bip85_p521_from_root, + bip85_brainpoolp256r1_from_root, + bip85_brainpoolp384r1_from_root, + bip85_brainpoolp512r1_from_root, + _bip85_subkey_specs, +) + +pytestmark = pytest.mark.skipif( + sys.platform in ("darwin", "win32") or shutil.which("gpg") is None, + reason="requires working GnuPG2", +) + +MASTER_XPRV = ( + "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLH" + "RdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb" +) + + +# ── GPG entropy vectors ───────────────────────────────────────────────────── +# All GPG entropy is the 64-byte HMAC-SHA512 output from the BIP85 derivation. +# This is deterministic and implementation-agnostic (no library differences). + +GPG_ENTROPY_VECTORS = [ + # (key_type, key_bits, expected_entropy_hex) + (BIP85_GPG_KEY_TYPE_RSA, 1024, "2b9380df43421f46b5c38e13ea80612ff53488bc5d272e86d493ee1eecf738bb7b50e4978b7352f95772f1211483b0e6bba86c544a946b10d76ed493b8c2e01f"), + (BIP85_GPG_KEY_TYPE_RSA, 2048, "98c4fb6d76f203e8828bdfd28416edca7a83a9b203901f7ad31f056cda8b3c25b19e5fd2aa642ca0abb9ed8bebf3d141af6c76b28a19eba624bdc6f8a76ce138"), + (BIP85_GPG_KEY_TYPE_RSA, 4096, "2d2ef3335dc51e7a0642bfe86fba0bb4e8401b703d8d679bb1a31d75f8a81f1fd52b20b2eae50ef6e0378b8755f4f0426c68b54f11edc0c848e017e81bb2ad87"), + (BIP85_GPG_KEY_TYPE_CURVE25519, 256, "0e90b553528cd97a033c282f54cf72c1020adaec205d5c0e57e9f2556d06fea683618e4be8f91e7e059647f9d6373eb8b5f535e7ba4097cfb3e93c4957843614"), + (BIP85_GPG_KEY_TYPE_SECP256K1, 256, "f3bb8b3d6b81fbd202c34b59ce7e97c83969e9b5733b936de16c51119c7a48239ddf66729ef5e4df97ea39471f05a89f070869b3f9d72d69f3ae8bd7ee4fb6b3"), + (BIP85_GPG_KEY_TYPE_NIST, 256, "f52586f58521916b9f28b0058be86effcde82e571eabada9e3f63c6f67752ff12a4d3bf2fffe0f147164945691605a58f28f6bded869c38b3db9f0e577d83728"), + (BIP85_GPG_KEY_TYPE_NIST, 384, "830005ea400f7a03c27aa06a9728fe311c9a48dc31bd417f07b96c69edc73d25baa00d04b9dbbe6f42539b06d9ef1ba62ed73d4a3a992302aae09e17e0d9f42f"), + (BIP85_GPG_KEY_TYPE_NIST, 521, "3524b3cbe60eb78a156dae44674702f69381afe5292d6d15d7801b7e530f2a0616b7b876c0ba85d6e675587fdc0ce2242ad00252493ec9c3a024217d1e2aa954"), + (BIP85_GPG_KEY_TYPE_BRAINPOOL, 256, "97ee4490d89bf257e9a038e2af12824fba47fec721970ca1fc1c094650d2716d75491402530776ba31d215fac6c2de0cb6661f1d380b682e20246bf962cdf385"), + (BIP85_GPG_KEY_TYPE_BRAINPOOL, 384, "3fa833db4195fbd7a9c4e3f6fdb65ffb8951c5c65ca0cce441a4410e11aa96fcb094ed8c1fb5317448ae098ca9cae2c351b513e47d1b74e4c80c1facdf7b0a5a"), + (BIP85_GPG_KEY_TYPE_BRAINPOOL, 512, "985f0131503109fc7fb2ab15e6a86846888e4b9a9f4f11f0d7b30dba4570cf8cc728a4c8ce9bbeb9b9819fbe924bb2d6d71a9c8332635cfb5db5008364f3a43a"), +] + + +def test_bipsea_gpg_entropy_vectors(): + """All GPG entropy derivation values match bipsea test vectors.""" + root = bip32.HDKey.from_string(MASTER_XPRV) + for key_type, key_bits, expected in GPG_ENTROPY_VECTORS: + entropy = bip85.derive_entropy( + root, BIP85_GPG_APP, [key_type, key_bits, 0] + ) + assert entropy.hex() == expected, ( + f"GPG entropy mismatch for type={key_type} bits={key_bits}" + ) + + +# ── ECC private key vectors ───────────────────────────────────────────────── +# These test the scalar derivation from entropy — deterministic and +# library-agnostic (just byte truncation + bit masking + range check). + +ECC_PRIVATE_KEY_VECTORS = [ + # (deriver, expected_private_hex) + (bip85_ed25519_from_root, "0e90b553528cd97a033c282f54cf72c1020adaec205d5c0e57e9f2556d06fea6"), + (bip85_secp256k1_from_root, "f3bb8b3d6b81fbd202c34b59ce7e97c83969e9b5733b936de16c51119c7a4823"), + (bip85_p256_from_root, "f52586f58521916b9f28b0058be86effcde82e571eabada9e3f63c6f67752ff1"), + (bip85_p384_from_root, "830005ea400f7a03c27aa06a9728fe311c9a48dc31bd417f07b96c69edc73d25baa00d04b9dbbe6f42539b06d9ef1ba6"), + (bip85_p521_from_root, "a9b5a5af6b4c45ea509e838cb55a0043412b49781c54a68931395be4b27550b1c60b3aa7814c9ba4093c7c0b3f72b5e21856317b97eb156533b42e36ae8f2bf157"), + (bip85_brainpoolp256r1_from_root, "97ee4490d89bf257e9a038e2af12824fba47fec721970ca1fc1c094650d2716d"), + (bip85_brainpoolp384r1_from_root, "3fa833db4195fbd7a9c4e3f6fdb65ffb8951c5c65ca0cce441a4410e11aa96fcb094ed8c1fb5317448ae098ca9cae2c3"), + (bip85_brainpoolp512r1_from_root, "985f0131503109fc7fb2ab15e6a86846888e4b9a9f4f11f0d7b30dba4570cf8cc728a4c8ce9bbeb9b9819fbe924bb2d6d71a9c8332635cfb5db5008364f3a43a"), +] + + +@pytest.mark.parametrize( + "deriver,expected_hex", + ECC_PRIVATE_KEY_VECTORS, + ids=[f.__name__.replace("bip85_", "").replace("_from_root", "") + for f, _ in ECC_PRIVATE_KEY_VECTORS], +) +def test_bipsea_ecc_private_key(deriver, expected_hex): + """ECC private key scalars match bipsea test vectors.""" + root = bip32.HDKey.from_string(MASTER_XPRV) + km = deriver(root, 0) + actual = hex(int(km.s))[2:] + # Pad to expected length (leading zeros) + actual = actual.zfill(len(expected_hex)) + assert actual == expected_hex + + +# ── ECC public key cross-validation with OpenSSL ──────────────────────────── +# Confirms that the private scalar → public point derivation in pgpy +# matches OpenSSL (via the ``cryptography`` library). + +OPENSSL_ECDSA_VECTORS = [ + # (deriver, openssl_curve_class) + ("secp256k1", bip85_secp256k1_from_root, "SECP256K1"), + ("NIST P-256", bip85_p256_from_root, "SECP256R1"), + ("NIST P-384", bip85_p384_from_root, "SECP384R1"), + ("NIST P-521", bip85_p521_from_root, "SECP521R1"), + ("Brainpool P-256", bip85_brainpoolp256r1_from_root, "BrainpoolP256R1"), + ("Brainpool P-384", bip85_brainpoolp384r1_from_root, "BrainpoolP384R1"), + ("Brainpool P-512", bip85_brainpoolp512r1_from_root, "BrainpoolP512R1"), +] + + +@pytest.mark.parametrize( + "name,deriver,curve_name", + OPENSSL_ECDSA_VECTORS, + ids=[v[0] for v in OPENSSL_ECDSA_VECTORS], +) +def test_openssl_cross_validates_ecdsa_public_key(name, deriver, curve_name): + """ECDSA public key from pgpy matches PyCryptodome/embit/pure-Python derivation.""" + from seedsigner.helpers.ec_point import nist_pub_xy, secp256k1_pub_xy, brainpool_pub_xy + + root = bip32.HDKey.from_string(MASTER_XPRV) + km = deriver(root, 0) + d = int(km.s) + pgpy_x = int(km.p.x) + pgpy_y = int(km.p.y) + + _CURVE_MAP = { + "SECP256K1": ("secp256k1", None), + "SECP256R1": ("nist", "P-256"), + "SECP384R1": ("nist", "P-384"), + "SECP521R1": ("nist", "P-521"), + "BrainpoolP256R1": ("brainpool", 256), + "BrainpoolP384R1": ("brainpool", 384), + "BrainpoolP512R1": ("brainpool", 512), + } + kind, param = _CURVE_MAP[curve_name] + if kind == "secp256k1": + ref_x, ref_y = secp256k1_pub_xy(d) + elif kind == "nist": + ref_x, ref_y = nist_pub_xy(param, d) + else: + ref_x, ref_y = brainpool_pub_xy(param, d) + + assert pgpy_x == ref_x, f"{name}: x mismatch" + assert pgpy_y == ref_y, f"{name}: y mismatch" + + +def test_openssl_cross_validates_ed25519_public_key(): + """Ed25519 public key from pgpy matches PyCryptodome derivation from same seed.""" + from seedsigner.helpers.ec_point import ed25519_pub_from_seed + + root = bip32.HDKey.from_string(MASTER_XPRV) + km = bip85_ed25519_from_root(root, 0) + entropy = bip85.derive_entropy( + root, BIP85_GPG_APP, [BIP85_GPG_KEY_TYPE_CURVE25519, 256, 0] + ) + pgpy_pub = km.p.x # raw 32-byte Ed25519 public key + + ref_pub = ed25519_pub_from_seed(entropy[:32]) + assert pgpy_pub == ref_pub + + +@pytest.mark.parametrize("bits", [1024, 2048, 3072, 4096], ids=["RSA-1024", "RSA-2048", "RSA-3072", "RSA-4096"]) +def test_openssl_cross_validates_rsa_key(bits): + """PyCryptodome RSA key self-signs and cross-verifies correctly.""" + from Cryptodome.Signature import pkcs1_15 + from Cryptodome.Hash import SHA256 as PycSHA256 + + root = bip32.HDKey.from_string(MASTER_XPRV) + rsa_key = bip85_rsa_from_root(root, bits, 0) + + # Verify basic key properties + assert rsa_key.n.bit_length() >= bits - 1 + assert rsa_key.e == 65537 + + # PyCryptodome sign → PyCryptodome verify (round-trip self-test) + msg = b"BIP85 RSA cross-validation" + pyc_sig = pkcs1_15.new(rsa_key).sign(PycSHA256.new(msg)) + pkcs1_15.new(rsa_key).verify(PycSHA256.new(msg), pyc_sig) + + +# ── PGP fingerprint vectors (ECC) ─────────────────────────────────────────── +# For ECC key types where pgpy and bipsea produce identical V4 fingerprints. + +def _build_pgp_key(primary_km, pkalg, alg_name, deriver, root, index=0): + """Build a PGP key with primary + subkeys and return it.""" + from pgpy import PGPKey, PGPUID + from pgpy.pgp import PrivKeyV4, PrivSubKeyV4 + from pgpy.constants import ( + PubKeyAlgorithm, + KeyFlags, + HashAlgorithm, + SymmetricKeyAlgorithm, + CompressionAlgorithm, + ) + + created = datetime.datetime.fromtimestamp( + BIP85_GPG_CREATED_TS, tz=datetime.timezone.utc + ) + pk = PrivKeyV4() + pk.pkalg = pkalg + pk.keymaterial = primary_km + pk.created = created + pk.update_hlen() + + pgp_key = PGPKey() + pgp_key._key = pk + uid = PGPUID.new("BIP85") + pgp_key.add_uid( + uid, + usage={KeyFlags.Certify, KeyFlags.Sign}, + hashes=[HashAlgorithm.SHA256], + ciphers=[SymmetricKeyAlgorithm.AES256], + compression=[CompressionAlgorithm.ZLIB], + created=created, + ) + + for sub_index, sub_pkalg, usage, *name in _bip85_subkey_specs(alg_name): + alg_n = name[0] if name else None + subpkt = PrivSubKeyV4() + subpkt.pkalg = sub_pkalg + subpkt.keymaterial = deriver(root, index, sub_index, alg_n) + subpkt.created = created + subpkt.update_hlen() + subkey = PGPKey() + subkey._key = subpkt + pgp_key.add_subkey( + subkey, + usage=usage, + hashes=[HashAlgorithm.SHA256], + ciphers=[SymmetricKeyAlgorithm.AES256], + compression=[CompressionAlgorithm.ZLIB], + created=created, + ) + return pgp_key + + +GPG_ECC_FINGERPRINT_VECTORS = [ + # (alg_name, deriver_func, pkalg_name, bipsea_fingerprint) + ("ed25519", bip85_ed25519_from_root, "EdDSA", "E81DF23714082AD2747E732B9A24C95BD8C2A55E"), + ("secp256k1", bip85_secp256k1_from_root, "ECDSA", "6D99D34874C6E88FF30C758A46F7E1AF05FC3414"), + ("nistp256", bip85_p256_from_root, "ECDSA", "2FE6D862FF2ABF1C1FAA2753B681BEF5B5D574C4"), + ("nistp384", bip85_p384_from_root, "ECDSA", "56687C3C907219B29FCE39CF95F016F9B150B8A1"), + ("nistp521", bip85_p521_from_root, "ECDSA", "EE2613AEC231FD42ECB6264EF0D67F7D75410C0B"), + ("brainpoolP256r1", bip85_brainpoolp256r1_from_root, "ECDSA", "61617C06F6F2AC323D67782F11CB4B79FEFD4369"), + ("brainpoolP384r1", bip85_brainpoolp384r1_from_root, "ECDSA", "32786624D0CA7D7F01330940397F2F1FA2BE47CB"), + ("brainpoolP512r1", bip85_brainpoolp512r1_from_root, "ECDSA", "99D7BDC937AC6E9BCC17D0936643E0501D03C680"), +] + + +@pytest.mark.parametrize( + "alg_name,deriver,pkalg_name,expected_fp", + GPG_ECC_FINGERPRINT_VECTORS, + ids=[v[0] for v in GPG_ECC_FINGERPRINT_VECTORS], +) +def test_bipsea_ecc_gpg_fingerprint(alg_name, deriver, pkalg_name, expected_fp): + """ECC GPG key fingerprints match bipsea test vectors.""" + from pgpy.constants import PubKeyAlgorithm + + pkalg = getattr(PubKeyAlgorithm, pkalg_name) + root = bip32.HDKey.from_string(MASTER_XPRV) + primary_km = deriver(root, 0) + + def sub_deriver(root, idx, sub_index, alg_n=None): + return deriver(root, idx, sub_index, alg_n) + + pgp_key = _build_pgp_key(primary_km, pkalg, alg_name, sub_deriver, root) + actual = str(pgp_key.fingerprint).replace(" ", "") + assert actual == expected_fp + + +# ── RSA vectors ────────────────────────────────────────────────────────────── +# PyCryptodome (FIPS 186-4) is the canonical reference for RSA. + +def _build_rsa_pgp_key(root, bits, index=0): + """Build an RSA PGP key with primary + subkeys.""" + from pgpy.constants import PubKeyAlgorithm + from pgpy.packet import fields + from pgpy.packet.types import MPI + + def _rsa_to_km(rsa_key): + km = fields.RSAPriv() + km.n = MPI(rsa_key.n) + km.e = MPI(rsa_key.e) + km.d = MPI(rsa_key.d) + km.p = MPI(rsa_key.p) + km.q = MPI(rsa_key.q) + km.u = MPI(pow(rsa_key.p, -1, rsa_key.q)) + return km + + rsa_key = bip85_rsa_from_root(root, bits, index) + primary_km = _rsa_to_km(rsa_key) + + def rsa_deriver(root, idx, sub_index, _alg=None): + return _rsa_to_km(bip85_rsa_from_root(root, bits, idx, sub_index)) + + return _build_pgp_key( + primary_km, + PubKeyAlgorithm.RSAEncryptOrSign, + f"rsa{bits}", + rsa_deriver, + root, + index, + ) + + +@pytest.mark.parametrize( + "bits", [2048, 4096], + ids=["RSA-2048", "RSA-4096"], +) +def test_bipsea_rsa_gpg_fingerprint(bits): + """RSA GPG key fingerprints match updated bipsea test vectors. + + As of bipsea commit d8f8d9075a, bipsea uses PyCryptodome for RSA + generation, so all RSA fingerprints now match between implementations. + + RSA-1024 is tested separately since seedsigner enforces MIN_RSA_KEY_BITS=2048. + """ + root = bip32.HDKey.from_string(MASTER_XPRV) + pgp_key = _build_rsa_pgp_key(root, bits) + actual = str(pgp_key.fingerprint).replace(" ", "") + assert actual == BIPSEA_RSA_FINGERPRINTS[bits] + + +def test_bipsea_rsa1024_fingerprint_direct(): + """RSA-1024 bipsea fingerprint validated via PyCryptodome directly. + + SeedSigner enforces MIN_RSA_KEY_BITS=2048, so we can't use + bip85_rsa_from_root for 1024. Instead we generate the key + directly with PyCryptodome from the BIP85-derived entropy. + """ + from Cryptodome.PublicKey import RSA + from pgpy import PGPKey, PGPUID + from pgpy.pgp import PrivKeyV4 + from pgpy.constants import ( + PubKeyAlgorithm, + KeyFlags, + HashAlgorithm, + SymmetricKeyAlgorithm, + CompressionAlgorithm, + ) + from pgpy.packet import fields + from pgpy.packet.types import MPI + + root = bip32.HDKey.from_string(MASTER_XPRV) + entropy = bip85.derive_entropy( + root, BIP85_GPG_APP, [BIP85_GPG_KEY_TYPE_RSA, 1024, 0] + ) + drng = BIP85DRNG.new(entropy) + rsa_key = RSA.generate(1024, randfunc=drng.read) + + km = fields.RSAPriv() + km.n = MPI(rsa_key.n) + km.e = MPI(rsa_key.e) + km.d = MPI(rsa_key.d) + km.p = MPI(rsa_key.p) + km.q = MPI(rsa_key.q) + km.u = MPI(pow(rsa_key.p, -1, rsa_key.q)) # CRT coefficient u = p^(-1) mod q + + created = datetime.datetime.fromtimestamp( + BIP85_GPG_CREATED_TS, tz=datetime.timezone.utc + ) + pk = PrivKeyV4() + pk.pkalg = PubKeyAlgorithm.RSAEncryptOrSign + pk.keymaterial = km + pk.created = created + pk.update_hlen() + + # Direct _key assignment required: pgpy has no public API for + # constructing a PGPKey from raw key material fields. + pgp_key = PGPKey() + pgp_key._key = pk + uid = PGPUID.new("BIP85") + pgp_key.add_uid( + uid, + usage={KeyFlags.Certify, KeyFlags.Sign}, + hashes=[HashAlgorithm.SHA256], + ciphers=[SymmetricKeyAlgorithm.AES256], + compression=[CompressionAlgorithm.ZLIB], + created=created, + ) + + actual = str(pgp_key.fingerprint).replace(" ", "") + assert actual == BIPSEA_RSA_FINGERPRINTS[1024] + + +# ── RSA fingerprint vectors (all sizes now match bipsea) ───────────────────── +# PyCryptodome (FIPS 186-4) fingerprints. As of bipsea commit d8f8d9075a, +# bipsea uses PyCryptodome for RSA generation so all vectors match. + +BIPSEA_RSA_FINGERPRINTS = { + 1024: "874A39644ED0255DEEC18E0E1E6388649672CF70", + 2048: "99879DF6D21E34C8A086A4BD8B448E5BC298294A", + 4096: "24C25A48383E117546871767D9A05CA64F2F6A85", +} + +# Internal reference including 3072 (not in bipsea vectors but validated) +PYCRYPTODOME_RSA_FINGERPRINTS = { + 1024: "874A39644ED0255DEEC18E0E1E6388649672CF70", + 2048: "99879DF6D21E34C8A086A4BD8B448E5BC298294A", + 3072: "5871B1143CE5724B381499ABA371306954371056", + 4096: "24C25A48383E117546871767D9A05CA64F2F6A85", +} + + +@pytest.mark.parametrize("bits", [2048, 3072, 4096], ids=["RSA-2048", "RSA-3072", "RSA-4096"]) +def test_pycryptodome_rsa_fingerprint(bits): + """RSA fingerprints match PyCryptodome FIPS 186-4 reference values.""" + root = bip32.HDKey.from_string(MASTER_XPRV) + pgp_key = _build_rsa_pgp_key(root, bits) + actual = str(pgp_key.fingerprint).replace(" ", "") + assert actual == PYCRYPTODOME_RSA_FINGERPRINTS[bits] + + +def test_rsa_implementations_now_agree(): + """RSA 2048/4096: bipsea and PyCryptodome produce identical fingerprints. + + As of bipsea commit d8f8d9075a, the reference implementation uses + PyCryptodome for RSA generation (FIPS 186-4 random MR witnesses). + This resolves the previous divergence where bipsea's pure-Python + ``_is_prime()`` used fixed small-prime witnesses that consumed NO + DRNG bytes, producing different primes for RSA-4096. + + RSA-1024 is validated separately (seedsigner enforces MIN_RSA_KEY_BITS=2048). + """ + root = bip32.HDKey.from_string(MASTER_XPRV) + for bits in (2048, 4096): + expected_fp = BIPSEA_RSA_FINGERPRINTS[bits] + pgp_key = _build_rsa_pgp_key(root, bits) + actual = str(pgp_key.fingerprint).replace(" ", "") + assert actual == expected_fp, ( + f"RSA-{bits} fingerprint should match bipsea/PyCryptodome" + ) + assert actual == PYCRYPTODOME_RSA_FINGERPRINTS[bits], ( + f"RSA-{bits} bipsea vector should equal PyCryptodome reference" + ) + + +def test_rsa2048_primes_from_pycryptodome(): + """RSA-2048: PyCryptodome generates deterministic primes from DRNG. + + Verifies that RSA key generation from the same DRNG entropy always + produces the same key (deterministic), which is the foundation of + BIP85 GPG RSA key derivation. + """ + from Cryptodome.PublicKey import RSA + + root = bip32.HDKey.from_string(MASTER_XPRV) + entropy = bip85.derive_entropy( + root, BIP85_GPG_APP, [BIP85_GPG_KEY_TYPE_RSA, 2048, 0] + ) + + # Generate twice with same entropy — must produce identical keys + drng1 = BIP85DRNG.new(entropy) + key1 = RSA.generate(2048, randfunc=drng1.read) + + drng2 = BIP85DRNG.new(entropy) + key2 = RSA.generate(2048, randfunc=drng2.read) + + assert key1.n == key2.n, "RSA modulus should be deterministic" + assert key1.p == key2.p, "RSA prime p should be deterministic" + assert key1.q == key2.q, "RSA prime q should be deterministic" + + # Also verify the deterministic output produces the expected fingerprint + pgp_key = _build_rsa_pgp_key(root, 2048) + actual = str(pgp_key.fingerprint).replace(" ", "") + assert actual == PYCRYPTODOME_RSA_FINGERPRINTS[2048] + + +def test_p521_private_key_and_fingerprint_match_bipsea(): + """NIST P-521: private key and PGP fingerprint match bipsea. + + The scalar is derived by reading 66 bytes from the SHAKE256 DRNG, + masking to 521 bits (matching bipsea's reference implementation). + PyCryptodome confirms the public point derivation. + """ + from seedsigner.helpers.ec_point import nist_pub_xy + + root = bip32.HDKey.from_string(MASTER_XPRV) + km = bip85_p521_from_root(root, 0) + + expected_d = int( + "a9b5a5af6b4c45ea509e838cb55a0043412b49781c54a68931395be4b27550b1" + "c60b3aa7814c9ba4093c7c0b3f72b5e21856317b97eb156533b42e36ae8f2bf157", + 16, + ) + assert int(km.s) == expected_d + + # PyCryptodome confirms the public point + ref_x, ref_y = nist_pub_xy("P-521", expected_d) + assert int(km.p.x) == ref_x + assert int(km.p.y) == ref_y + + # PGP fingerprint now matches bipsea + from pgpy.constants import PubKeyAlgorithm + + def sub_deriver(root, idx, sub_index, alg_n=None): + return bip85_p521_from_root(root, idx, sub_index, alg_n) + + pgp_key = _build_pgp_key( + km, PubKeyAlgorithm.ECDSA, "nistp521", sub_deriver, root + ) + actual_fp = str(pgp_key.fingerprint).replace(" ", "") + bipsea_fp = "EE2613AEC231FD42ECB6264EF0D67F7D75410C0B" + assert actual_fp == bipsea_fp diff --git a/tests/test_bip85_gpg.py b/tests/test_bip85_gpg.py index debb71fe1..2e355d14c 100644 --- a/tests/test_bip85_gpg.py +++ b/tests/test_bip85_gpg.py @@ -11,8 +11,12 @@ from seedsigner.views.tools_views import ( MIN_RSA_KEY_BITS, bip85_brainpoolp256r1_from_root, + bip85_brainpoolp384r1_from_root, + bip85_brainpoolp512r1_from_root, bip85_ed25519_from_root, bip85_p256_from_root, + bip85_p384_from_root, + bip85_p521_from_root, bip85_rsa_from_root, bip85_secp256k1_from_root, bip85_add_subkeys, @@ -27,6 +31,7 @@ bip85_load_data, _select_import_algo, bip85_verify_existing, + _normalize_date_input, ) from seedsigner.models.settings_definition import SettingsConstants from seedsigner.helpers.bip85_drng import BIP85DRNG @@ -45,52 +50,52 @@ ) BIP85_GPG_RSA_VECTORS = [ ( - "b3415a819ba8175a7f11b949d75133725594ee3dcf6284dbec8fe6a625d0e0df757c148e576369f9405b19aec9356a848897de64202df8da4880a5f769aac297", + "2b9380df43421f46b5c38e13ea80612ff53488bc5d272e86d493ee1eecf738bb7b50e4978b7352f95772f1211483b0e6bba86c544a946b10d76ed493b8c2e01f", 1024, 0, ), ( - "ca1e93031427e4f086538f89b19f5f224719332c8a7b8c87db7eb81e4be935db24dcbc71873d0607ddd3876777cd158a2f061a5a5153413307df08fe5911a857", + "b34c06698228ce7a531dd292e76a4fbde1238659588e70714e1a7558a6df54e533034ba4d29ff42133db54af37520ba6724e3d3cd7e98329d6a0e4806e166bf0", 1024, 1, ), ( - "e3ff02b1f0b934357cc0952225bb0e90081005b0cc992c5ed22f6fb8e9c628a3a0f138f9324e33ed4ba7250e43dd66d725a4e4c683dcf5a3b4015b82bcf71934", + "98c4fb6d76f203e8828bdfd28416edca7a83a9b203901f7ad31f056cda8b3c25b19e5fd2aa642ca0abb9ed8bebf3d141af6c76b28a19eba624bdc6f8a76ce138", 2048, 0, ), ( - "b1b4d03eb9826aeb2fabc4529dc37da5eaaa9072d3e2b7e69da79862e2b9cd8131dbb5a9001612239cd96310f6be0417bd39c39500bf8a99ba5df32571866fe6", + "fad7641f55ce91a45a7ff36a7d8ebcac34b28778fd9f98abeba7afd24e0ff80ac9aaff7c8c728fbca5ebb893e0f7b9bce9b62f15c977044ad9cd05685044189e", 2048, 1, ), ( - "9bd8cb61fea01892ffd981b4da7aae22f32c9641e49c48104682e249a98f7911ed55035a52e085938291d64e34537e9cc0b730f42ae9183b5ddaac33a55764ea", + "ed98d9cd1919fbcf73f5e3d2496b9bc43f869ffe5e27e6d55dc740eb9039ec545f084037e189ced93d0e71f70a3bcc0fa76560b98f685d2725b8345e1be50e01", 3072, 0, ), ( - "fc49330db1352558f615651ae8d7840b083cce5c9e731e349847569d3813a3f7f605b5d66b178bf19fdd04bd7f48d2ddb07e16793703d17ee06c86e49e19a896", + "750caeee45c70b71a0297532a22e73f03a38bf226ffa95bc52969d98a586ee5913c7e9d9514d87455e6530c46d277ff67995a56e484bcefdcb662a1bb1d57d43", 3072, 1, ), ( - "12a499947a142ee3ede9c0960061383f2564b5cc569327d0dd22f7887094676f2e5d5785cd4eb683990d12209ebf6f39a5c1b5e217ea66710260e99fbe4b2be3", + "2d2ef3335dc51e7a0642bfe86fba0bb4e8401b703d8d679bb1a31d75f8a81f1fd52b20b2eae50ef6e0378b8755f4f0426c68b54f11edc0c848e017e81bb2ad87", 4096, 0, ), ( - "a6fdf91d4f4a0cadaf3d20d638744b574306725aababa0ab7136f8f8b88c5a4c5ca6104646d695cd95a72ad15e6e6912e263762eab951bfcea8e9939ed7c03f4", + "02b18073332b3486f1ef0bad015bdbe695595b8b3ed5ea5b4d9e54b54670e7a8011dd69b888f5042f9ae8d9f658708f786314458ba0c0078339789c50f78085b", 4096, 1, ), ( - "b3a0baa54a6fa75363e2bc0809dafd20eacea8b4d0fba9ef26f9ea9c471e135c53c1f787fd6a7a02bf736bed620d44e5b4465856fae6c2ef2d620b730098f8e9", + "c966fcf60d5ede0515ef2c51dbe300b1e67e448049fa1a63770a104f0d52ab173f9891a6345f9d0993452d7a0ba446ada5c69c8cd07a656829df9bb26f6ca29f", 8192, 0, ), ( - "1b5f1ae261e9e36039cd7d55d25e71934a4f0a2fdd2d93b2f73fbd272d04257d6eba8f6ff6bc1ffe1d58f68b707b794e54e983e2f573991bb776b48b8ed9a1ca", + "b8f8456044ebc18a964ef05dae81d88c6f602823d8c0415a912ed58095244562f459781c142aa723bb29092b08ce22efc648e631c6b9fdb46ffad845c893a27f", 8192, 1, ), @@ -117,17 +122,186 @@ def test_bip85_drng_vector(): def test_bip85_rsa_entropy_vectors_match_libwally(): root = bip32.HDKey.from_string(LIBWALLY_RSA_MASTER_XPRV) for expected, bits, index in BIP85_GPG_RSA_VECTORS: - entropy = bip85.derive_entropy(root, tools_views.BIP85_GPG_APP_RSA, [bits, index]) + entropy = bip85.derive_entropy(root, tools_views.BIP85_GPG_APP, [tools_views.BIP85_GPG_KEY_TYPE_RSA, bits, index]) assert entropy.hex() == expected +# ── Cross-implementation reference vectors ────────────────────────────────── +# These vectors use the common xprv from the BIP85 spec test vectors. +# Any BIP85-GPG implementation MUST derive identical entropy, DRNG output, +# and key material for the same master key and path. +# +# For RSA, PyCryptodome's RSA.generate(bits, randfunc=drng.read) is the +# reference algorithm as implied by the BIP85 spec's example code +# (``RSA.generate_key(4096, drng_reader.read)``). +# +# Master xprv: +# xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLH +# RdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb + +CROSS_IMPL_XPRV = LIBWALLY_RSA_MASTER_XPRV + +CROSS_IMPL_ECC_VECTORS = [ + # (key_type, key_bits, expected_entropy_hex, expected_private_hex) + ( + tools_views.BIP85_GPG_KEY_TYPE_CURVE25519, + 256, + "0e90b553528cd97a033c282f54cf72c1020adaec205d5c0e57e9f2556d06fea6" + "83618e4be8f91e7e059647f9d6373eb8b5f535e7ba4097cfb3e93c4957843614", + "0e90b553528cd97a033c282f54cf72c1020adaec205d5c0e57e9f2556d06fea6", + ), + ( + tools_views.BIP85_GPG_KEY_TYPE_SECP256K1, + 256, + "f3bb8b3d6b81fbd202c34b59ce7e97c83969e9b5733b936de16c51119c7a4823" + "9ddf66729ef5e4df97ea39471f05a89f070869b3f9d72d69f3ae8bd7ee4fb6b3", + "f3bb8b3d6b81fbd202c34b59ce7e97c83969e9b5733b936de16c51119c7a4823", + ), + ( + tools_views.BIP85_GPG_KEY_TYPE_NIST, + 256, + "f52586f58521916b9f28b0058be86effcde82e571eabada9e3f63c6f67752ff1" + "2a4d3bf2fffe0f147164945691605a58f28f6bded869c38b3db9f0e577d83728", + "f52586f58521916b9f28b0058be86effcde82e571eabada9e3f63c6f67752ff1", + ), + ( + tools_views.BIP85_GPG_KEY_TYPE_NIST, + 384, + "830005ea400f7a03c27aa06a9728fe311c9a48dc31bd417f07b96c69edc73d25" + "baa00d04b9dbbe6f42539b06d9ef1ba62ed73d4a3a992302aae09e17e0d9f42f", + "830005ea400f7a03c27aa06a9728fe311c9a48dc31bd417f07b96c69edc73d25" + "baa00d04b9dbbe6f42539b06d9ef1ba6", + ), + ( + tools_views.BIP85_GPG_KEY_TYPE_NIST, + 521, + "3524b3cbe60eb78a156dae44674702f69381afe5292d6d15d7801b7e530f2a06" + "16b7b876c0ba85d6e675587fdc0ce2242ad00252493ec9c3a024217d1e2aa954", + "a9b5a5af6b4c45ea509e838cb55a0043412b49781c54a68931395be4b27550b1" + "c60b3aa7814c9ba4093c7c0b3f72b5e21856317b97eb156533b42e36ae8f2bf157", + ), + ( + tools_views.BIP85_GPG_KEY_TYPE_BRAINPOOL, + 256, + "97ee4490d89bf257e9a038e2af12824fba47fec721970ca1fc1c094650d2716d" + "75491402530776ba31d215fac6c2de0cb6661f1d380b682e20246bf962cdf385", + "97ee4490d89bf257e9a038e2af12824fba47fec721970ca1fc1c094650d2716d", + ), + ( + tools_views.BIP85_GPG_KEY_TYPE_BRAINPOOL, + 384, + "3fa833db4195fbd7a9c4e3f6fdb65ffb8951c5c65ca0cce441a4410e11aa96fc" + "b094ed8c1fb5317448ae098ca9cae2c351b513e47d1b74e4c80c1facdf7b0a5a", + "3fa833db4195fbd7a9c4e3f6fdb65ffb8951c5c65ca0cce441a4410e11aa96fc" + "b094ed8c1fb5317448ae098ca9cae2c3", + ), + ( + tools_views.BIP85_GPG_KEY_TYPE_BRAINPOOL, + 512, + "985f0131503109fc7fb2ab15e6a86846888e4b9a9f4f11f0d7b30dba4570cf8c" + "c728a4c8ce9bbeb9b9819fbe924bb2d6d71a9c8332635cfb5db5008364f3a43a", + "985f0131503109fc7fb2ab15e6a86846888e4b9a9f4f11f0d7b30dba4570cf8c" + "c728a4c8ce9bbeb9b9819fbe924bb2d6d71a9c8332635cfb5db5008364f3a43a", + ), +] + +# Expected RSA-2048 n value for path m/83696968'/828365'/0'/2048'/0' +# generated via PyCryptodome RSA.generate(2048, randfunc=drng.read). +CROSS_IMPL_RSA2048_N = int( + "c76c90074ba3f2e487c5d7714bdade9e1c6e4beafb3c1d1246ed9d0a5607a15d" + "58608bc5aec96db6160920c8327487311bc9f799d9bb312e489e2296d3e93ceb" + "322bf5d8ede6f989713e1ecc3b03ad146186aaba5af50656f3c29babb7b792da" + "8e6e1ffc6521af425965faeb5c94bd18d1526d77d8b51f501d029fb59a26384b" + "0269ddb36c79ee8764d0e2d09222eb9f9bfd0a7ae6be35f71e63743cdca8983e" + "bd7e712b2b38c8ea84fcb950bd851b882642eb05a0578c346057d9eeb7fa3218" + "eefcdb9b19aa11e8a1364e81de2a54940496a302c1958a99f6bf3b7b8154d0b4" + "c800081a8311b5b4182ed3e057af0e1010232b2e04fdc1edf5cf37978ac75cb9", + 16, +) + + +def test_cross_impl_ecc_entropy_vectors(): + """Entropy values for ECC key types match reference vectors.""" + root = bip32.HDKey.from_string(CROSS_IMPL_XPRV) + for key_type, key_bits, expected_entropy, _ in CROSS_IMPL_ECC_VECTORS: + entropy = bip85.derive_entropy( + root, tools_views.BIP85_GPG_APP, [key_type, key_bits, 0] + ) + assert entropy.hex() == expected_entropy, ( + f"Entropy mismatch for key_type={key_type}, key_bits={key_bits}" + ) + + +def test_cross_impl_ecc_private_key_vectors(): + """Derived ECC private key scalars match reference vectors.""" + root = bip32.HDKey.from_string(CROSS_IMPL_XPRV) + derivers = { + (tools_views.BIP85_GPG_KEY_TYPE_CURVE25519, 256): lambda r: bip85_ed25519_from_root(r, 0), + (tools_views.BIP85_GPG_KEY_TYPE_SECP256K1, 256): lambda r: bip85_secp256k1_from_root(r, 0), + (tools_views.BIP85_GPG_KEY_TYPE_NIST, 256): lambda r: bip85_p256_from_root(r, 0), + (tools_views.BIP85_GPG_KEY_TYPE_NIST, 384): lambda r: bip85_p384_from_root(r, 0), + (tools_views.BIP85_GPG_KEY_TYPE_NIST, 521): lambda r: bip85_p521_from_root(r, 0), + (tools_views.BIP85_GPG_KEY_TYPE_BRAINPOOL, 256): lambda r: bip85_brainpoolp256r1_from_root(r, 0), + (tools_views.BIP85_GPG_KEY_TYPE_BRAINPOOL, 384): lambda r: bip85_brainpoolp384r1_from_root(r, 0), + (tools_views.BIP85_GPG_KEY_TYPE_BRAINPOOL, 512): lambda r: bip85_brainpoolp512r1_from_root(r, 0), + } + for key_type, key_bits, _, expected_private_hex in CROSS_IMPL_ECC_VECTORS: + key = derivers[(key_type, key_bits)](root) + actual = int(key.s) + expected = int(expected_private_hex, 16) + assert actual == expected, ( + f"Private key mismatch for key_type={key_type}, key_bits={key_bits}: " + f"got {hex(actual)}, expected {hex(expected)}" + ) + + +def test_cross_impl_rsa2048_key(): + """RSA-2048 key n-value matches reference vector. + + This pins PyCryptodome's RSA.generate() output for deterministic + cross-implementation verification. The BIP85 spec implies + PyCryptodome by its example: ``RSA.generate_key(4096, drng_reader.read)``. + """ + root = bip32.HDKey.from_string(CROSS_IMPL_XPRV) + key = bip85_rsa_from_root(root, 2048, 0) + assert key.n == CROSS_IMPL_RSA2048_N + + +def test_xprv_seed_produces_same_bip85_gpg_keys(): + """XprvSeed.get_root() must produce the same BIP85 GPG keys as the + equivalent mnemonic-derived Seed. This is the bug that caused the user + to see different RSA keys when loading an xprv directly into SeedSigner + versus providing a mnemonic to bipsea.""" + seed = Seed(mnemonic=MNEMONIC) + root_from_mnemonic = bip32.HDKey.from_seed(seed.seed_bytes) + xprv_str = root_from_mnemonic.to_base58() + xprv_seed = XprvSeed(xprv_str) + + root_from_xprv = xprv_seed.get_root() + + # The roots must derive identical BIP85 entropy + entropy_mn = bip85.derive_entropy(root_from_mnemonic, tools_views.BIP85_GPG_APP, [0, 2048, 0]) + entropy_xp = bip85.derive_entropy(root_from_xprv, tools_views.BIP85_GPG_APP, [0, 2048, 0]) + assert entropy_mn == entropy_xp, "XprvSeed BIP85 entropy must match mnemonic Seed" + + # RSA key must be identical + rsa_mn = bip85_rsa_from_root(root_from_mnemonic, 2048, 0) + rsa_xp = bip85_rsa_from_root(root_from_xprv, 2048, 0) + assert rsa_mn.n == rsa_xp.n, "XprvSeed RSA key n-value must match mnemonic Seed" + + # ECC key must be identical + ecc_mn = bip85_secp256k1_from_root(root_from_mnemonic, 0) + ecc_xp = bip85_secp256k1_from_root(root_from_xprv, 0) + assert int(ecc_mn.s) == int(ecc_xp.s), "XprvSeed secp256k1 key must match mnemonic Seed" + + def test_bip85_rsa_deterministic(): seed = Seed(mnemonic=MNEMONIC) root = bip32.HDKey.from_seed(seed.seed_bytes) key = bip85_rsa_from_root(root, 1024, 0) assert key.size_in_bits() == MIN_RSA_KEY_BITS assert key.n == int( - "c50bee42220c0162164154c147b661aff9ac6b56e9f1a470db1fdba5ba82338113c5734135bb49d7a4e248b2927324dcd2d5493d385543145177a79cb0a7cdea8c8b31f493d24a7bdeb0cdd0c7ef3685e34c7f5776d2f86d6b3b935bf2d5d3edcbb5c338314444eba19c2c128e935cfaa217fde3fbcab3d2dfdc9a7d9dbf9ca5d1f9cd58f862d8158d0de7fb5c8935ea52547d662bbe1e484752b2104e8d337a4794f9c2b2b0b6c4afcb4bf88c304644c0b134355f39228619091fe7fbe612f0005216b441edce575dbf639710c73eab6da71f980bb2a412b19fbceeca3b56756a62d29e12cbfbb1a6025f4059a9ea5ce6b537e2f06bb589a5e24b22a6f77b95", + "cf61fb06f3fea3d33e2671cf1f8c9878760864c63ae73e7c9b9f8a912740368a1fa4dc416a5d98e825ce08adcbc57eb3f6032079aea83da0bae61ac544a11c23ced82227cab51c2f72cf163c77ef77607fc2fb6e959a83baceb94d0a70fe8662d9d8180748e7240b2440ceb85240280ef1c83e19c19c9dc7bb8f5a60904a0a3cbf6fec290da4210900c07a52ef1c2380947bd373291aaafffd598fde0dd6b9ce6f24b70a4714092b16a377190e6b80f447412e7b0e9bece88a94c3a0f6854715947980de09d463343939733704679787f7622928c28bce92e11298cb54b87694284d50645e56cc51fb9411b026723adb05f68037d040e8448f7762507309a1c7", 16, ) @@ -145,7 +319,7 @@ def test_bip85_rsa_3072_deterministic(): key = bip85_rsa_from_root(root, 3072, 0) assert key.size_in_bits() == 3072 assert key.n == int( - "c3cfd8332fde9f8ec605520f687c11f250b0eedfd695aa3170f3eb242c15e0be769a1120f9c81c30615e3a5f3a0c50aa399df15f2d3a8554a0d698c5c86cacfbbce160c8bf6e7f581f9ad16885cbe5aeffeddc8ff66c16a16b6f429da765b98adbdd4554e0ec322206fc8c9b780f3527f2b93aa3075bde1fb735829e41f5f42be6ee7dc0d28f570c394e7610f44b85ba452a933e2405a3a72cdf8d33577a85fb5bb35b2cd0c2d7c6f3309c4ca47aab8eb094d31db982c91e9ea9c8f369827d73c4a53f943c15dfff791b33aa2d60173f13dc437cee05222b288726cea9d02eefff111a74714655ed6c048c27ff1a3264732d2952a233c42b640ec93bc214a39eef342b285c828ae00d2082fae2bef26e88a6fc0650939beeeb518feea3b79576a54afe640146eb0d9fb0bcd12d14d7dea6aed79527243a182f6bf83d9b6128582b87eddecfb99d8969c779314e8334e7580204ac25ae734035b45510268d6fb8964a4f74ae7ca5ff2cabf0553c374d760d600da4472d09a42a81844844346525", + "b338ff5ea63036e2dea2cc9037f8c3147d47c539ced2a20ca5d979186e8269ea9ab538c584cbfa5171d34f79e9e5e33aaf6ed24e436b55d2dca362e50c11003c32a7293c78c54c0d89bddb0b4a97257fe619ac320af247d2f45cc59d6a5587e764419021abba5a10cc79c1a2b410da9e4d656731bcd48c3b3a2ead9824504cc8c751a688c3b3b9d7977c8f5b9b70c4b60eac295446a6c145dc2ca723d9ee557b2200737b4b5116467f8344b00a22a2954875b629e63fc27b17429041ccf95b4ae077d2cdd3227d32135d94e05130f404975a8bf7b4bbcfbbc1f9bcaa6886374e4899988d0c7b1d471906c210159543e07b297081e1ea6eca074e79558904a179eaa1c96e26a32c09a39df2c08eff672c7d9759768f657698dfd63621937056dcff02ac99ac8ebcc5dc5bff93fc5de20f768eb6660a9e755b1eb059b135c6dec968cda8877c362c7b97ce899403b2a43e7e2c86ca5355f371a8cd01582df8900d76159ef5a298fde9845378efd11798c7c2ee1f9450d7b1edc120c0c3a379913d", 16, ) @@ -155,7 +329,7 @@ def test_bip85_secp256k1_deterministic(): root = bip32.HDKey.from_seed(seed.seed_bytes) key = bip85_secp256k1_from_root(root, 0) assert int(key.s) == int( - "f57c35cfe2bc7d15d847bb82951188d9b72c604cf052de9f9fd41dff545d5743", 16 + "dd4eebe20675d3649e8f188a14a8832fb473cfcea029cf755fb4f7b715ea9d23", 16 ) @@ -164,7 +338,7 @@ def test_bip85_p256_deterministic(): root = bip32.HDKey.from_seed(seed.seed_bytes) key = bip85_p256_from_root(root, 0) assert int(key.s) == int( - "236c5de595d7306949afe92f0f0086ef5fed541e4ca7e374eee414499a365f3f", 16 + "dc9b40d295b20fa87aa7414d5aa1db8b12bc850587fa0ed172f71ee620062114", 16 ) @@ -173,7 +347,7 @@ def test_bip85_brainpoolp256r1_deterministic(): root = bip32.HDKey.from_seed(seed.seed_bytes) key = bip85_brainpoolp256r1_from_root(root, 0) assert int(key.s) == int( - "6dedd2fd032d8153fbea2ec026e2df265897af0c070c544324a3c24a2964d755", 16 + "904a67c2b20820d8bf98be62a24a2cddcd9674ecd0943bb5e10d7b50fd02806c", 16 ) @@ -182,7 +356,7 @@ def test_bip85_ed25519_deterministic(): root = bip32.HDKey.from_seed(seed.seed_bytes) key = bip85_ed25519_from_root(root, 0) assert int(key.s) == int( - "738a622e2b889989a272da3350053d42978c94e56cec646da180e206010924d3", 16 + "9c2c35e872ee9112ae0235c811c12b5187d8e4ce77c5b8595e1da0f787dc4caa", 16 ) @@ -196,6 +370,45 @@ def test_bip85_ed25519_sub_index_progression(): assert int(later.s) == int(repeat.s) +def test_bip85_p384_deterministic(): + seed = Seed(mnemonic=MNEMONIC) + root = bip32.HDKey.from_seed(seed.seed_bytes) + key = bip85_p384_from_root(root, 0) + assert int(key.s) == int( + "61296e8ee7b08b639c56babb292a0bdaf352ceacd37b33c5a51a3da82d8d8434f7f42353fb8ec82e79599824889cb582", 16 + ) + + +def test_bip85_p521_deterministic(): + seed = Seed(mnemonic=MNEMONIC) + root = bip32.HDKey.from_seed(seed.seed_bytes) + key = bip85_p521_from_root(root, 0) + assert int(key.s) == int( + "6700224442c326298a3fd6b3df9c4c05068a4c7df2bc3b2f3fee647d46355d34" + "7905692be4b690c1ca19357f40dfa3f1f1c788a1ff55fa8c992873fabdf75f25c7", 16 + ) + + +def test_bip85_brainpoolp384r1_deterministic(): + seed = Seed(mnemonic=MNEMONIC) + root = bip32.HDKey.from_seed(seed.seed_bytes) + key = bip85_brainpoolp384r1_from_root(root, 0) + # This mnemonic's entropy exceeds the curve order, exercising the + # out-of-range fallback: (d % (order - 1)) + 1. + assert int(key.s) == int( + "5ff15e7affe063458500ebe3cb883388cc0c01a395d59b2b198bb34ea0b8c95f8399ff0197c45bd1d8e7a09babb60f14", 16 + ) + + +def test_bip85_brainpoolp512r1_deterministic(): + seed = Seed(mnemonic=MNEMONIC) + root = bip32.HDKey.from_seed(seed.seed_bytes) + key = bip85_brainpoolp512r1_from_root(root, 0) + assert int(key.s) == int( + "2b26f4734a1408d42ed2dcdb04415346da82db6c9bc62d972091f6136e7ded1a9317676f6924c6d05b506026eb04bcb444cfd3368c8a046765c517c50a862c4d", 16 + ) + + def test_bip85_gpg_mixed_subkeys_deterministic(): import datetime from pgpy import PGPKey, PGPUID @@ -278,15 +491,15 @@ def rsa_to_privpacket(rsa_key: RSA.RsaKey): created=created, ) - assert pgp_key.fingerprint == "662A9BF1670B7704A41C1FBC74054CC86CF4AB2C" + assert pgp_key.fingerprint == "BDC8DB33B793C02FC5E295B2CC44522B14B5A8B6" fingerprints = [str(sk.fingerprint).replace(" ", "") for sk in pgp_key.subkeys.values()] assert fingerprints == [ - "71DD02A0E5E9033287EF85EA0E7275FA96AF991C", - "36B51EB5B9D11A2EEA976CF47D8B9EEB49E3B336", - "34599F081AA651F1A03EE7EF2F985087816EEBDA", - "0938B62C0B8FE641FE528A8411A26272C153E6CF", - "9696B4AAFCA808BFFDE2A04AD2CA980F3652A5D4", - "07A435FD12E96F72C09B31966577C9E71A248706", + "55C54A4B6382B313B4539C3B781215E4F91451F9", + "55BDCFA487CCE02A07460F3ED2944F2EC019B5DF", + "AC3DE112686C83BB26A4587DF18933B6CEE6D463", + "49902AF8AE102DC986233CB6626F4106A6AB1355", + "94D620C2FC7DD25BAEC027FA106DAD2E7177CFB7", + "E3C9FEA1785D2A00CCAC373BB8AC66BF71D074F0", ] @@ -791,7 +1004,7 @@ def test_load_bip85_key_selects_seed(monkeypatch): original = list(controller.storage.seeds) controller.storage.seeds = [Seed(mnemonic=MNEMONIC), Seed(mnemonic=MNEMONIC)] - responses = iter([1]) + responses = iter([0, 1]) screens = [] captured = {} @@ -829,12 +1042,20 @@ def fake_get_seed(idx): controller.storage.seeds = original assert captured["idx"] == 1 - assert screens[0] == tools_views.seed_screens.SeedSelectSeedScreen - assert WarningScreen not in screens - assert captured["key_type_options"] == ["RSA 2048", "RSA 3072", "RSA 4096"] + assert screens[0] == WarningScreen + assert screens[1] == tools_views.seed_screens.SeedSelectSeedScreen + assert captured["key_type_options"] == [ + "ECC Ed25519", + "ECC NIST P-256", + "ECC Brainpool P-256", + "RSA 2048", + "RSA 3072", + "RSA 4096", + "ECC secp256k1", + ] -def test_load_bip85_key_warning_when_ecc_enabled(monkeypatch): +def test_load_bip85_key_warning_always_shown(monkeypatch): from seedsigner.views import tools_views controller = Controller.get_instance() @@ -867,24 +1088,11 @@ def display(self): ) view = tools_views.ToolsGPGLoadBIP85KeyView() - base_settings = view.settings - - class DummySettings: - def __init__(self, base): - self._base = base - - def get_value(self, attr_name): - if attr_name == SettingsConstants.SETTING__BIP85_ECC_KEYS: - return SettingsConstants.OPTION__ENABLED - return self._base.get_value(attr_name) - - view.settings = DummySettings(base_settings) try: view.run() finally: controller.storage.seeds = original - view.settings = base_settings assert screens and screens[0] == WarningScreen assert captured["key_type_options"] == [ @@ -898,6 +1106,52 @@ def get_value(self, attr_name): ] +def test_bip85_key_type_choices_include_all(): + """``_bip85_key_type_choices(include_all=True)`` returns every type.""" + from seedsigner.views.tools_views import _bip85_key_type_choices + + choices = _bip85_key_type_choices(include_all=True) + codes = [code for _, code in choices] + assert "p384" in codes + assert "p521" in codes + assert "brainpoolp384r1" in codes + assert "brainpoolp512r1" in codes + assert len(codes) == len(SettingsConstants.ALL_GPG_KEY_TYPES) + assert set(codes) == {code for code, _ in SettingsConstants.ALL_GPG_KEY_TYPES} + + +def test_bip85_key_type_choices_respects_setting(monkeypatch): + """``_bip85_key_type_choices()`` filters by ``SETTING__GPG_KEY_TYPES``.""" + from seedsigner.views.tools_views import _bip85_key_type_choices + from seedsigner.models.settings import Settings + + settings = Settings.get_instance() + original = settings.get_value(SettingsConstants.SETTING__GPG_KEY_TYPES) + settings.set_value(SettingsConstants.SETTING__GPG_KEY_TYPES, ["rsa2048"]) + try: + choices = _bip85_key_type_choices() + assert choices == [("RSA 2048", "rsa2048")] + finally: + settings.set_value(SettingsConstants.SETTING__GPG_KEY_TYPES, original) + + +def test_bip85_key_type_choices_default_matches_generate_new(): + """Default GPG key types match the original Generate New menu types.""" + from seedsigner.views.tools_views import _bip85_key_type_choices + + choices = _bip85_key_type_choices() + codes = [code for _, code in choices] + assert codes == [ + "ed25519", + "p256", + "brainpoolp256r1", + "rsa2048", + "rsa3072", + "rsa4096", + "secp256k1", + ] + + def test_filter_deletable_subkeys_bip85_only_latest(): BIP85_DATA.clear() fpr = "P" @@ -1177,6 +1431,346 @@ def fake_run_screen(*args, **kwargs): assert "Rebuild BIP85 Key" in buttons["labels"] +def test_gpg_menu_has_view_keys_option(monkeypatch): + buttons = {} + + def fake_run_screen(*args, **kwargs): + buttons["labels"] = [b.button_label for b in kwargs["button_data"]] + return RET_CODE__BACK_BUTTON + + monkeypatch.setattr( + tools_views.ToolsGPGMenuView, "run_screen", fake_run_screen + ) + view = tools_views.ToolsGPGMenuView() + view.run() + assert "View Keys" in buttons["labels"] + + +def test_view_keys_no_keys(monkeypatch): + import subprocess + + call_idx = [0] + + def fake_run(cmd, *a, **kw): + class R: + returncode = 0 + stdout = "" + stderr = "" + return R() + + monkeypatch.setattr(subprocess, "run", fake_run) + + screens = [] + + def fake_run_screen(*args, **kwargs): + screens.append(kwargs.get("title", args[1].__name__ if len(args) > 1 else "")) + return 0 + + monkeypatch.setattr( + tools_views.ToolsGPGViewKeysView, "run_screen", fake_run_screen + ) + view = tools_views.ToolsGPGViewKeysView() + view.run() + assert "View Keys" in screens + + +def test_view_keys_with_key(monkeypatch): + import subprocess + + colon_output = ( + "sec:-:255:22:81D909D9534ED202:1231006505:::-:::scESCA:::+::ed25519:::0:\n" + "fpr:::::::::DFA07C169B1513F3485769A581D909D9534ED202:\n" + "uid:-::::1231006505::ABC::Test User ::::::::::0:\n" + "ssb:-:255:18:C8088EF1E47500B1:1231006505::::::e:::+::cv25519::\n" + "fpr:::::::::0FAA3F5D0FCEC3E74A357659C8088EF1E47500B1:\n" + ) + + def fake_run(cmd, *a, **kw): + class R: + returncode = 0 + stdout = colon_output + stderr = "" + return R() + + monkeypatch.setattr(subprocess, "run", fake_run) + + screens = [] + + def fake_run_screen(*args, **kwargs): + title = kwargs.get("title", "") + screens.append(title) + if title == "View Keys": + return 0 # select first key + return 0 + + monkeypatch.setattr( + tools_views.ToolsGPGViewKeysView, "run_screen", fake_run_screen + ) + view = tools_views.ToolsGPGViewKeysView() + dest = view.run() + + assert "View Keys" in screens + # The view now delegates to ToolsGPGKeyDetailsView + assert dest.View_cls is tools_views.ToolsGPGKeyDetailsView + assert dest.view_args["fpr"] == "DFA07C169B1513F3485769A581D909D9534ED202" + + +def test_key_details_shows_subkeys_button(monkeypatch): + import subprocess + + colon_output = ( + "sec:-:255:22:81D909D9534ED202:1231006505:::-:::scESCA:::+::ed25519:::0:\n" + "fpr:::::::::DFA07C169B1513F3485769A581D909D9534ED202:\n" + "uid:-::::1231006505::ABC::Test User ::::::::::0:\n" + "ssb:-:255:18:C8088EF1E47500B1:1231006505::::::e:::+::cv25519::\n" + "fpr:::::::::0FAA3F5D0FCEC3E74A357659C8088EF1E47500B1:\n" + ) + + def fake_run(cmd, *a, **kw): + class R: + returncode = 0 + stdout = colon_output + stderr = "" + return R() + + monkeypatch.setattr(subprocess, "run", fake_run) + + screen_kwargs = [] + + def fake_run_screen(*args, **kwargs): + screen_kwargs.append(kwargs) + return RET_CODE__BACK_BUTTON + + monkeypatch.setattr( + tools_views.ToolsGPGKeyDetailsView, "run_screen", fake_run_screen + ) + view = tools_views.ToolsGPGKeyDetailsView( + fpr="DFA07C169B1513F3485769A581D909D9534ED202" + ) + view.run() + + detail_kw = screen_kwargs[-1] + # Full fingerprint in blocks of 4 hex chars + assert "DFA0 7C16 9B15 13F3 4857 69A5 81D9 09D9 534E D202" in detail_kw["text"] + assert "EdDSA" in detail_kw["text"] + # Subkey count should NOT be in the text (removed per requirements) + assert "Subkeys:" not in detail_kw["text"] + # No green tick icon + assert detail_kw.get("status_icon_size") == 0 + # Back button enabled + assert detail_kw.get("show_back_button") is True + # Subkeys button present + btn_labels = [b.button_label for b in detail_kw["button_data"]] + assert "Subkeys" in btn_labels + + +def test_key_details_no_subkeys_no_subkeys_button(monkeypatch): + import subprocess + + colon_output = ( + "sec:-:255:22:81D909D9534ED202:1231006505:::-:::scESCA:::+::ed25519:::0:\n" + "fpr:::::::::DFA07C169B1513F3485769A581D909D9534ED202:\n" + "uid:-::::1231006505::ABC::Test User ::::::::::0:\n" + ) + + def fake_run(cmd, *a, **kw): + class R: + returncode = 0 + stdout = colon_output + stderr = "" + return R() + + monkeypatch.setattr(subprocess, "run", fake_run) + + screen_kwargs = [] + + def fake_run_screen(*args, **kwargs): + screen_kwargs.append(kwargs) + return RET_CODE__BACK_BUTTON + + monkeypatch.setattr( + tools_views.ToolsGPGKeyDetailsView, "run_screen", fake_run_screen + ) + view = tools_views.ToolsGPGKeyDetailsView( + fpr="DFA07C169B1513F3485769A581D909D9534ED202" + ) + view.run() + + detail_kw = screen_kwargs[-1] + btn_labels = [b.button_label for b in detail_kw["button_data"]] + assert "Subkeys" not in btn_labels + assert "Back" in btn_labels + + +def test_view_keys_filters_subkey_fprs(monkeypatch): + """If GPG lists a subkey fingerprint as a separate sec entry, it should be + filtered out so only genuine primary keys appear in the View Keys list.""" + import subprocess + + # Simulate GPG output where the subkey fingerprint also appears as a + # separate sec entry (some GPG configurations may do this). + colon_output = ( + "sec:-:255:22:81D909D9534ED202:1231006505:::-:::scESCA:::+::ed25519:::0:\n" + "fpr:::::::::DFA07C169B1513F3485769A581D909D9534ED202:\n" + "uid:-::::1231006505::ABC::Test User ::::::::::0:\n" + "ssb:-:255:18:C8088EF1E47500B1:1231006505::::::e:::+::cv25519::\n" + "fpr:::::::::0FAA3F5D0FCEC3E74A357659C8088EF1E47500B1:\n" + "sec:-:255:18:C8088EF1E47500B1:1231006505:::-:::e:::+::cv25519:::0:\n" + "fpr:::::::::0FAA3F5D0FCEC3E74A357659C8088EF1E47500B1:\n" + ) + + def fake_run(cmd, *a, **kw): + class R: + returncode = 0 + stdout = colon_output + stderr = "" + return R() + + monkeypatch.setattr(subprocess, "run", fake_run) + + screen_kwargs = [] + + def fake_run_screen(*args, **kwargs): + screen_kwargs.append(kwargs) + return RET_CODE__BACK_BUTTON + + monkeypatch.setattr( + tools_views.ToolsGPGViewKeysView, "run_screen", fake_run_screen + ) + view = tools_views.ToolsGPGViewKeysView() + view.run() + + # Only 1 button should appear (the primary key), not 2 + btn_labels = [b.button_label for b in screen_kwargs[0]["button_data"]] + assert len(btn_labels) == 1 + assert "Test User" in btn_labels[0] + + +def test_key_subkeys_view(monkeypatch): + import subprocess + + colon_output = ( + "sec:-:255:22:81D909D9534ED202:1231006505:::-:::scESCA:::+::ed25519:::0:\n" + "fpr:::::::::DFA07C169B1513F3485769A581D909D9534ED202:\n" + "uid:-::::1231006505::ABC::Test User ::::::::::0:\n" + "ssb:-:255:18:C8088EF1E47500B1:1231006505::::::e:::+::cv25519::\n" + "fpr:::::::::0FAA3F5D0FCEC3E74A357659C8088EF1E47500B1:\n" + "ssb:-:256:19:AABB112233445566:1231006505::::::s::::nistp256:\n" + "fpr:::::::::AABB112233445566AABB112233445566AABB1122:\n" + ) + + def fake_run(cmd, *a, **kw): + class R: + returncode = 0 + stdout = colon_output + stderr = "" + return R() + + monkeypatch.setattr(subprocess, "run", fake_run) + + screen_kwargs = [] + + def fake_run_screen(*args, **kwargs): + screen_kwargs.append(kwargs) + return RET_CODE__BACK_BUTTON + + monkeypatch.setattr( + tools_views.ToolsGPGKeySubkeysView, "run_screen", fake_run_screen + ) + view = tools_views.ToolsGPGKeySubkeysView( + fpr="DFA07C169B1513F3485769A581D909D9534ED202" + ) + view.run() + + assert screen_kwargs[0]["title"] == "Subkeys" + btn_labels = [b.button_label for b in screen_kwargs[0]["button_data"]] + assert len(btn_labels) == 2 + # First subkey: cv25519 with encrypt capability + assert "[E]" in btn_labels[0] + # Second subkey: nistp256 with sign capability + assert "[S]" in btn_labels[1] + + +def test_key_subkeys_view_select_navigates_to_subkey_details(monkeypatch): + import subprocess + + colon_output = ( + "sec:-:255:22:81D909D9534ED202:1231006505:::-:::scESCA:::+::ed25519:::0:\n" + "fpr:::::::::DFA07C169B1513F3485769A581D909D9534ED202:\n" + "uid:-::::1231006505::ABC::Test User ::::::::::0:\n" + "ssb:-:255:18:C8088EF1E47500B1:1231006505::::::e:::+::cv25519::\n" + "fpr:::::::::0FAA3F5D0FCEC3E74A357659C8088EF1E47500B1:\n" + ) + + def fake_run(cmd, *a, **kw): + class R: + returncode = 0 + stdout = colon_output + stderr = "" + return R() + + monkeypatch.setattr(subprocess, "run", fake_run) + + def fake_run_screen(*args, **kwargs): + return 0 # select first subkey + + monkeypatch.setattr( + tools_views.ToolsGPGKeySubkeysView, "run_screen", fake_run_screen + ) + view = tools_views.ToolsGPGKeySubkeysView( + fpr="DFA07C169B1513F3485769A581D909D9534ED202" + ) + dest = view.run() + + assert dest.View_cls is tools_views.ToolsGPGSubkeyDetailsView + assert dest.view_args["primary_fpr"] == "DFA07C169B1513F3485769A581D909D9534ED202" + assert dest.view_args["subkey_fpr"] == "0FAA3F5D0FCEC3E74A357659C8088EF1E47500B1" + + +def test_subkey_details_view(monkeypatch): + import subprocess + + colon_output = ( + "sec:-:255:22:81D909D9534ED202:1231006505:::-:::scESCA:::+::ed25519:::0:\n" + "fpr:::::::::DFA07C169B1513F3485769A581D909D9534ED202:\n" + "uid:-::::1231006505::ABC::Test User ::::::::::0:\n" + "ssb:-:255:18:C8088EF1E47500B1:1231006505::::::e:::+::cv25519::\n" + "fpr:::::::::0FAA3F5D0FCEC3E74A357659C8088EF1E47500B1:\n" + ) + + def fake_run(cmd, *a, **kw): + class R: + returncode = 0 + stdout = colon_output + stderr = "" + return R() + + monkeypatch.setattr(subprocess, "run", fake_run) + + screen_kwargs = [] + + def fake_run_screen(*args, **kwargs): + screen_kwargs.append(kwargs) + return RET_CODE__BACK_BUTTON + + monkeypatch.setattr( + tools_views.ToolsGPGSubkeyDetailsView, "run_screen", fake_run_screen + ) + view = tools_views.ToolsGPGSubkeyDetailsView( + primary_fpr="DFA07C169B1513F3485769A581D909D9534ED202", + subkey_fpr="0FAA3F5D0FCEC3E74A357659C8088EF1E47500B1", + ) + view.run() + + detail_kw = screen_kwargs[-1] + assert detail_kw["title"] == "Subkey Details" + # Full fingerprint in blocks of 4 + assert "0FAA 3F5D 0FCE C3E7 4A35 7659 C808 8EF1 E475 00B1" in detail_kw["text"] + assert "ECDH" in detail_kw["text"] + assert "Encrypt" in detail_kw["text"] + assert detail_kw.get("show_back_button") is True + + def test_rebuild_bip85_key(monkeypatch): controller = Controller.get_instance() seed = Seed(mnemonic=MNEMONIC) @@ -2389,3 +2983,37 @@ def cmd_put_data(self, tag, value): assert lengths[0] == 3 assert lengths[1] == lengths[2] == lengths[3] == lengths[4] == lengths[5] assert lengths[6] == lengths[1] * 2 + + +# --- _normalize_date_input tests --- + +@pytest.mark.parametrize("input_str,expected", [ + ("2035-12-31", "2035-12-31"), # normal ASCII + (" 2035-12-31 ", "2035-12-31"), # leading/trailing whitespace + ("2035-12-31\n", "2035-12-31"), # trailing newline + ("\t2035-12-31\t", "2035-12-31"), # tabs + ("2035\uff0d12\uff0d31", "2035-12-31"), # fullwidth hyphen + ("2035\u201312\u201331", "2035-12-31"), # en-dash + ("2035\u201412\u201431", "2035-12-31"), # em-dash + ("2035\u221212\u221231", "2035-12-31"), # Unicode minus sign + ("", ""), # empty string + (" ", ""), # whitespace-only +]) +def test_normalize_date_input(input_str, expected): + assert _normalize_date_input(input_str) == expected + + +@pytest.mark.parametrize("input_str", [ + "2035-12-31", + " 2035-12-31 ", + "2035-12-31\n", + "2035\uff0d12\uff0d31", # fullwidth hyphen + "2035\u201312\u201331", # en-dash + "2035\u201412\u201431", # em-dash + "2035\u221212\u221231", # Unicode minus sign +]) +def test_normalize_date_input_parses_as_valid_date(input_str): + from datetime import datetime + normalized = _normalize_date_input(input_str) + dt = datetime.strptime(normalized, "%Y-%m-%d") + assert dt.year == 2035 and dt.month == 12 and dt.day == 31 diff --git a/tests/test_bip85_pgp_cli.py b/tests/test_bip85_pgp_cli.py index 2dfcb4d5f..ed6783295 100644 --- a/tests/test_bip85_pgp_cli.py +++ b/tests/test_bip85_pgp_cli.py @@ -1,4 +1,5 @@ import inspect +import shutil import sys from pathlib import Path @@ -33,13 +34,17 @@ def group_order(self): # pragma: no cover - simple property PGP_PRIMARY_KEY_VECTORS = ( - ("p256", "3374F7A064CB1852FB4125B67425C6BAE0AA45A3"), - ("brainpoolp256r1", "710ACBBE07535E996F1BCB52137B423AD982C3AB"), - ("rsa2048", "ECC1557BE1B91255FAC1BB370ABFE55998DF2870"), - ("rsa3072", "668E7C53F00725C85040FB9F79968E741014229F"), - ("rsa4096", "6784BC8345BAEF74ED426F55903131B578BB929C"), - ("secp256k1", "501587912640CB9C6CA3E4A0FAB3C5734A95416E"), - ("ed25519", "9837B7E85F711F478DBE22EB641301EA97DA37C5"), + ("p256", "0BF339995A11016A845BACFEABBC8853BA3DF0A1"), + ("brainpoolp256r1", "A62AE9A4B7ED113DF862C4D51D7E6E45BDAEF94C"), + ("rsa2048", "0834BA10384C8F2E9D8497AF9529A76933812D71"), + ("rsa3072", "54815E62E0BDEFF7803CD0071A46ACFB405DBC49"), + ("rsa4096", "F6CB403856E6FCE0EDD1BE956D20A28AAEB2D96C"), + ("secp256k1", "3673A1D4C16969043B18F8BB7F4EBA98175298F1"), + ("ed25519", "14E5E1C61CDF70FBE296DEBD83743E4131F7F3F4"), + ("p384", "16F9F12C3AF4D4239156B914BE144D9E334240D8"), + ("p521", "DD344D1E23FADA0AF7377745F1CDB8D4298A8ED6"), + ("brainpoolp384r1", "4944AABFFF341DB44485B33C7F3F5BBD0B7BCA78"), + ("brainpoolp512r1", "486CC1FE4EDB82714E347153D8D628E15995698F"), ) @@ -75,7 +80,11 @@ def test_generate_pgp_key_with_subkeys(): def test_cli_key_type_choices_include_all_curves(): expected = { "p256", + "p384", + "p521", "brainpoolp256r1", + "brainpoolp384r1", + "brainpoolp512r1", "rsa2048", "rsa3072", "rsa4096", @@ -83,3 +92,108 @@ def test_cli_key_type_choices_include_all_curves(): "ed25519", } assert expected.issubset(set(bip85_pgp.CLI_KEY_TYPE_CODES)) + + +# Key types that can be tested quickly (ECC only — RSA takes minutes). +ECC_KEY_TYPES = [ + "ed25519", + "secp256k1", + "p256", + "p384", + "p521", + "brainpoolp256r1", + "brainpoolp384r1", + "brainpoolp512r1", +] + + +@pytest.mark.parametrize("primary_type", ECC_KEY_TYPES) +def test_private_key_export_all_types(primary_type): + """Every key type must produce valid ASCII-armored private key output.""" + key = bip85_pgp.create_bip85_pgp_key( + MNEMONIC, + key_index=0, + primary_type=primary_type, + name="Export Test", + email="export@test.com", + ) + priv = bip85_pgp.export_private_key(key) + assert priv.startswith("-----BEGIN PGP PRIVATE KEY BLOCK-----") + assert "-----END PGP PRIVATE KEY BLOCK-----" in priv + + +@pytest.mark.parametrize("primary_type", ECC_KEY_TYPES) +def test_private_key_export_with_subkeys(primary_type): + """Every key type with subkeys must produce valid private key output.""" + key = bip85_pgp.create_bip85_pgp_key( + MNEMONIC, + key_index=0, + primary_type=primary_type, + name="Export Test", + email="export@test.com", + subkey_type=primary_type, + ) + assert len(key.subkeys) >= 3 + priv = bip85_pgp.export_private_key(key) + assert priv.startswith("-----BEGIN PGP PRIVATE KEY BLOCK-----") + assert "-----END PGP PRIVATE KEY BLOCK-----" in priv + + +# Key types that can be tested with GPG import (256-bit keys work with +# SHA-256 self-signatures; larger curves require SHA-384/512 which pgpy +# doesn't configure automatically). +GPG_COMPATIBLE_KEY_TYPES = [ + "ed25519", + "secp256k1", + "p256", + "brainpoolp256r1", +] + + +@pytest.mark.skipif( + sys.platform in ("darwin", "win32") or shutil.which("gpg") is None, + reason="requires working GnuPG2", +) +@pytest.mark.parametrize("primary_type", GPG_COMPATIBLE_KEY_TYPES) +def test_gpg_roundtrip_import_export(primary_type, tmp_path): + """BIP85 keys must survive a GPG import→export round-trip.""" + import os + import subprocess + + gnupghome = str(tmp_path / "gnupg") + os.makedirs(gnupghome, mode=0o700) + env = {**os.environ, "GNUPGHOME": gnupghome} + + key = bip85_pgp.create_bip85_pgp_key( + MNEMONIC, + key_index=0, + primary_type=primary_type, + name="Roundtrip Test", + email="roundtrip@test.com", + subkey_type=primary_type if primary_type == "ed25519" else None, + ) + + armored = bip85_pgp.export_private_key(key) + fpr = str(key.fingerprint).replace(" ", "") + + # Import + r = subprocess.run( + ["gpg", "--batch", "--import"], + input=armored.encode(), + capture_output=True, + env=env, + ) + assert r.returncode == 0, f"Import failed: {r.stderr.decode()}" + assert "lower 3 bits" not in r.stderr.decode(), ( + f"Cv25519 clamping warning during import: {r.stderr.decode()}" + ) + + # Export private key + r2 = subprocess.run( + ["gpg", "--armor", "--export-secret-keys", fpr], + capture_output=True, + text=True, + env=env, + ) + assert r2.returncode == 0, f"Private export failed: {r2.stderr}" + assert "-----BEGIN PGP PRIVATE KEY BLOCK-----" in r2.stdout diff --git a/tests/test_ec_backends.py b/tests/test_ec_backends.py new file mode 100644 index 000000000..4133decf2 --- /dev/null +++ b/tests/test_ec_backends.py @@ -0,0 +1,303 @@ +"""Test all EC point derivation backends produce identical results. + +Validates that python-cryptography, pycryptodomex, embit, ecdsa, and +the pure-Python fallback all agree on EC public-key derivation from the +same private scalars. Each backend is tested independently via +:func:`~seedsigner.helpers.ec_point.set_backend`, and results are +cross-validated to ensure byte-exact agreement. +""" + +import pytest + +import base # noqa: F401 – ensure hardware mocks + +from seedsigner.helpers.ec_point import ( + CRYPTOGRAPHY, + PYCRYPTODOME, + EMBIT, + ECDSA_LIB, + PURE_PYTHON, + available_backends, + set_backend, + get_backend, + ed25519_pub_from_seed, + curve25519_pub_from_seed, + secp256k1_pub_xy, + nist_pub_xy, + brainpool_pub_xy, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_BACKENDS = available_backends() + + +def _skip_unless(backend: str) -> pytest.MarkDecorator: + """Skip a test if *backend* is not installed.""" + return pytest.mark.skipif( + backend not in _BACKENDS, + reason=f"{backend} not installed", + ) + + +@pytest.fixture(autouse=True) +def _reset_backend(): + """Ensure auto-detection is restored after every test.""" + yield + set_backend(None) + + +# --------------------------------------------------------------------------- +# Test vectors — deterministic inputs valid for all curves +# --------------------------------------------------------------------------- + +# 32-byte seed for Ed25519 / Curve25519 +_SEED_32 = bytes(range(32)) + +# Small scalars valid for all Weierstrass curves +_SCALAR_SMALL = 42 + +# Larger scalars within the range of each curve order +# Must be < BrainpoolP256r1 order (0xA9FB57DB...) for universal validity +_SCALAR_256 = 0x4242424242424242424242424242424242424242424242424242424242424242 +_SCALAR_384 = int.from_bytes(b"\x7f" + bytes(range(1, 48)), "big") +_SCALAR_521 = int.from_bytes(b"\x01" + bytes(range(1, 66)), "big") & ((1 << 521) - 1) +_SCALAR_512 = int.from_bytes(b"\x7f" + bytes(range(1, 64)), "big") + + +# =================================================================== +# Ed25519 +# =================================================================== + +_ED25519_BACKENDS = [ + pytest.param(CRYPTOGRAPHY, marks=_skip_unless(CRYPTOGRAPHY)), + pytest.param(PYCRYPTODOME, marks=_skip_unless(PYCRYPTODOME)), +] + + +@pytest.mark.parametrize("backend", _ED25519_BACKENDS) +def test_ed25519_each_backend(backend): + """Each backend derives a 32-byte Ed25519 public key without error.""" + set_backend(backend) + pub = ed25519_pub_from_seed(_SEED_32) + assert isinstance(pub, bytes) and len(pub) == 32 + + +def test_ed25519_backends_agree(): + """All installed backends produce the same Ed25519 public key.""" + results = {} + for name in [CRYPTOGRAPHY, PYCRYPTODOME]: + if name in _BACKENDS: + set_backend(name) + results[name] = ed25519_pub_from_seed(_SEED_32) + if len(results) > 1: + vals = list(results.values()) + names = list(results.keys()) + for i in range(1, len(vals)): + assert vals[0] == vals[i], ( + f"Ed25519 mismatch between {names[0]} and {names[i]}" + ) + + +# =================================================================== +# Curve25519 +# =================================================================== + +_CV25519_BACKENDS = [ + pytest.param(CRYPTOGRAPHY, marks=_skip_unless(CRYPTOGRAPHY)), + pytest.param(PYCRYPTODOME, marks=_skip_unless(PYCRYPTODOME)), +] + + +@pytest.mark.parametrize("backend", _CV25519_BACKENDS) +def test_curve25519_each_backend(backend): + """Each backend derives a 32-byte Curve25519 public key without error.""" + set_backend(backend) + pub = curve25519_pub_from_seed(_SEED_32) + assert isinstance(pub, bytes) and len(pub) == 32 + + +def test_curve25519_backends_agree(): + """All installed backends produce the same Curve25519 public key.""" + results = {} + for name in [CRYPTOGRAPHY, PYCRYPTODOME]: + if name in _BACKENDS: + set_backend(name) + results[name] = curve25519_pub_from_seed(_SEED_32) + if len(results) > 1: + vals = list(results.values()) + names = list(results.keys()) + for i in range(1, len(vals)): + assert vals[0] == vals[i], ( + f"Curve25519 mismatch between {names[0]} and {names[i]}" + ) + + +# =================================================================== +# secp256k1 +# =================================================================== + +_SECP256K1_BACKENDS = [ + pytest.param(CRYPTOGRAPHY, marks=_skip_unless(CRYPTOGRAPHY)), + pytest.param(EMBIT, marks=_skip_unless(EMBIT)), + pytest.param(ECDSA_LIB, marks=_skip_unless(ECDSA_LIB)), +] + + +@pytest.mark.parametrize("backend", _SECP256K1_BACKENDS) +@pytest.mark.parametrize("d", [_SCALAR_SMALL, _SCALAR_256], ids=["small", "large"]) +def test_secp256k1_each_backend(backend, d): + """Each backend derives a valid secp256k1 (x, y) pair.""" + set_backend(backend) + x, y = secp256k1_pub_xy(d) + assert isinstance(x, int) and isinstance(y, int) + assert x > 0 and y > 0 + + +def test_secp256k1_backends_agree(): + """All installed backends produce the same secp256k1 public key.""" + for d in [_SCALAR_SMALL, _SCALAR_256]: + results = {} + for name in [CRYPTOGRAPHY, EMBIT, ECDSA_LIB]: + if name in _BACKENDS: + set_backend(name) + results[name] = secp256k1_pub_xy(d) + if len(results) > 1: + vals = list(results.values()) + names = list(results.keys()) + for i in range(1, len(vals)): + assert vals[0] == vals[i], ( + f"secp256k1 mismatch between {names[0]} and {names[i]} for d={d}" + ) + + +# =================================================================== +# NIST curves (P-256, P-384, P-521) +# =================================================================== + +_NIST_BACKENDS = [ + pytest.param(CRYPTOGRAPHY, marks=_skip_unless(CRYPTOGRAPHY)), + pytest.param(PYCRYPTODOME, marks=_skip_unless(PYCRYPTODOME)), + pytest.param(ECDSA_LIB, marks=_skip_unless(ECDSA_LIB)), +] + +_NIST_CURVES = [ + ("P-256", _SCALAR_SMALL), + ("P-256", _SCALAR_256), + ("P-384", _SCALAR_SMALL), + ("P-384", _SCALAR_384), + ("P-521", _SCALAR_SMALL), + ("P-521", _SCALAR_521), +] +_NIST_IDS = [f"{c}-{'small' if d == _SCALAR_SMALL else 'large'}" for c, d in _NIST_CURVES] + + +@pytest.mark.parametrize("backend", _NIST_BACKENDS) +@pytest.mark.parametrize("curve,d", _NIST_CURVES, ids=_NIST_IDS) +def test_nist_each_backend(backend, curve, d): + """Each backend derives a valid NIST (x, y) pair.""" + set_backend(backend) + x, y = nist_pub_xy(curve, d) + assert isinstance(x, int) and isinstance(y, int) + assert x > 0 and y > 0 + + +@pytest.mark.parametrize("curve,d", _NIST_CURVES, ids=_NIST_IDS) +def test_nist_backends_agree(curve, d): + """All installed backends produce the same NIST public key.""" + results = {} + for name in [CRYPTOGRAPHY, PYCRYPTODOME, ECDSA_LIB]: + if name in _BACKENDS: + set_backend(name) + results[name] = nist_pub_xy(curve, d) + if len(results) > 1: + vals = list(results.values()) + names = list(results.keys()) + for i in range(1, len(vals)): + assert vals[0] == vals[i], ( + f"{curve} mismatch between {names[0]} and {names[i]} for d={d}" + ) + + +# =================================================================== +# Brainpool curves (P-256, P-384, P-512) +# =================================================================== + +_BRAINPOOL_BACKENDS = [ + pytest.param(CRYPTOGRAPHY, marks=_skip_unless(CRYPTOGRAPHY)), + pytest.param(ECDSA_LIB, marks=_skip_unless(ECDSA_LIB)), + pytest.param(PURE_PYTHON), # always available +] + +_BRAINPOOL_CURVES = [ + (256, _SCALAR_SMALL), + (256, _SCALAR_256), + (384, _SCALAR_SMALL), + (384, _SCALAR_384), + (512, _SCALAR_SMALL), + (512, _SCALAR_512), +] +_BRAINPOOL_IDS = [f"BP{b}-{'small' if d == _SCALAR_SMALL else 'large'}" for b, d in _BRAINPOOL_CURVES] + + +@pytest.mark.parametrize("backend", _BRAINPOOL_BACKENDS) +@pytest.mark.parametrize("bits,d", _BRAINPOOL_CURVES, ids=_BRAINPOOL_IDS) +def test_brainpool_each_backend(backend, bits, d): + """Each backend derives a valid Brainpool (x, y) pair.""" + set_backend(backend) + x, y = brainpool_pub_xy(bits, d) + assert isinstance(x, int) and isinstance(y, int) + assert x > 0 and y > 0 + + +@pytest.mark.parametrize("bits,d", _BRAINPOOL_CURVES, ids=_BRAINPOOL_IDS) +def test_brainpool_backends_agree(bits, d): + """All installed backends produce the same Brainpool public key.""" + results = {} + for name in [CRYPTOGRAPHY, ECDSA_LIB, PURE_PYTHON]: + if name in _BACKENDS: + set_backend(name) + results[name] = brainpool_pub_xy(bits, d) + if len(results) > 1: + vals = list(results.values()) + names = list(results.keys()) + for i in range(1, len(vals)): + assert vals[0] == vals[i], ( + f"BrainpoolP{bits}R1 mismatch between {names[0]} and {names[i]} for d={d}" + ) + + +# =================================================================== +# Backend management API +# =================================================================== + +def test_available_backends_returns_frozenset(): + """available_backends() returns an immutable set.""" + result = available_backends() + assert isinstance(result, frozenset) + assert PURE_PYTHON in result # always available + + +def test_set_backend_none_is_auto(): + """set_backend(None) restores auto-detection.""" + set_backend(None) + assert get_backend() is None + + +def test_set_backend_invalid_raises(): + """set_backend() rejects unknown backend names.""" + with pytest.raises(ValueError, match="not available"): + set_backend("nonexistent_backend") + + +def test_auto_detect_prefers_cryptography(): + """With auto-detection, cryptography is used when available.""" + if CRYPTOGRAPHY not in _BACKENDS: + pytest.skip("cryptography not installed") + set_backend(None) + # Ed25519 should use cryptography (first in preference order) + pub = ed25519_pub_from_seed(_SEED_32) + assert len(pub) == 32 diff --git a/tests/test_embit_utils.py b/tests/test_embit_utils.py index 731df324d..7733011bb 100644 --- a/tests/test_embit_utils.py +++ b/tests/test_embit_utils.py @@ -493,6 +493,130 @@ def test_parse_derivation_path(): assert actual_result["index"] == int(derivation_path.split("/")[-1]) +def test_parse_derivation_path_rejects_unicode_digit_index(): + """Address index containing non-ASCII digits should not be parsed as int. + + Python's str.isdigit() returns True for Arabic-Indic digits (U+0660–U+0669) + but they are not valid in a BIP32 derivation path. + """ + # Use Arabic-Indic digit '٥' (U+0665) as the address index + result = embit_utils.parse_derivation_path("m/84'/0'/0'/0/\u0665") + assert result["index"] is None + + +def test_sign_message_uses_correct_derivation(): + """ + Verify that sign_message derives signing key directly from the root HDKey, + producing a signature that matches the address derived via the same root. + This catches the double-derivation bug where root.secret was fed back into + HDKey.from_seed(), giving a completely different key tree. + """ + from embit import bip39, bip32 + from embit.networks import NETWORKS + + mnemonic = "abandon " * 11 + "about" + seed_bytes = bip39.mnemonic_to_seed(mnemonic) + root = bip32.HDKey.from_seed(seed_bytes, version=NETWORKS["main"]["xprv"]) + + derivation_path = "m/84'/0'/0'/0/0" + message = "Hello, Bitcoin!" + + sig = embit_utils.sign_message( + root=root, + derivation=derivation_path, + msg=message.encode(), + ) + + # The signature should be a non-empty base64 string + assert isinstance(sig, str) + assert len(sig) > 0 + + # Verify the signature decodes to 65 bytes (1 byte flag + 64 byte compact sig) + from binascii import a2b_base64 + sig_bytes = a2b_base64(sig) + assert len(sig_bytes) == 65 + + # The flag byte should indicate a compressed key recovery ID + flag = sig_bytes[0] + assert 31 <= flag <= 34, f"Unexpected recovery flag: {flag}" + + +def test_sign_message_same_result_for_bip39_and_xprv(): + """ + An XprvSeed and a BIP39 Seed derived from the same mnemonic must produce + identical signatures, because both use the same root key. + + This test catches the prior double-derivation bug where + seed.get_root().secret was passed to HDKey.from_seed(), creating a + different root. + """ + from embit import bip39, bip32 + from embit.networks import NETWORKS + + mnemonic = "abandon " * 11 + "about" + seed_bytes = bip39.mnemonic_to_seed(mnemonic) + + # BIP39 root (what Seed.get_root() returns) + bip39_root = bip32.HDKey.from_seed(seed_bytes, version=NETWORKS["main"]["xprv"]) + + # XprvSeed root (same key, loaded from xprv string) + xprv_string = bip39_root.to_base58() + xprv_root = bip32.HDKey.from_string(xprv_string) + + derivation_path = "m/84'/0'/0'/0/0" + message = "Test message for signing" + + sig_bip39 = embit_utils.sign_message( + root=bip39_root, + derivation=derivation_path, + msg=message.encode(), + ) + sig_xprv = embit_utils.sign_message( + root=xprv_root, + derivation=derivation_path, + msg=message.encode(), + ) + + assert sig_bip39 == sig_xprv, ( + "BIP39 and XprvSeed roots must produce identical signatures" + ) + + +def test_sign_message_double_derivation_gives_different_result(): + """ + Demonstrate that the old buggy pattern (feeding root.secret back into + HDKey.from_seed) produces a DIFFERENT signature than using the root + directly. This confirms the fix is meaningful. + """ + from embit import bip39, bip32 + from embit.networks import NETWORKS + + mnemonic = "abandon " * 11 + "about" + seed_bytes = bip39.mnemonic_to_seed(mnemonic) + root = bip32.HDKey.from_seed(seed_bytes, version=NETWORKS["main"]["xprv"]) + + # The WRONG root that the old code produced (double-derivation) + wrong_root = bip32.HDKey.from_seed(root.secret, version=NETWORKS["main"]["xprv"]) + + derivation_path = "m/84'/0'/0'/0/0" + message = "Test double derivation" + + sig_correct = embit_utils.sign_message( + root=root, + derivation=derivation_path, + msg=message.encode(), + ) + sig_wrong = embit_utils.sign_message( + root=wrong_root, + derivation=derivation_path, + msg=message.encode(), + ) + + assert sig_correct != sig_wrong, ( + "Double-derived root must NOT produce the same signature" + ) + + def test_get_expanded_search_derivation_paths(): """Verify expanded search returns correct paths for all networks.""" # Mainnet diff --git a/tests/test_encryption_vectors.py b/tests/test_encryption_vectors.py index 866f18d52..65b9a887e 100644 --- a/tests/test_encryption_vectors.py +++ b/tests/test_encryption_vectors.py @@ -150,3 +150,45 @@ def test_decode_gcm_encrypted_qr(self): mnemonic = bip39.mnemonic_from_bytes(encrypted_qr.decrypt(TEST_KEY)) assert mnemonic == TEST_WORDS + + +def test_cipher_nfkd_normalizes_key(): + """NFC and NFD forms of the same password must derive the same key. + + macOS input methods typically produce NFD (e + combining-acute) while + Linux/Windows produce NFC (single é codepoint). The Cipher class + NFKD-normalizes string keys so the same password works cross-platform. + """ + import unicodedata + + # é as NFC (single codepoint U+00E9) + key_nfc = "caf\u00e9" + # é as NFD (e + combining acute U+0301) + key_nfd = "cafe\u0301" + + assert key_nfc != key_nfd # different Python str objects + assert unicodedata.normalize("NFKD", key_nfc) == unicodedata.normalize("NFKD", key_nfd) + + salt = "test salt" + iterations = 10 + + cipher_nfc = kef.Cipher(key_nfc, salt, iterations) + cipher_nfd = kef.Cipher(key_nfd, salt, iterations) + + plaintext = b"hello world" + encrypted = cipher_nfc.encrypt(plaintext, 0) # ECB mode + + # Both forms must decrypt successfully + assert cipher_nfd.decrypt(encrypted, 0) == plaintext + assert cipher_nfc.decrypt(encrypted, 0) == plaintext + + +def test_cipher_ascii_key_unchanged_by_normalization(): + """ASCII-only keys must produce identical results before and after + the NFKD normalization change (backward compatibility).""" + cipher = kef.Cipher(TEST_KEY, TEST_MNEMONIC_ID, ITERATIONS) + plaintext = TEST_WORDS.encode() + + # Verify the same vectors still pass (ASCII is a no-op for NFKD) + encrypted_ecb = cipher.encrypt(plaintext, 0) + assert encrypted_ecb == ECB_ENCRYPTED_WORDS diff --git a/tests/test_gpg_dep_check.py b/tests/test_gpg_dep_check.py new file mode 100644 index 000000000..e132195f7 --- /dev/null +++ b/tests/test_gpg_dep_check.py @@ -0,0 +1,105 @@ +"""Tests for ToolsGPGMenuView dependency checking (pgpy + gnupg2).""" +import sys +import types +import base # noqa: F401 -- ensure hardware mocks +import pytest + +from seedsigner.gui.screens import RET_CODE__BACK_BUTTON, ErrorScreen +from seedsigner.gui.screens.screen import ButtonListScreen, ButtonOption +from seedsigner.views import tools_views +from seedsigner.views.view import BackStackView + + +def _make_fake_run_screen(captured): + """Return a fake run_screen that records the screen type and kwargs.""" + def fake_run_screen(self, screen, *args, **kwargs): + captured["screen"] = screen + captured["kwargs"] = kwargs + return 0 # press the first (OK) button + return fake_run_screen + + +def test_missing_pgpy_shows_error(monkeypatch): + """When pgpy cannot be imported, an ErrorScreen mentioning 'pgpy' is shown.""" + captured = {} + monkeypatch.setattr( + tools_views.ToolsGPGMenuView, "run_screen", + _make_fake_run_screen(captured), + ) + # Hide pgpy so the import check fails (monkeypatch restores on teardown) + monkeypatch.setitem(sys.modules, "pgpy", None) + # Ensure gpg binary IS available so only pgpy is reported + monkeypatch.setattr("shutil.which", lambda cmd: "/usr/bin/gpg" if cmd == "gpg" else None) + + view = tools_views.ToolsGPGMenuView() + dest = view.run() + + assert captured["screen"] is ErrorScreen + assert "pgpy" in captured["kwargs"]["text"] + assert dest.View_cls is BackStackView + + +def test_missing_gpg_binary_shows_error(monkeypatch): + """When the gpg binary is not found, an ErrorScreen mentioning 'gnupg2' is shown.""" + captured = {} + monkeypatch.setattr( + tools_views.ToolsGPGMenuView, "run_screen", + _make_fake_run_screen(captured), + ) + # Ensure pgpy is importable (use a stub if not installed) + if "pgpy" not in sys.modules or sys.modules["pgpy"] is None: + monkeypatch.setitem(sys.modules, "pgpy", types.ModuleType("pgpy")) + # gpg binary not found + monkeypatch.setattr("shutil.which", lambda cmd: None) + + view = tools_views.ToolsGPGMenuView() + dest = view.run() + + assert captured["screen"] is ErrorScreen + assert "gnupg2" in captured["kwargs"]["text"] + assert dest.View_cls is BackStackView + + +def test_missing_both_shows_both(monkeypatch): + """When both pgpy and gpg are missing, both are listed.""" + captured = {} + monkeypatch.setattr( + tools_views.ToolsGPGMenuView, "run_screen", + _make_fake_run_screen(captured), + ) + monkeypatch.setitem(sys.modules, "pgpy", None) + monkeypatch.setattr("shutil.which", lambda cmd: None) + + view = tools_views.ToolsGPGMenuView() + dest = view.run() + + assert captured["screen"] is ErrorScreen + text = captured["kwargs"]["text"] + assert "pgpy" in text + assert "gnupg2" in text + assert dest.View_cls is BackStackView + + +def test_all_deps_present_shows_menu(monkeypatch): + """When both pgpy and gpg are available, the normal menu is shown.""" + captured = {} + + def fake_run_screen(self, screen, *args, **kwargs): + captured["screen"] = screen + return RET_CODE__BACK_BUTTON + + monkeypatch.setattr( + tools_views.ToolsGPGMenuView, "run_screen", fake_run_screen, + ) + # Ensure gpg binary found + monkeypatch.setattr("shutil.which", lambda cmd: "/usr/bin/gpg" if cmd == "gpg" else None) + # Ensure pgpy is importable (use a stub if not installed) + if "pgpy" not in sys.modules or sys.modules["pgpy"] is None: + monkeypatch.setitem(sys.modules, "pgpy", types.ModuleType("pgpy")) + + view = tools_views.ToolsGPGMenuView() + dest = view.run() + + # Should show the ButtonListScreen menu, not ErrorScreen + assert captured["screen"] is ButtonListScreen + assert dest.View_cls is BackStackView diff --git a/tests/test_gpg_message.py b/tests/test_gpg_message.py index 498e0beae..2d825e83c 100644 --- a/tests/test_gpg_message.py +++ b/tests/test_gpg_message.py @@ -1,4 +1,5 @@ """Tests for helpers.gpg_message""" +import sys import pytest from pgpy import PGPKey, PGPUID, PGPMessage from pgpy.constants import PubKeyAlgorithm, KeyFlags, EllipticCurveOID @@ -10,6 +11,21 @@ from seedsigner.models.settings import SettingsConstants +def _msys2_path(path: str) -> str: + """Convert a Windows path to MSYS2 format for GNUPGHOME. + + On Windows CI the GPG binary ships with Git-for-Windows and runs under + MSYS2. It expects POSIX-style paths (``/c/Users/...``) but Python's + ``tempfile`` returns native Windows paths (``C:\\Users\\...``). + """ + if sys.platform == "win32": + path = path.replace("\\", "/") + # Convert drive letter, e.g. "C:/Users/..." → "/c/Users/..." + if len(path) >= 2 and path[1] == ":": + path = "/" + path[0].lower() + path[2:] + return path + + def test_encrypt_decrypt_roundtrip(): key = PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 1024) uid = PGPUID.new("Test User", email="test@example.com") @@ -173,3 +189,314 @@ def test_encrypt_decrypt_binary_roundtrip(): assert decrypted == plaintext assert signer is None assert not verified + + +# --------------------------------------------------------------------------- +# BIP85-derived key message roundtrip tests +# --------------------------------------------------------------------------- + +MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + +@pytest.mark.parametrize( + "key_type", + ["ed25519", "p256", "brainpoolp256r1", "secp256k1", "rsa2048"], +) +def test_bip85_key_encrypt_decrypt_roundtrip(key_type): + """Encrypt/decrypt using a BIP85-derived key for each supported type.""" + from tools.bip85_pgp import create_bip85_pgp_key + + try: + key = create_bip85_pgp_key( + mnemonic=MNEMONIC, + key_index=0, + primary_type=key_type, + name="Test", + email="test@example.com", + subkey_type=key_type, + ) + except Exception as exc: + pytest.skip(f"{key_type} generation unsupported: {exc}") + + plaintext = f"BIP85 {key_type} encrypt test" + ciphertext = encrypt_message(str(key.pubkey), plaintext) + decrypted, signer, verified = decrypt_message(str(key), ciphertext) + + assert decrypted == plaintext + assert signer is None + assert not verified + + +@pytest.mark.parametrize( + "key_type", + ["ed25519", "p256", "brainpoolp256r1", "secp256k1", "rsa2048"], +) +def test_bip85_key_sign_roundtrip(key_type): + """Sign-only using a BIP85-derived key for each supported type.""" + from tools.bip85_pgp import create_bip85_pgp_key + + try: + key = create_bip85_pgp_key( + mnemonic=MNEMONIC, + key_index=0, + primary_type=key_type, + name="Test", + email="test@example.com", + subkey_type=key_type, + ) + except Exception as exc: + pytest.skip(f"{key_type} generation unsupported: {exc}") + + plaintext = f"BIP85 {key_type} sign test" + signed_msg = encrypt_message(None, plaintext, signkey_blob=str(key)) + decrypted, signer_fpr, verified = decrypt_message( + None, signed_msg, pubkey_blobs=[str(key.pubkey)] + ) + + assert decrypted == plaintext + assert signer_fpr is not None + assert verified + + +@pytest.mark.parametrize( + "key_type", + ["ed25519", "p256", "brainpoolp256r1", "secp256k1", "rsa2048"], +) +def test_bip85_key_sign_encrypt_decrypt_roundtrip(key_type): + """Full sign+encrypt+decrypt using a BIP85-derived key.""" + from tools.bip85_pgp import create_bip85_pgp_key + + try: + key = create_bip85_pgp_key( + mnemonic=MNEMONIC, + key_index=0, + primary_type=key_type, + name="Test", + email="test@example.com", + subkey_type=key_type, + ) + except Exception as exc: + pytest.skip(f"{key_type} generation unsupported: {exc}") + + plaintext = f"BIP85 {key_type} sign+encrypt test" + ciphertext = encrypt_message( + str(key.pubkey), plaintext, signkey_blob=str(key) + ) + decrypted, signer_fpr, verified = decrypt_message( + str(key), ciphertext, pubkey_blobs=[str(key.pubkey)] + ) + + assert decrypted == plaintext + assert signer_fpr is not None + assert verified + + +@pytest.mark.parametrize( + "key_type", + ["ed25519", "p256", "brainpoolp256r1", "secp256k1", "rsa2048"], +) +def test_bip85_key_gpg_export_roundtrip(key_type): + """End-to-end: generate BIP85 key, import to GPG, export, sign+encrypt.""" + import subprocess + import tempfile + import os + import shutil + + from tools.bip85_pgp import create_bip85_pgp_key + + if not shutil.which("gpg"): + pytest.skip("gpg binary not available") + + try: + key = create_bip85_pgp_key( + mnemonic=MNEMONIC, + key_index=0, + primary_type=key_type, + name="Test", + email="test@example.com", + subkey_type=key_type, + ) + except Exception as exc: + pytest.skip(f"{key_type} generation unsupported: {exc}") + + with tempfile.TemporaryDirectory() as gnupghome: + env = {**os.environ, "GNUPGHOME": _msys2_path(gnupghome)} + + # Import into GPG (same way the UI does) + result = subprocess.run( + ["gpg", "--batch", "--import"], + input=str(key).encode(), + capture_output=True, + env=env, + ) + assert result.returncode == 0, f"Import failed: {result.stderr.decode()}" + + # Export public key + pub_export = subprocess.run( + ["gpg", "--armor", "--export", str(key.fingerprint)], + capture_output=True, + text=True, + env=env, + ) + assert pub_export.returncode == 0 + pub_blob = pub_export.stdout + + # Export secret key + sec_export = subprocess.run( + ["gpg", "--armor", "--export-secret-keys", str(key.fingerprint)], + capture_output=True, + text=True, + env=env, + ) + assert sec_export.returncode == 0 + sec_blob = sec_export.stdout + + # Encrypt with GPG-exported public key + plaintext = f"GPG roundtrip {key_type} test" + ciphertext = encrypt_message(pub_blob, plaintext) + decrypted, _, _ = decrypt_message(sec_blob, ciphertext) + assert decrypted == plaintext + + # Sign with GPG-exported secret key + signed = encrypt_message(None, plaintext, signkey_blob=sec_blob) + decrypted, signer_fpr, verified = decrypt_message( + None, signed, pubkey_blobs=[pub_blob] + ) + assert decrypted == plaintext + assert signer_fpr is not None + assert verified + + # Sign + encrypt + ciphertext = encrypt_message( + pub_blob, plaintext, signkey_blob=sec_blob + ) + decrypted, signer_fpr, verified = decrypt_message( + sec_blob, ciphertext, pubkey_blobs=[pub_blob] + ) + assert decrypted == plaintext + assert verified + + +@pytest.mark.parametrize( + "key_type", + ["ed25519", "p256", "brainpoolp256r1", "rsa2048"], +) +def test_generate_new_gpg_export_roundtrip(key_type): + """End-to-end: PGPKey.new with subkeys → GPG import → export → sign+encrypt. + + This mirrors the exact workflow in ToolsGPGGenerateKeyView: generate a + primary key with subkeys, import into GPG, then use the GPG-exported keys + for the secure message sign/encrypt operations. + """ + import subprocess + import tempfile + import os + import shutil + + from pgpy.constants import ( + PubKeyAlgorithm, + KeyFlags, + HashAlgorithm, + SymmetricKeyAlgorithm, + CompressionAlgorithm, + EllipticCurveOID, + ) + from datetime import datetime, timezone, timedelta + + if not shutil.which("gpg"): + pytest.skip("gpg binary not available") + + # Generate key the same way ToolsGPGGenerateKeyView does + if key_type == "ed25519": + alg, param = PubKeyAlgorithm.EdDSA, EllipticCurveOID.Ed25519 + elif key_type == "p256": + alg, param = PubKeyAlgorithm.ECDSA, EllipticCurveOID.NIST_P256 + elif key_type == "brainpoolp256r1": + alg, param = PubKeyAlgorithm.ECDSA, EllipticCurveOID.Brainpool_P256 + elif key_type == "rsa2048": + alg, param = PubKeyAlgorithm.RSAEncryptOrSign, 2048 + else: + raise ValueError(key_type) + + try: + master_key = PGPKey.new(alg, param) + except Exception as exc: + pytest.skip(f"{key_type} unsupported: {exc}") + + uid = PGPUID.new("GenTest", email="gen@example.com") + expires = timedelta(days=365) + master_key.add_uid( + uid, + usage={KeyFlags.Certify, KeyFlags.Sign}, + hashes=[HashAlgorithm.SHA256], + ciphers=[SymmetricKeyAlgorithm.AES256], + compression=[CompressionAlgorithm.ZLIB], + expires=expires, + ) + + # Add subkeys matching ToolsGPGGenerateKeyView logic + if alg == PubKeyAlgorithm.RSAEncryptOrSign: + sub_enc = PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, param) + elif alg == PubKeyAlgorithm.EdDSA: + sub_enc = PGPKey.new(PubKeyAlgorithm.ECDH, EllipticCurveOID.Curve25519) + else: + sub_enc = PGPKey.new(PubKeyAlgorithm.ECDH, param) + + master_key.add_subkey( + sub_enc, + usage={KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage}, + hashes=[HashAlgorithm.SHA256], + ciphers=[SymmetricKeyAlgorithm.AES256], + compression=[CompressionAlgorithm.ZLIB], + expires=expires, + ) + + with tempfile.TemporaryDirectory() as gnupghome: + env = {**os.environ, "GNUPGHOME": _msys2_path(gnupghome)} + + result = subprocess.run( + ["gpg", "--batch", "--import"], + input=str(master_key).encode(), + capture_output=True, + env=env, + ) + assert result.returncode == 0, f"Import failed: {result.stderr.decode()}" + + pub_export = subprocess.run( + ["gpg", "--armor", "--export", str(master_key.fingerprint)], + capture_output=True, text=True, env=env, + ) + assert pub_export.returncode == 0 + pub_blob = pub_export.stdout + + sec_export = subprocess.run( + ["gpg", "--armor", "--export-secret-keys", str(master_key.fingerprint)], + capture_output=True, text=True, env=env, + ) + assert sec_export.returncode == 0 + sec_blob = sec_export.stdout + + # PGPy automatically selects ECDH/RSA subkey for encryption + plaintext = f"Generate-new {key_type} roundtrip" + ciphertext = encrypt_message(pub_blob, plaintext) + decrypted, _, _ = decrypt_message(sec_blob, ciphertext) + assert decrypted == plaintext + + # PGPy uses primary key (or signing subkey) for signing + signed = encrypt_message(None, plaintext, signkey_blob=sec_blob) + decrypted, signer_fpr, verified = decrypt_message( + None, signed, pubkey_blobs=[pub_blob] + ) + assert decrypted == plaintext + assert signer_fpr is not None + assert verified + + # Sign + encrypt combined + ciphertext = encrypt_message( + pub_blob, plaintext, signkey_blob=sec_blob + ) + decrypted, signer_fpr, verified = decrypt_message( + sec_blob, ciphertext, pubkey_blobs=[pub_blob] + ) + assert decrypted == plaintext + assert verified diff --git a/tests/test_passport_backup.py b/tests/test_passport_backup.py index c8ce8ae92..66ca18130 100644 --- a/tests/test_passport_backup.py +++ b/tests/test_passport_backup.py @@ -68,3 +68,20 @@ def test_invalid_password_raises(tmp_path): with pytest.raises(PassportBackupError): decode_passport_backup(archive, "0000-0000-0000-0000-0000") + + +def test_format_backup_code_rejects_unicode_digits(): + """_format_backup_code should only count ASCII digits, not Unicode digits. + + Arabic-Indic digits (U+0660–U+0669) pass str.isdigit() but are not valid + in a Passport backup code. + """ + from seedsigner.helpers.passport_backup import _format_backup_code + + # 20 ASCII digits should work + assert _format_backup_code("12345678901234567890") == "1234-5678-9012-3456-7890" + + # 20 Arabic-Indic digits (١٢٣٤٥٦٧٨٩٠) should be rejected + arabic_indic_20 = "\u0661\u0662\u0663\u0664\u0665\u0666\u0667\u0668\u0669\u0660" * 2 + with pytest.raises(PassportBackupError): + _format_backup_code(arabic_indic_20) diff --git a/tests/test_settings.py b/tests/test_settings.py index 6c7292502..53ce013d4 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -172,3 +172,24 @@ def test_set_value_ignores_missing_settings_entry(self): assert self.settings.get_value(SettingsConstants.SETTING__CAMERA_DEVICE) == current finally: settings_definition.USING_MOCK_GPIO = orig + + def test_settingsqr_numeric_parsing_rejects_superscript_digits(self): + """Settings QR parser should not crash on non-ASCII digit characters. + + Python's str.isdigit() returns True for Unicode superscript digits + (e.g. '¹²³') but int() raises ValueError on them. The parser must + handle this gracefully by keeping the value as a string rather than + crashing. + """ + # camera=¹⁸⁰ uses Unicode superscript digits — old .isdigit() path + # would pass the pre-check but crash on int('¹⁸⁰'). + # The parser should treat this as a non-numeric string value. + superscript_180 = "\u00b9\u2078\u2070" # ¹⁸⁰ + settingsqr_data = f"settings::v1 camera={superscript_180}" + # Superscript digits: isdigit() == True but int() fails + assert superscript_180.isdigit() is True # precondition + + # Should raise InvalidSettingsQRData because the value is not a valid + # option for "camera", but must NOT raise ValueError/crash. + with pytest.raises(InvalidSettingsQRData): + Settings.parse_settingsqr(settingsqr_data) diff --git a/tools/bip85_pgp.py b/tools/bip85_pgp.py index e108be99d..9320a40c1 100644 --- a/tools/bip85_pgp.py +++ b/tools/bip85_pgp.py @@ -42,14 +42,18 @@ bip85_ed25519_from_root, bip85_secp256k1_from_root, bip85_p256_from_root, + bip85_p384_from_root, + bip85_p521_from_root, bip85_brainpoolp256r1_from_root, + bip85_brainpoolp384r1_from_root, + bip85_brainpoolp512r1_from_root, _bip85_subkey_specs, _bip85_key_type_choices, ) # CLI test tool should expose every supported key type regardless of UI settings. -CLI_KEY_TYPE_CHOICES = tuple(_bip85_key_type_choices(include_ecc=True)) +CLI_KEY_TYPE_CHOICES = tuple(_bip85_key_type_choices(include_all=True)) CLI_KEY_TYPE_CODES = tuple(code for _, code in CLI_KEY_TYPE_CHOICES) @@ -89,8 +93,16 @@ def _add_subkey( subpkt.keymaterial = bip85_secp256k1_from_root(root, key_index, sub_index, alg_name) elif alg in ["p256", "nistp256"]: subpkt.keymaterial = bip85_p256_from_root(root, key_index, sub_index, alg_name) + elif alg in ["p384", "nistp384"]: + subpkt.keymaterial = bip85_p384_from_root(root, key_index, sub_index, alg_name) + elif alg in ["p521", "nistp521"]: + subpkt.keymaterial = bip85_p521_from_root(root, key_index, sub_index, alg_name) elif alg in ["brainpoolp256r1", "brainpoolP256r1"]: subpkt.keymaterial = bip85_brainpoolp256r1_from_root(root, key_index, sub_index, alg_name) + elif alg in ["brainpoolp384r1", "brainpoolP384r1"]: + subpkt.keymaterial = bip85_brainpoolp384r1_from_root(root, key_index, sub_index, alg_name) + elif alg in ["brainpoolp512r1", "brainpoolP512r1"]: + subpkt.keymaterial = bip85_brainpoolp512r1_from_root(root, key_index, sub_index, alg_name) elif alg == "ed25519": subpkt.keymaterial = bip85_ed25519_from_root(root, key_index, sub_index, alg_name) else: @@ -194,9 +206,21 @@ def create_bip85_pgp_key( elif primary_type == "p256": pk.pkalg = PubKeyAlgorithm.ECDSA pk.keymaterial = bip85_p256_from_root(root, key_index) + elif primary_type == "p384": + pk.pkalg = PubKeyAlgorithm.ECDSA + pk.keymaterial = bip85_p384_from_root(root, key_index) + elif primary_type == "p521": + pk.pkalg = PubKeyAlgorithm.ECDSA + pk.keymaterial = bip85_p521_from_root(root, key_index) elif primary_type == "brainpoolp256r1": pk.pkalg = PubKeyAlgorithm.ECDSA pk.keymaterial = bip85_brainpoolp256r1_from_root(root, key_index) + elif primary_type == "brainpoolp384r1": + pk.pkalg = PubKeyAlgorithm.ECDSA + pk.keymaterial = bip85_brainpoolp384r1_from_root(root, key_index) + elif primary_type == "brainpoolp512r1": + pk.pkalg = PubKeyAlgorithm.ECDSA + pk.keymaterial = bip85_brainpoolp512r1_from_root(root, key_index) elif primary_type == "ed25519": pk.pkalg = PubKeyAlgorithm.EdDSA pk.keymaterial = bip85_ed25519_from_root(root, key_index) @@ -235,7 +259,7 @@ def create_bip85_pgp_key( (1, PubKeyAlgorithm.EdDSA, {KeyFlags.Authentication}, "EdDSA"), (2, PubKeyAlgorithm.EdDSA, {KeyFlags.Sign}, "EdDSA"), ] - elif subkey_type in ["secp256k1", "p256", "brainpoolp256r1"]: + elif subkey_type in ["secp256k1", "p256", "p384", "p521", "brainpoolp256r1", "brainpoolp384r1", "brainpoolp512r1"]: base_specs = [ ( 0, @@ -275,7 +299,11 @@ def create_bip85_pgp_key( alg_for_specs = { "p256": "nistp256", + "p384": "nistp384", + "p521": "nistp521", "brainpoolp256r1": "brainpoolP256r1", + "brainpoolp384r1": "brainpoolP384r1", + "brainpoolp512r1": "brainpoolP512r1", "secp256k1": "secp256k1", "ed25519": "ed25519", "rsa2048": "rsa2048",