parity: PGP key derivation, byte-for-byte with PGPy

The last unported derivation, and the one the plan flagged as highest risk.
It turned out to be fully reproducible: the key material is deterministic,
EdDSA signatures are deterministic (RFC 8032), and the packet layout is
fixed -- so the armored blocks match exactly, with no OpenPGP dependency.

core/derive/pgp.ts emits the packets directly: new-format headers, the
secret-key packet (unencrypted MPI plus 16-bit checksum), the user-id
packet, and the self-certification signature with PGPy's exact subpacket
set and order, wrapped in ASCII armor with a CRC-24 footer. Verified
against PGPy across two seeds, three indices and three user-id shapes --
armored private key, armored public key, and fingerprint.

RSA is deliberately unsupported: PyCryptodome's seeded prime search is not
reproducible, so the TS port refuses the key type rather than silently
deriving a different key.

Two things worth recording from the build: the v4 fingerprint reproduces
from first principles (SHA-1 over the public-key packet), which is what
made byte-parity look feasible before committing to it; and the only
mismatch in the first run was packet framing -- PGPy uses new-format
headers where I had emitted old-format.

CLI: `entry add pgp`, reveal (armored private key), `entry pgp-public`.
Cross-impl phases A/B now cover a pgp entry in both directions, completing
coverage of every creatable entry kind.

193 core + 47 CLI tests green; 30/30 cross-implementation checks green.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-14 16:46:24 -04:00
1 parent 5dcd779a6f
commit c2d7306bb7
12 files changed
+457 -13

No files matched your search

+15
View File
@@ -183,6 +183,7 @@ def py_create_profile(app_dir: Path, seed: str, password: str) -> str:
em.add_managed_account("python-managed", seed)
em.add_nostr_key("python-nostr", seed)
em.add_ssh_key("python-ssh", seed)
em.add_pgp_key("python-pgp", seed, user_id="[email protected]")
mgr = FingerprintManager(app_dir)
if fp not in mgr.fingerprints:
@@ -276,6 +277,16 @@ def py_secrets_for(index: dict, seed: str) -> dict[str, str]:
)
elif kind == "managed_account":
out[label] = derive_seed_phrase(bip85, int(entry.get("index", int(idx))), 12)
elif kind == "pgp":
from seedpass.core.password_generation import derive_pgp_key
priv, _pub, _fp = derive_pgp_key(
bip85,
int(entry.get("index", int(idx))),
entry.get("key_type", "ed25519"),
entry.get("user_id", ""),
)
out[label] = priv
elif kind == "ssh":
from seedpass.core.password_generation import derive_ssh_key_pair
@@ -378,6 +389,10 @@ def phase_b(tmp: Path) -> None:
run_cli(app_dir, "entry", "add", "managed-account", "ts-managed", env_extra=env)
run_cli(app_dir, "entry", "add", "ssh", "ts-ssh", env_extra=env)
run_cli(app_dir, "entry", "add", "nostr", "ts-nostr", env_extra=env)
run_cli(
app_dir, "entry", "add", "pgp", "ts-pgp", "--user-id", "[email protected]",
env_extra=env,
)
run_cli(app_dir, "entry", "add", "document", "ts-doc", "ts document body", env_extra=env)
run_cli(app_dir, "entry", "add", "seed", "ts-seed", "--words", "24", env_extra=env)
run_cli(
+31
View File
@@ -1133,6 +1133,36 @@ def gen_ssh_keys() -> dict:
}
def gen_pgp_keys() -> dict:
from seedpass.core.password_generation import derive_pgp_key
cases = []
for mid in (PRIMARY, "zoo24"):
bip85 = _bip85(MNEMONICS[mid])
for index, user_id in ((0, "[email protected]"), (3, ""), (7, "Test User <[email protected]>")):
priv, pub, fp = derive_pgp_key(bip85, index, "ed25519", user_id)
cases.append(
{
"mnemonic_id": mid,
"index": index,
"user_id": user_id,
"fingerprint": fp,
"private_key_armored": priv,
"public_key_armored": pub,
}
)
return {
"description": (
"Deterministic PGP (Ed25519/EdDSA) keys from BIP-85 app 32, "
"created 2000-01-01T00:00:00Z, as PGPy serializes them. EdDSA "
"signatures are deterministic, so the armored output is "
"byte-reproducible."
),
"created_at": 946684800,
"cases": cases,
}
def gen_kdf_metadata() -> dict:
return {
"description": (
@@ -1177,6 +1207,7 @@ def main() -> None:
"kdf_metadata.json": gen_kdf_metadata(),
"migrations.json": gen_migrations(),
"ssh_keys.json": gen_ssh_keys(),
"pgp_keys.json": gen_pgp_keys(),
"password_kdf.json": gen_password_kdf(),
"legacy_payloads.json": gen_legacy_payloads(),
}