From c2d7306bb7bd6d65edce55428211e9ac79295171 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:46:24 -0400 Subject: [PATCH] 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 --- docs/typescript_cutover_plan.md | 11 +- docs/typescript_port_compatibility_matrix.md | 6 +- js/packages/cli/src/program.ts | 36 +++ js/packages/cli/src/secrets.ts | 15 +- js/packages/core/src/derive/pgp.ts | 241 ++++++++++++++++++ js/packages/core/src/index.ts | 6 + js/packages/core/test/pgp.test.ts | 38 +++ .../test-vectors/fixtures/manifest.json | 3 +- .../test-vectors/fixtures/pgp_keys.json | 55 ++++ js/packages/test-vectors/src/index.ts | 13 + scripts/cross_impl_check.py | 15 ++ scripts/generate_ts_port_fixtures.py | 31 +++ 12 files changed, 457 insertions(+), 13 deletions(-) create mode 100644 js/packages/core/src/derive/pgp.ts create mode 100644 js/packages/core/test/pgp.test.ts create mode 100644 js/packages/test-vectors/fixtures/pgp_keys.json diff --git a/docs/typescript_cutover_plan.md b/docs/typescript_cutover_plan.md index 0cefa03a..38ae25d3 100644 --- a/docs/typescript_cutover_plan.md +++ b/docs/typescript_cutover_plan.md @@ -17,7 +17,7 @@ P0 gate is green, Python remains the normative reference | Cross-implementation profile interop | green | | Sync protocol parity | green | | Agent security model | green (TS ahead of Python) | -| Feature parity for daily use | partial | +| Feature parity for daily use | partial (all derivations green) | | Packaging + release | not started | | Migration + rollback story | not started | @@ -47,14 +47,15 @@ P0 gate is green, Python remains the normative reference and the TS CLI restores it with secrets intact. **Green.** 5. **Feature parity for real daily use.** **Partial.** Remaining: - - PGP key material (entries round-trip; derivation not ported — highest - parity risk, PGPy serialization) - document import/export commands - `semantic` and `api` command groups (deliberately deferred; decide whether they block cutover or ship post-cutover) + - PGP RSA keys: unsupported by design (not byte-reproducible). Decide + whether existing RSA entries block cutover; ed25519 is at parity. - Done since this list was written: SSH key derivation (PEM byte-for-byte) - and schema migrations 0→4, so legacy Python profiles now open in TS. + Done since this list was written: SSH and PGP key derivation (both + byte-for-byte) and schema migrations 0→4, so legacy Python profiles now + open in TS. 6. **Packaging and distribution exist.** Not started. Needs: an installable artifact (npm bin, single-file build, or both), checksums and signatures diff --git a/docs/typescript_port_compatibility_matrix.md b/docs/typescript_port_compatibility_matrix.md index 1704df56..a9d70468 100644 --- a/docs/typescript_port_compatibility_matrix.md +++ b/docs/typescript_port_compatibility_matrix.md @@ -31,7 +31,7 @@ until a later milestone. | Password KDF (Argon2id) | P0 | green | `password_kdf.json` | `@noble/hashes` argon2id (pure JS, audited family); default (t=2, m=64MiB, p=8) and light params verified. | | KDF metadata parsing | P0 | green | `kdf_metadata.json`, `legacy_payloads.json` | KdfConfig schema with Python-default fallbacks. | | SSH key derivation | P1 | green | `ssh_keys.json` | Ed25519 from BIP-85 app 32; PKCS#8 and SubjectPublicKeyInfo PEM match Python `cryptography` byte-for-byte (fixed DER prefixes, no ASN.1 dependency). CLI: `entry add ssh`, reveal (private PEM), `entry ssh-public` (PEM or OpenSSH line — the OpenSSH format is a TS-only convenience Python does not emit). Cross-impl phases A/B include an ssh entry. | -| PGP key derivation | P1/P2 | todo | — | Highest parity risk (PGPy serialization); import/roundtrip first. | +| PGP key derivation | P1/P2 | green | `pgp_keys.json` | Ed25519/EdDSA from BIP-85 app 32, creation time pinned to 2000-01-01Z. OpenPGP packets are emitted directly (no OpenPGP dependency): new-format headers, secret-key/user-id/self-signature packets, ASCII armor with CRC-24. Armored private and public blocks match PGPy byte-for-byte across two seeds, three indices and three user-id shapes. **RSA is deliberately unsupported** — PyCryptodome's seeded prime search is not reproducible, so TS refuses rather than emitting a different key. CLI: `entry add pgp`, reveal, `entry pgp-public`. | | Nostr snapshot chunk/manifest model | P0 | green | `nostr_snapshot.json` | Kinds 30070/30071/30072; gzip chunking, chunk-hash verify, reassembly of Python-produced chunks, manifest JSON parse, manifest id HMAC (key_index chain). Compressed bytes are deliberately not byte-pinned (deflate encoders differ); cross-decompression verified both ways. | | Relay transport + sync flows | P0 | green | — (live mock relay) | RelayPool over platform WebSocket (multi-relay publish/fetch, dedupe, signature verification on ingest); publish/fetch snapshot with chunk-hash verify, delta publish/replay. End-to-end tested against an in-process NIP-01 relay incl. corrupted-chunk rejection, and at the CLI level: sync -> destroy local vault -> restore. jsdom skips transport tests (real-browser run: Milestone 6 CI). | | Deterministic conflict merge | P0 | green | `sync_merge.json` | Full `merge_index_payloads` port: ts precedence, canonical-hash tie-breaks (Python-compatible ensure_ascii canonical JSON), equal-ts field union incl. the `custom_fields: null` quirk, tombstone lifecycle, retention cap, `_sync_meta`. | @@ -51,7 +51,7 @@ until a later milestone. | Environment | Status | |---|---| -| Node 22 (vitest) | green — 173/173 core + 44 CLI | +| Node 22 (vitest) | green — 193/193 core + 44 CLI | | jsdom browser-like env | green — 148/148 core (3 transport tests Node-only) | | Real Chromium/Firefox (vitest browser mode) | todo — lands with Milestone 6 web app CI | @@ -83,7 +83,7 @@ the same values: Entry coverage in phases A/B: password (plain and policy-constrained), TOTP (deterministic and imported), key_value, document, seed, managed_account, -nostr, ssh. +nostr, ssh, pgp — every creatable kind. ## Known intentional divergences diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index ab1ebf18..eacfba79 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -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