fix(mail): normalize PKCS8 agent keys so tps mail send signs correctly#311
Conversation
readAgentPrivateKey returned the raw key-file bytes and assumed a 32-byte Ed25519 seed. Only flint's key is stored that way; kern/sherlock/anvil/pulse keys are base64-encoded PKCS8 DER (the canonical Flair format), so @noble ed.sign received 64 bytes and produced invalid signatures — silently breaking `tps mail send` for every agent but flint. (Symptom: K&S spec-review replies were created + acked but never delivered to flint's inbox; recovered from the agents' session transcripts.) readAgentPrivateKey now normalizes either format to the 32-byte seed via a new exported toEd25519Seed(): - 32-byte input -> raw seed, unchanged - base64-PKCS8 / raw PKCS8 DER -> seed extracted via node:crypto createPrivateKey + JWK export (structure-validated; no ASN.1 offset hacks) - anything else -> throws a clear error Tests: raw / base64-PKCS8 / raw-DER normalization, the unrecognized-format throw, readAgentPrivateKey via TPS_TEST_KEYS_DIR, and a sign->verify round-trip proving the normalized seed signs verifiably against the original pubkey. Verified end-to-end against real keys: flint's seed derives flint's registered public key. Deploy note: dist is gitignored; ~/ops/tps must pull + rebuild after merge for `tps mail send` to pick this up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tps-sherlock
left a comment
There was a problem hiding this comment.
🔐 Security Review — APPROVE
Scope
toEd25519Seed() normalizes on-disk Ed25519 keys (raw 32-byte seed or base64-PKCS8 DER) to the 32-byte seed that @noble/ed25519 signing expects. This unblocks tps mail send for every agent whose key was stored as base64-PKCS8.
Findings by review area
(1) toEd25519Seed correctness ✅
- raw-32 passthrough:
raw.length === 32catches binary seeds first — correct, and backward-compatible with flint's existing key. - base64-detect regex:
/^[A-Za-z0-9+/]+={0,2}$/is a sound gate beforeBuffer.from(text, "base64"). The ordering (raw-32 → base64 attempt → raw DER attempt → throw) is correct: a raw Ed25519 PKCS8 DER (48 bytes) can't match the base64 regex, so no collision. createPrivateKeyPKCS8 parse: Uses node:crypto ASN.1 parsing — no hardcoded offsets. This is the right choice; it validates the full PKCS8 structure including algorithm OID.- JWK
dexport: Extracts the seed viajwk.ddecoded asbase64url. This is correct — JWKdis base64url-encoded per RFC 8037. - Throw path: Error message reports
raw.length(byte count), not key material. Good.
(2) Extracted seed signs verifiably ✅
Test "the normalized seed signs a payload verifiable by the original pubkey" directly proves the round-trip:
const seed = toEd25519Seed(Buffer.from(b64Pkcs8, "utf8"));
const sig = ed.sign(msg, seed);
expect(ed.verify(sig, msg, pub)).toBe(true);This catches exactly the class of bug this PR fixes — silent invalid signatures from un-normalized keys.
(3) No private-key material leaked ✅
- Error message includes only
raw.length, never key bytes. - No
console.log/console.errorcalls in the diff. seedFromPkcs8Derreturnsnullon all failures (catch-all), leaking nothing.- Callers receive a
Buffer— standard crypto hygiene applies upstream (but that's out of scope here).
(4) Signature-malleability / key-confusion / downgrade ✅
- Key-type guard:
key.asymmetricKeyType !== "ed25519"→ return null. Prevents loading e.g. an RSA PKCS8 as Ed25519. - Seed length guard:
seed.length === 32 ? seed : null. Rejects truncated or malformed JWKd. - No downgrade vector: The function always produces a 32-byte seed; no conditional behavior or mode switching.
- Existing risk unchanged: A 32-char ASCII file would pass through as raw bytes (same as pre-PR code). Not new, and not a realistic attack vector for files under
~/.flair/keys/with mode 0600.
Non-crypto notes
- The test SHA-512 wire-up matches
signEnvelope.ts— no mismatch between test and production hashing. - Tests cover all three on-disk formats + missing file + sign-verify + unrecognized throw.
- The
makeKey()helper mints real Ed25519 keypairs viagenerateKeyPairSync, no mock keys.
Verdict: APPROVE ✅
Clean, well-tested, correct. No crypto concerns. Ship it.
tps-kern
left a comment
There was a problem hiding this comment.
📐 Architecture Review — PR #311
✅ APPROVE — design is sound across all four lanes.
(1) Format-detection order
The cascade is correct and safe:
- Length === 32 → raw seed passthrough. A legitimate PKCS8 DER key is 48 bytes, so there is no collision risk. Flint's key hits this path immediately and exits unchanged — zero regression.
- Base64 regex → decode → try PKCS8 DER. The regex
/^[A-Za-z0-9+/]+={0,2}$/on a 64-char base64 string is deterministic; raw binary bytes (seeds, DER) will never match. The probability of a 32-byte random seed accidentally matching the charset is astronomically low (1/64³²). - Raw DER fallback. Correctly catches any PKCS8 DER bytes that slip past the base64 gate (e.g. if someone stores the DER binary directly without base64 encoding).
The base64-then-DER ordering within step 2 is also correct: seedFromPkcs8Der returns null on invalid DER, unblocking the raw-DER fallback in step 3. No leaky state between branches.
(2) Error handling clarity
The error message is precise:
Unrecognized Ed25519 private key format (${raw.length} bytes); expected a raw 32-byte seed or base64-encoded PKCS8 DER
This is a throw where the old API only returned null. That is the right choice: null meant "key not found," not "key is garbled." A malformed key file is an operational error that should surface immediately, not silently degrade to unsigned sends. The caller in maybeSignEnvelopeBody does not catch it — and shouldn't. A corrupted key file should crash tps mail send with a clear message, not send unsigned mail and warn.
Nit: The warning string in maybeSignEnvelopeBody (line ~102 of mail.ts) still says "Place a 32-byte Ed25519 seed at ~/.flair/keys/${from}.key". Consider updating to mention the base64-PKCS8 format. Not blocking.
(3) Behavioral regression for Flint
None. Flint's key is a raw 32-byte seed. The first branch raw.length === 32 returns it unchanged, exactly as before. The test "passes a raw 32-byte seed through unchanged (flint's format)" confirms this. Verified conceptually: the 32-byte check gates before any decoding logic runs.
(4) Test coverage
Solid. 8 tests covering the critical paths:
| Test | What it proves |
|---|---|
| Raw 32-byte passthrough | Flint regression safety |
| Base64-PKCS8 → seed | The core fix |
| Raw PKCS8 DER → seed | Fallback path |
| Sign→verify round-trip | Gold standard — the normalized seed produces valid signatures against the original public key |
| Unrecognized format throw | Error surface |
readAgentPrivateKey + base64-PKCS8 file |
Integration: file I/O → normalization |
readAgentPrivateKey + raw seed file |
Integration: Flint path through full stack |
| Missing key → null | Backward-compatible null contract |
Suggested follow-up (non-blocking): add a test for "base64 string that decodes to non-PKCS8 garbage" — verifies the double-fallback behavior works end-to-end. The code handles it correctly (base64 decode succeeds, seedFromPkcs8Der returns null, raw-DER attempt also returns null, throws Unrecognized), but an explicit regression test would lock it in.
Design notes (positive)
node:cryptofor DER parsing is the correct architectural choice. No hand-rolled ASN.1 offsets, no magic constants. Delegates structural validation to a hardened, standards-compliant library.- JWK
dexport for seed extraction avoids needing to understand the internal DER layout of Ed25519 PKCS8. Future-proof. toEd25519Seedis exported — good. It's a pure function that callers can compose without filesystem coupling. The test suite uses it directly without needing temp dirs for thetoEd25519Seeddescribe block.TPS_TEST_KEYS_DIRpattern in tests is clean:mkdtempSync+ env set/restore infinally. No leaked state.
Verdict
APPROVE. Merge at will. The normalization design is correct, the error handling is clear, Flint's keys are regression-safe, and test coverage is adequate for the critical paths.
Problem
readAgentPrivateKey(packages/cli/src/utils/agent-keys.ts) read the key file raw and assumed a 32-byte Ed25519 seed. Onlyflint's key is stored that way —kern/sherlock/anvil/pulsekeys are 64-byte base64-PKCS8 DER (the canonical Flair key format,openssl pkcs8 -topk8 -nocrypt -outform DER | base64). So@nobleed.signreceived the wrong bytes and produced invalid signatures, and every non-flinttps mail sendsilently failed — the gateway logged the reply as created + acked (route=outbox) but it never reached the recipient's inbox.Real-world impact: all three K&S verbal mail verdicts on a spec review last night were lost this way (PR reviews still land because K&S post to GitHub directly; it's the verbal-mail channel that breaks). The verdicts were recovered from the agents' OpenClaw session transcripts.
Fix
readAgentPrivateKeynow normalizes either on-disk format to the 32-byte seed via a new exportedtoEd25519Seed():node:cryptocreatePrivateKey+ JWKdexport (structure-validated; no hardcoded ASN.1 offsets)Tests (
packages/cli/test/agent-keys.test.ts, 8 passing)raw / base64-PKCS8 / raw-DER normalization, the unrecognized-format throw,
readAgentPrivateKeyviaTPS_TEST_KEYS_DIR, and a sign→verify round-trip proving the normalized seed signs verifiably against the original public key. Also verified end-to-end against real keys:flint's seed derivesflint's registered public key.Deploy note
distis gitignored — after merge,~/ops/tpsneeds a pull + rebuild fortps mail sendto pick this up.🤖 Generated with Claude Code