Review date: 2026-06-12; status reconciled 2026-07-31. Scope: internal/crypto/{crypto.go,hybrid.go} plus a
strategic read of the ledger / proof-of-transit design. Companion to
CODE_GRAPH.md.
This is a focused first-pass review of the cryptographic core and the architectural premise. It is not a full audit — see Recommended next steps.
What's solid
The ECIES construction in crypto.go is correct, which is rarer than it should be:
- AAD is bound into the GCM tag, so ciphertext can't be transplanted onto a different message envelope.
- Fresh ephemeral X25519 key per message, with HKDF re-derivation using the ephemeral public key as context — every message gets a unique AES-256 key.
- Built on
crypto/ecdh, so all-zero / low-order-point shared secrets are rejected by the standard library rather than hand-rolled. - With a unique key per message, the random 96-bit GCM nonce is belt-and-suspenders rather than a nonce-reuse landmine.
hybrid.go uses the real crypto/mlkem (Go 1.24 stdlib FIPS-203) for
ML-KEM-768 + X25519 instead of a vendored Kyber — the right call.
Findings
Ordered by importance.
1. Hybrid KEM combiner is the "okay" version, not the gold standard (design)
EncapsulateHybrid/DecapsulateHybrid derive the session key as:
HKDF-SHA256( x25519_ss || mlkem_ss , info = "aftersmtp-hybrid-kem-v1" )
The KEM ciphertext / X25519 ephemeral public key is not fed into the combiner.
X25519 is famously non-binding (the shared secret does not commit to the
ephemeral), so the modern hybrid designs — X-Wing and
draft-ietf-tls-hybrid-design — additionally hash the KEM ciphertext and the
recipient public key into the KDF transcript to get IND-CCA robustness for the
combiner as a whole.
It is defensible as-is because ML-KEM's Fujisaki-Okamoto transform makes mlkem_ss
commit to its ciphertext. But for a protocol meant to outlive RSA, match X-Wing
exactly: include the full transcript in the combiner. Cheap now, expensive to
retrofit once clients exist.
Action: bind ciphertext || recipient_pubkey into the HKDF info/IKM, or adopt
X-Wing's SHA3-based combiner directly.
2. Hybrid wire-format comment still contradicts the code (correctness / interop)
The header comment in hybrid.go states:
[1184 bytes ML-KEM-768 ciphertext]…Total: 1216 bytes
The code is correct (hybridCTLen = 32 + 1088 = 1120); the comment is wrong.
ML-KEM-768 ciphertext is 1088 bytes — 1184 is the encapsulation key size,
which the comment conflates with the ciphertext.
This matters because it is a wire-format spec: anyone writing an interop client
(Rust/Node/Python per the /library goal) from the comment ships a broken framer.
Status: still open as of 2026-07-31. Fix the header comment to 1088 / 1120
and assert the sizes in a unit test so doc and code cannot drift again.
3. sharedSecretCache weakens the forward-secrecy story (security)
Decrypt caches raw ECDH shared secrets in a process-wide 10k-entry LRU keyed by
SHA-256(myPub || ephemeralPub). The stated reasoning is sound — distinct ephemeral
⇒ distinct key, so it only hits on literal retries/duplicates — but the security cost
is under-stated:
- The entire premise of ephemeral DH is that the secret is destroyed after use. Holding thousands of raw secrets in RAM past the message lifetime means a memory-disclosure bug (Heartbleed-class) harvests live decryption keys for many messages at once.
- For a product positioned around "ephemeral / time-expiring email," a long-lived in-memory pool of decryption secrets is an awkward asterisk.
Action: cache the parsed peer key (the validation/parse is the cheap win to keep) but not the derived secret; or, if the secret cache stays, give it an aggressive TTL and zero the bytes on eviction. The ECDH scalar-mult it saves is only realized on duplicate ciphertexts, which the queue/dedupe layer should be handling anyway.
4. PQ path is not yet on the message path (status, not a bug)
hybrid.go exists as a primitive but Encrypt/Decrypt still use classical
X25519-only ECIES. The "quantum-resistant" capability is built but not wired into
the data plane. Fine as roadmap — just don't let the README imply messages are
PQ-protected today. Track the cutover explicitly.
Architecture / strategic
The cryptography and the QUIC transport stand on their own merits. The part that warrants the hardest scrutiny is proof-of-transit: anchoring every message hash on-chain. Three questions it must answer before the ledger earns its place:
- Throughput & cost. Real email volume vs. Substrate block space and extrinsic cost. What is the per-message anchoring budget, and what is the batching strategy (Merkle-root-per-epoch rather than per-message)?
- Metadata privacy. A public, immutable log of
(hash, timestamp, sender DID, recipient DID)is a permanent traffic-analysis goldmine even though message bodies are sealed. This is arguably a regression vs. today's email for metadata-sensitive senders. What is the unlinkability story? - Key lifecycle on an append-only log. Revocation and rotation against an immutable ledger is the hard problem MLS spent years on. What happens when a DID's Ed25519/X25519 key is compromised?
DKIM + DANE + MTA-STS + ARC already deliver non-repudiation and anti-spoofing without a chain. The sharpest framing: what does the ledger buy that a DNSSEC-anchored Certificate-Transparency-style log of public keys would not, at a fraction of the cost and metadata exposure? If there's a crisp answer, this is a real protocol. If the answer is "decentralization," that premise deserves its own validation against what email operators actually want.
(The project now has an optional identity.PoWManager, but cmd/aftersmtp does not
attach it to the client API. Public registration is therefore still not protected by
the main running gateway.)
Recommended next steps
- Full line-level pass over
internal/protocol/legacy(the SMTP on/off-ramp — most attacker-exposed surface: parsing, STARTTLS enforcement, header injection, rewrite/alias logic). - Review
internal/ledgerextrinsic submission and the SQLite fallback for consistency/replay properties. - Fuzz
crypto.DecryptandDecapsulateHybridagainst malformed ciphertexts. - Confirm QUIC server has the bounded worker pool / anti-amplification controls noted
in
ARCHITECTURE_REVIEW.md§1 before any exposure.