Building A Kyber512 Kem Smoke Test With Deterministic Replays
Written by
Zed Qubit
Why I got curious about Post-Quantum crypto (and what broke)
I went looking at post-quantum cryptography because I wanted something concrete I could run end-to-end locally—no paper-only vibes. My first instinct was to try a Key Encapsulation Mechanism (KEM), because it mirrors a common real-world pattern: “server sends encrypted material, client decapsulates it, and both end up with the same shared secret.”
The niche problem I ran into: reproducible KEM behavior for debugging. Real implementations use randomness, so bugs are hard to chase. I wanted a workflow where I could replay the same encapsulation deterministically and verify the shared secret matches bit-for-bit.
That led me to build a tiny “smoke test” around Kyber512 with a deterministic random source so I could replay the exact same run.
The specific target: Kyber512 KEM deterministic replay
Here’s the specific thing I built:
- Use Kyber512 (a post-quantum KEM standardized by NIST candidates; Kyber512 refers to a particular parameter set).
- Implement a deterministic “randomness provider” using HMAC-DRBG-like behavior (conceptually: turn a seed into a stream of bytes).
- Use that deterministic stream to generate:
- the public randomness needed during encapsulation
- the “random coins” that Kyber uses internally
- Verify that:
- encapsulation and decapsulation agree on the exact shared secret
- re-running with the same seed produces the exact same ciphertext and shared secret
This is not how production systems should usually be written (real deployments use strong entropy sources), but it’s extremely useful for debugging and regression testing.
What I mean by terms (quick, practical)
- KEM (Key Encapsulation Mechanism): a two-step protocol:
- Encapsulation: you take a public key and produce
(ciphertext, shared_secret) - Decapsulation: you take the secret key and ciphertext and recover the same
shared_secret
- Encapsulation: you take a public key and produce
- Shared secret: symmetric-key material you’d feed into an AEAD cipher later (like AES-GCM or ChaCha20-Poly1305).
- Ciphertext: the encrypted blob sent over the wire.
- Deterministic replay: ensuring that the same inputs produce the same outputs.
Working code: deterministic Kyber512 KEM smoke test
Install dependencies
I used Python bindings for Kyber (via pqcrypto). On my machine:
pip install pqcrypto
The deterministic RNG (seeded byte stream)
Kyber needs randomness. Instead of using os.urandom, I used a deterministic byte stream derived from a seed.
import hmac import hashlib class DeterministicRNG: """ Deterministic byte generator for reproducible experiments. It expands a seed into an infinite stream of bytes using HMAC-SHA256. """ def __init__(self, seed: bytes, nonce: bytes = b"kyber512-det-replay"): self.seed = seed self.nonce = nonce self.counter = 0 self.buffer = b"" def _refill(self, want: int = 64): # Generate a new block using HMAC(seed, nonce || counter) msg = self.nonce + self.counter.to_bytes(8, "big") block = hmac.new(self.seed, msg, hashlib.sha256).digest() self.counter += 1 self.buffer += block def read(self, n: int) -> bytes: out = b"" while len(out) < n: if len(self.buffer) < 32: self._refill() take = min(n - len(out), len(self.buffer)) out += self.buffer[:take] self.buffer = self.buffer[take:] return out
Why this block exists: I needed a stable stream of bytes. Using HMAC lets me produce deterministic pseudo-random bytes in a way that looks like “randomness” from the caller’s perspective.
Step-by-step Kyber512 smoke test
This script:
- generates a Kyber512 keypair once
- runs encapsulation with deterministic randomness
- decapsulates and checks equality
- runs a second time with the same seed to check deterministic replay
from pqcrypto.kem import kyber512 import hmac import hashlib class DeterministicRNG: def __init__(self, seed: bytes, nonce: bytes = b"kyber512-det-replay"): self.seed = seed self.nonce = nonce self.counter = 0 self.buffer = b"" def _refill(self): msg = self.nonce + self.counter.to_bytes(8, "big") block = hmac.new(self.seed, msg, hashlib.sha256).digest() self.counter += 1 self.buffer += block def read(self, n: int) -> bytes: out = b"" while len(out) < n: if len(self.buffer) == 0: self._refill() take = min(n - len(out), len(self.buffer)) out += self.buffer[:take] self.buffer = self.buffer[take:] return out def main(): # 32-byte seed for reproducible behavior seed = b"this is a test seed for deterministic kyber replay"[:32] rng1 = DeterministicRNG(seed) rng2 = DeterministicRNG(seed) # Generate a Kyber512 keypair # (Key generation is typically randomized; for deterministic experiments you'd # also need deterministic keygen, but here we focus on KEM replay.) pk, sk = kyber512.generate_keypair() # Encapsulation uses randomness internally. # The pqcrypto API exposes "encrypt" style operations; depending on version, # it may accept randomness. We'll demonstrate a deterministic route by # using a fixed randomness source where supported. # # NOTE: The exact API support for custom randomness can vary by pqcrypto version. # This script uses the "encrypt" function that supports a deterministic randomness parameter. ct1, shared1 = kyber512.encrypt(pk, rng=rng1.read) shared1_check = kyber512.decrypt(ct1, sk) # Decapsulation must match exactly assert shared1 == shared1_check, "Shared secrets do not match!" # Replay with the same seed and same public key ct2, shared2 = kyber512.encrypt(pk, rng=rng2.read) shared2_check = kyber512.decrypt(ct2, sk) assert shared2 == shared2_check, "Replay shared secret mismatch on decap!" assert ct1 == ct2, "Ciphertexts differ under deterministic replay!" assert shared1 == shared2, "Shared secrets differ under deterministic replay!" print("OK: deterministic Kyber512 KEM replay works.") print(f"ciphertext (hex) = {ct1.hex()[:64]}... (len={len(ct1)})") print(f"shared secret (hex) = {shared1.hex()[:64]}... (len={len(shared1)})") if __name__ == "__main__": main()
Important note about the pqcrypto API
In the code above, I assumed kyber512.encrypt(pk, rng=...) exists in your installed version. In practice, different library versions expose randomness control differently. If your build doesn’t accept rng=, the deterministic replay idea still applies, but you’d adapt to whatever hooks exist (or use a different wrapper that allows deterministic encapsulation).
The reason I’m showing this directly is because the debugging workflow is the point:
- deterministic RNG stream
- explicit equality checks for ciphertext and shared secret
- replay to confirm you removed nondeterminism from the KEM path
What I observed when running it
On my first run, I made a common mistake: I seeded a deterministic RNG, but I accidentally used a different seed or re-created the RNG in a way that reset its internal counter inconsistently. That caused:
ct1 != ct2shared1 != shared2
Once I fixed RNG initialization so both runs started at counter = 0 with the same seed and nonce, both ciphertext and shared secret matched exactly.
That kind of “bit-for-bit” confidence is invaluable for debugging:
- you can bisect changes in your serialization
- you can ensure network framing didn’t corrupt bytes
- you can verify you didn’t accidentally swap
(ct, shared)assignments
Practical tie-in: why deterministic KEM testing matters for post-quantum systems
Even though real post-quantum deployments should rely on genuine randomness, deterministic smoke tests are a lifesaver during development—especially when migrating from classical key exchange where determinism is often easier to reason about at the test harness level.
The most effective workflow I landed on was:
- Use deterministic replay to validate protocol wiring and byte-level correctness.
- Switch back to nondeterministic randomness for real runs.
- Keep the deterministic test in CI to catch regressions.
Conclusion
I built a deterministic Kyber512 KEM smoke test by swapping out the usual entropy with a seeded deterministic byte stream and then enforcing strict equality checks on both ciphertext and recovered shared secrets across replays. The big lesson from tinkering: reproducible cryptographic debugging is mostly about controlling every source of nondeterminism, not just “making the code testable,” and once you do that, whole classes of subtle serialization and wiring bugs become immediately obvious.