All writing

Post-quantum cryptography

Building the same secure channel twice

For an applied cryptography project at NYU (CS6903), I built an end-to-end encrypted channel between two endpoints — the classic Alice ↔ Bob setup — with one twist: I implemented it twice, with two completely different families of post-quantum primitives, and then attacked and benchmarked both under the same test harness.

The premise sounds academic until you remember what post-quantum migration actually looks like in the real world. NIST has standardized ML-KEM (Kyber) and ML-DSA (Dilithium), and almost everyone is deploying exactly those. Which means almost everyone now shares a single point of cryptanalytic failure: structured lattices. If lattices fall, they all fall together. The interesting engineering question isn't "can I integrate Kyber?" — it's "how expensive is it to not bet everything on one mathematical assumption?"

The two backends

Both channels target NIST post-quantum security Category 3 and share the same symmetric layer. Only the asymmetric primitives differ:

  • Scheme A (lattice): ML-KEM-768 (Kyber) for key encapsulation, ML-DSA-65 (Dilithium) for signatures.
  • Scheme B (lattice-free): HQC-192 — a code-based KEM — plus SLH-DSA-SHA2-192s (SPHINCS+), a hash-based signature scheme.
  • Shared: AES-256-GCM for authenticated encryption, HKDF-SHA256 for key derivation, liboqs underneath.

Everything hides behind one pq_backend_t interface in C, selected at runtime with --backend A or --backend B. That interface is the actual deliverable, in my opinion. Getting it right forced every protocol decision — transcript hashing, key schedules, message framing — to be primitive-agnostic. That's what crypto-agility means in practice: not "we can swap libraries," but "nothing above the KEM knows or cares which KEM it is."

What the benchmarks actually said

Running both backends through the same Docker harness makes the trade-offs concrete rather than theoretical:

  • Kyber/Dilithium is fast and small. Handshakes complete in what feels like classical-crypto time. This is why it won: the performance cost of the lattice bet is nearly zero.
  • SPHINCS+ signatures are enormous — on the order of 16KB at Category 3 — and slow to generate. You feel every signature in the handshake transcript.
  • HQC sits in between: bigger ciphertexts than Kyber, but nothing like the SPHINCS+ penalty.

The takeaway isn't "Scheme B is bad." It's that the diversified channel costs you a constant factor on the handshake and almost nothing after it, because the symmetric layer dominates steady-state traffic. For a long-lived session, mathematical diversity is close to free. For handshake-heavy workloads, it's a real bill.

Attacking my own protocol

The project required attack demos, and this is where most of my actual learning happened. Three classes of attacks, run as automated tests (make test fails the build if any attack succeeds):

  1. Replay: capture a legitimate handshake message and replay it later. Defeated by nonces bound into the transcript hash — but my first draft bound them in the wrong order, and the test caught a real (if narrow) reflection issue.
  2. Downgrade: a MITM strips the stronger backend from negotiation. Defeated by signing the full negotiation transcript, not just the chosen suite. This is the exact bug class behind TLS's historical downgrade attacks, and it's shockingly easy to reintroduce.
  3. Tampering: bit-flips in AES-GCM ciphertexts. GCM's tag catches these — but only if you actually check it before touching the plaintext, and only if your nonce handling never repeats. Nonce discipline in C is a hygiene problem, not a math problem.
The primitives were never the weak point. Every bug I found lived in the protocol glue — ordering, framing, negotiation — the parts no NIST competition evaluates.

What I'd tell someone starting a PQ migration

  • Design the abstraction first. If your KEM interface leaks Kyber-specific assumptions (key sizes, failure modes), you haven't migrated — you've hard-coded a different algorithm.
  • Budget for signature size, not just speed. Hash-based signatures change your message framing, your MTU story, and your handshake latency.
  • Write the attacks as CI tests. A protocol property you don't continuously test is a property you don't have.
  • Hybrid/diversified deployments are cheaper than they look — the symmetric layer amortizes almost everything.

The full implementation, attack suite, and benchmark harness are on GitHub — it runs anywhere Docker does: lutherleo/Quantum-safe-Kyber-Crystals.