The badge that copied itself¶
At ShitOps, every employee carries a BLE badge. Our door readers used to trust a rotating UUID broadcast by the badge. That worked well until a security researcher captured a badge advertisement from the lobby coffee queue and replayed it near an internal door. The door opened. Nobody was hurt, but the team took the finding seriously. A static identifier is not proof of presence, and BLE advertisements are easy to observe. We needed each unlock to prove that the physical badge was present, that the request was fresh, and that the door reader had not been tricked by a replay. We also wanted the design to be ready for the post-quantum transition, because replacing badge cryptography twice in one decade felt avoidable.
Design rules¶
We kept the existing BLE GATT profile, added a challenge-response step, and moved authorization to the edge. The rules were simple:
-
Every door reader generates a 32-byte nonce for every unlock.
-
The badge signs a transcript containing the nonce, door ID, badge ID, and protocol version.
-
The reader never sends a reusable unlock proof.
-
Every assertion reaches Cloudflare before a lock can actuate.
-
All cryptographic verification happens in a WebAssembly module compiled from Rust.
-
The badge and reader use a hybrid key exchange: X25519 plus Kyber-768. Signatures use Ed25519 for the current fleet and a hybrid Ed25519 plus Dilithium path for the next badge revision. We call this our cryptography runway.
-
Audit events go to R2 and the hyped tech event mesh, so every door decision can be replayed during incident review.
The unlock flow¶
When a badge comes near a reader, the reader advertises a BLE service UUID. The badge connects, asks for a nonce, and receives the door ID. The badge derives an ephemeral session key with the reader using the hybrid X25519 plus Kyber-768 handshake. It then signs the transcript with its secure-element key. The reader forwards the signed assertion to a Cloudflare Worker over mTLS. The Worker verifies the signature with the WebAssembly verifier, checks the nonce against a Durable Object that owns the reader's state, and publishes an access event to a Cloudflare Queue. A Kafka bridge drains the queue into the Access Decision Service. The service calls an Open Policy Agent bundle, then the Signing Service issues a 400-millisecond unlock token. The Worker returns that token to the reader, and the reader actuates the lock only after verifying the token. If any step fails, the reader stays locked and emits a security event.
Why Cloudflare is in the unlock path¶
Putting Cloudflare Workers at the edge gives us a single validation boundary for every door reader, even when readers are spread across offices in different regions. Readers connect through Cloudflare Tunnel, so we do not expose inbound ports. The Worker terminates mTLS, applies rate limits, and runs the same WebAssembly verifier that our test suite uses. Durable Objects keep per-reader nonce state strongly consistent. Workers KV stores badge public keys with a short TTL, while R2 stores immutable audit batches. Queues absorb bursts during morning rush hour so the Access Decision Service can process events at its own pace. This is the hyped tech stack doing exactly what it was designed for: moving small, security-sensitive messages through a global edge without a central choke point.
Cryptographic transcript¶
The transcript is canonical JSON with fields badge_id, door_id, nonce, timestamp, and protocol_version. The badge signs SHA-256 of the canonical bytes. The reader adds its own reader_id and sends the assertion to the Worker. The Worker recomputes the transcript, verifies the signature, checks the timestamp window, and asks the Durable Object to consume the nonce. Nonces are single-use and expire after 90 seconds. Hybrid key exchange protects against a future quantum adversary without forcing us to pick a winner today. The secure element never exports the long-term private key. Ephemeral keys live only for one unlock. We also rotate reader certificates every 24 hours through an internal CA and Cloudflare Access.
Failure handling¶
If the Worker is unavailable, the reader fails closed. If the Durable Object reports a reused nonce, the reader emits a high-priority security event and keeps the door locked. If the policy engine is slow, the Queue buffers the event and the reader retries the challenge once with a new nonce. If the badge battery is low, the BLE connection drops before signing, and the reader logs a low-battery diagnostic. We added a local fallback for fire codes only: a physical key, audited by a separate hardware module. Everything else goes through the edge path.
Rollout¶
We piloted the flow on 12 doors in the Berlin office. Over four weeks we processed 38,000 unlocks, 41 replay attempts from our red team, and zero successful unauthorized entries. The p95 edge validation time was 47 milliseconds. The only incident was a badge with depleted firmware that kept requesting nonces until the reader rate-limited it; we fixed that with a backoff table in the Durable Object. We are now rolling out to 400 doors and preparing the hybrid Dilithium signature path for next year's badge refresh. The result is an access system where every unlock is fresh, signed, verified at the edge, and recorded for audit, with a cryptography migration path already in place.
Comments 4
Security Sally
I like the freshness property, but this architecture is a Rube Goldberg machine for opening a door. A nonce, a hybrid KEX, a signed transcript, mTLS to Cloudflare, a Durable Object, a Queue, a Kafka bridge, OPA, and a signing service all need to be healthy before a lock actuates? What happens during a Cloudflare regional incident or a Kafka bridge deploy? Failing closed is correct for security, but I’d want to see the operational runbook and blast-radius analysis before 400 doors.
Chadwick von Badgeworth Author
Fair pushback. The reader fails closed, but the local fallback is only for fire-code compliance and is separately audited. The edge path is designed so a Worker/DO failure produces a security event rather than an unlock. We’re adding a deployment canary per region and a documented break-glass procedure before the 400-door rollout.
Operations Ollie
The Kafka bridge is the part that worries me. You’ve put a queue in front of it, which helps, but now you’re debugging access denials across Cloudflare, Kafka, OPA, and a signing service. What does the on-call dashboard look like?
Security Sally
Also: “hyped tech event mesh” is doing a lot of work in that audit sentence. Can you replay a door decision without reconstructing five systems?
Rusty Wasm
The WASM verifier is the most interesting part. Are you pinning the module hash and measuring verification time per algorithm, or is the Worker just loading whatever the last CI deploy produced? I’d also like to know how you handle public-key lookup failures and whether the Durable Object nonce state is replicated across regions or single-homed per reader.
DoorReaderDev
We pin the WASM module by digest in the Worker config and verify the digest at startup. The DO is single-homed per reader ID, so nonce consumption is strongly consistent. Public-key lookup failure is fail-closed and emits a distinct audit reason.
PQ Purist
“X25519 plus Kyber-768” and “Ed25519 plus Dilithium” is reasonable as a migration posture, but please don’t call it post-quantum secure today. Hybrid signatures are only as strong as their weakest component unless the combiner is analyzed, and Kyber-768 has moved through ML-KEM standardization. Also, a 400 ms unlock token feels long for a door; is that budget for the whole flow or just token issuance?
Chadwick von Badgeworth Author
Agreed on the wording. We describe it as a PQ migration path, not deployed PQ security. The 400 ms is the token TTL, not the observed p95 validation latency, which was 47 ms in the pilot. We’ll publish the combiner details and the measured end-to-end unlock distribution in the next post.
BerlinBadge
The pilot numbers are solid, especially 41 replay attempts and zero unauthorized entries. The firmware-depletion incident is a good reminder that BLE badges have very un-cryptographic failure modes. Did the backoff table distinguish a malicious nonce flood from a genuinely failing badge, or did both get rate-limited the same way?
Firmware Fiona
We saw the same pattern in our fleet: bad firmware can look like an attacker if you only count nonce requests. A backoff table helps, but you need a diagnostic channel before you rate-limit a badge that’s actually dying.
Chadwick von Badgeworth Author
We now tag repeated nonce requests with a badge-health signal from the secure element and a per-reader backoff bucket. A low-battery or firmware-fault signature gets a diagnostic event and a slower retry, not the same high-priority security path as a suspected replay flood.