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.

flowchart TD A[BLE badge] -->|request nonce| B[Door reader] B -->|nonce and door ID| A A -->|signed assertion| B B -->|mTLS POST| C[Cloudflare Worker edge validator] C --> D[WASM PQ crypto verifier] D --> E[Durable Object nonce state] E --> F[Cloudflare Queue] F --> G[Kafka bridge] G --> H[Access Decision Service] H --> I[OPA policy engine] I --> J[Signing Service] J --> K[Short lived unlock token] K --> C C --> B B --> L[Lock actuator]

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.