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

+36
View File
@@ -52,7 +52,9 @@ import {
addManagedAccountEntry,
addNostrKeyEntry,
addSshKeyEntry,
addPgpKeyEntry,
deriveSshKeyPair,
derivePgpKey,
sshPublicKeyOpenSsh,
} from "@seedpass/core";
import { readFile, writeFile } from "node:fs/promises";
@@ -407,6 +409,40 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
await finishAdd(vault, id);
});
commonAddOptions(
add
.command("pgp <label>")
.description("derived Ed25519 PGP key entry")
.option("--user-id <uid>", "PGP user id", ""),
).action(
async (
label: string,
o: { userId: string; notes?: string; tags?: string[]; archived?: boolean },
) => {
const vault = await openFromOptions(program.opts());
const id = addPgpKeyEntry(vault.index, label, {
...commonOpts(o),
userId: o.userId,
});
await finishAdd(vault, id);
},
);
entry
.command("pgp-public <refOrQuery>")
.description("print a PGP entry's public key block (not secret material)")
.action(async (refOrQuery: string) => {
const vault = await openFromOptions(program.opts());
const hit = resolveEntry(vault.index, refOrQuery);
if (hit.entry.kind !== "pgp") throw new Error(`${hit.ref} is not a pgp entry`);
const key = derivePgpKey(vault.mnemonic, hit.entry.index, {
userId: hit.entry.user_id,
keyType: hit.entry.key_type,
});
io.out(JSON.stringify({ fingerprint: key.fingerprint }));
io.out(key.publicKeyArmored.trimEnd());
});
entry
.command("ssh-public <refOrQuery>")
.description("print an SSH entry's public key (not secret material)")