Vehicle access security
KeyGuard
An educational lab for phone-as-key vehicle access. A browser key signs single-use challenges, the server decides, keys can be revoked instantly, and an Attack Lab replays a captured unlock against a vulnerable and a hardened verifier side by side.
- Role
- Solo project
- When
- November 2026 – present
- Status
- In progress. All seven core features work; analytics are next.
- Built with
- Next.js, TypeScript, FastAPI, Python, SQLite, Web Crypto API
- Links
- The repository is private while I'm still building it.

The idea
A lot of newer cars let your phone act as the key. The basic scheme is simple: the car sends a fresh random challenge, the phone signs it with a private key that never leaves the phone, and the car checks the signature against a public key it already trusts. No shared secret ever crosses the air.
What interested me is that a valid signature isn’t enough. If someone records a genuine unlock response and plays it back later, the signature still checks out. Whether the car opens depends entirely on what the verifier does beyond checking the math.
KeyGuard is a lab for exploring that. It uses fictional vehicles and simulated credentials only, and it doesn’t connect to any real car. The project works from a written product and engineering spec and a threat model, and I’ve been building it in sprints.
How an unlock works
- When you enroll a key, the browser generates an ECDSA P-256 key pair with the Web Crypto API. The private key is created as non-extractable: page scripts can sign with it, but no code, including mine, can export it. Only the public key goes to the server.
- To unlock, the browser asks for a challenge naming the vehicle, the key, and the action.
- The server creates a challenge with a 32-byte random nonce, bound to that vehicle, key and action, valid for 30 seconds, and stores it as unused.
- The browser checks that the challenge matches what it asked for, builds the exact bytes to sign, and signs them.
- The server rebuilds those bytes from its own stored copy of the challenge, never from anything the client sends, and runs six checks. It stops at the first one that fails.
The Inspector page walks through one exchange step by step, down to the bytes:

You can see where each kind of attack gets stopped:
An enrolled, active key signs a fresh challenge. All six checks pass and the server changes the lock state.
- Challenge exists in this labpass
- Challenge fresh and consumed (single use)Marked used in the same transactionpass
- Key matches the challengepass
- Key active and bound to vehiclepass
- Signature valid over server-rebuilt bytesECDSA P-256 / SHA-256pass
- Vehicle state committedpass
ALLOW · car unlocked
An attacker resends a captured, genuine response. The signature is valid, so only single use stops it.
- Challenge exists in this labpass
- Challenge fresh and consumed (single use)CHALLENGE_CONSUMED: this challenge was already usedfail
- Key matches the challengeskipped
- Key active and bound to vehicleskipped
- Signature valid over server-rebuilt bytesskipped
- Vehicle state committedskipped
DENY · CHALLENGE_CONSUMED
Challenges last 30 seconds on the server clock. The test allows at +29 s and denies at +30 s.
- Challenge exists in this labpass
- Challenge fresh and consumed (single use)CHALLENGE_EXPIRED: older than 30 secondsfail
- Key matches the challengeskipped
- Key active and bound to vehicleskipped
- Signature valid over server-rebuilt bytesskipped
- Vehicle state committedskipped
DENY · CHALLENGE_EXPIRED
Revocation takes effect immediately, even for a challenge issued before the key was revoked.
- Challenge exists in this labpass
- Challenge fresh and consumed (single use)pass
- Key matches the challengepass
- Key active and bound to vehicleKEY_REVOKEDfail
- Signature valid over server-rebuilt bytesskipped
- Vehicle state committedskipped
DENY · KEY_REVOKED
A signature made for “lock” is sent with an “unlock” challenge. The action is part of the signed bytes, so it fails.
- Challenge exists in this labpass
- Challenge fresh and consumed (single use)pass
- Key matches the challengepass
- Key active and bound to vehiclepass
- Signature valid over server-rebuilt bytesSIGNATURE_INVALID: signed bytes do not match this challengefail
- Vehicle state committedskipped
DENY · SIGNATURE_INVALID
Scenarios MX-01, MX-03, MX-04, MX-05 and MX-07 from the KeyGuard test matrix.
What exactly gets signed
The browser and the Python server have to produce byte-for-byte identical messages, or every signature fails. I considered signing JSON, but making JSON canonical has edge cases with number formatting and Unicode. Joining fields with a delimiter is worse, because a value containing the delimiter can shift the boundaries between fields.
I went with a fixed domain tag followed by seven fields, each prefixed with its length as a 4-byte big-endian integer:
message = "KEYGUARD-AUTH" 0x00 0x01 || field_1 || field_2 || ... || field_7
field_i = u32_be(len(bytes_i)) || bytes_i
| # | Field | Value |
|---|---|---|
| 1 | protocol_version |
kg-auth-v1 |
| 2 | vehicle_id |
Opaque server ID, veh_… |
| 3 | key_id |
Opaque server ID, key_… |
| 4 | challenge_id |
Opaque server ID, chl_… |
| 5 | nonce |
32 random bytes as unpadded base64url |
| 6 | action |
unlock or lock |
| 7 | expires_at |
Unix seconds as plain decimal |
Because the vehicle and the action are inside the signed bytes, a signature made for one car or for “lock” can’t be reused for another car or for “unlock.” Any change to the format bumps the version byte. Both the TypeScript and Python test suites read the same file of test vectors, so the two implementations can’t drift apart.
Making single use actually hold
The obvious way to enforce single use is to read the challenge, check it hasn’t been used, then mark it used. That has a gap: two requests can both read “unused” before either one writes.
Instead, the server marks the challenge used with one conditional update that only succeeds if the challenge is still unused and unexpired:
UPDATE challenges SET consumed_at = :now
WHERE id = :id AND lab_id = :lab
AND consumed_at IS NULL AND expires_at > :now
If the update changes one row, this request won the race. If it changes zero, the challenge was already used or has expired. The test fires 16 threads at the same challenge, five times over. Every round, exactly one is allowed and fifteen are denied with CHALLENGE_CONSUMED.
To make sure that test would actually catch a broken version, I removed consumed_at IS NULL from the update and re-ran the suite. Eleven tests failed: the replay test and all ten concurrency rounds.
I also decided that any attempt on a valid challenge uses it up, even if a later check fails. That means an attacker gets exactly one try per challenge.
The Attack Lab
The Attack Lab runs a replay scenario twice: once against a deliberately vulnerable verifier that checks signatures but never marks challenges as used, and once against the real engine. The owner unlocks, then locks the car. An attacker who captured the unlock response sends it again.

Shipping intentionally vulnerable code next to the real thing is risky, so I kept them apart. The vulnerable verifier lives in its own package, runs only in memory, and imports no database code. Both runs use throwaway state, so the user’s own lab is never touched. A test builds the import graph of the backend and fails if anything on the real authentication path can reach the vulnerable package.
Other decisions
- Labs instead of accounts. Each visitor gets a disposable lab with one fictional car and an access token that’s returned once and stored only as a SHA-256 hash. Labs expire after 24 hours.
- Labs can’t be discovered. An unknown lab, a wrong token, and an expired lab all return the same
404, so the API can’t be used to probe which lab IDs exist. Every database query also filters by lab ID. - No guessable IDs. Everything uses random 128-bit IDs with a type prefix (
veh_,key_,chl_,lab_), never sequential numbers. - The key is saved only after the server agrees. The browser stores the private key handle in IndexedDB only after the server accepts the public key and its fingerprint matches the one computed locally. Otherwise the key pair is thrown away.
- Revocation is immediate. A revoked key is denied even on a challenge that was issued before it was revoked.
Testing
The spec defines an 11-scenario security matrix. Every CI run executes it through the real HTTP API against a fresh database, and anything other than a pass fails the build. The runner sorts results into a pass, a security failure (something unauthorized got through), a functional failure (a valid request was denied), or an infrastructure failure (the test itself broke, so the result can’t be trusted).
| Scenario | Expected | Result |
|---|---|---|
| Active key, fresh challenge | Allow | Allowed |
| Incorrect signature | Deny | Denied · SIGNATURE_INVALID |
| Revoked key | Deny | Denied · KEY_REVOKED |
| Expired challenge | Deny | Denied · CHALLENGE_EXPIRED |
| Replayed response | Deny | Denied · CHALLENGE_CONSUMED |
| Wrong vehicle | Deny | Denied · SIGNATURE_INVALID |
| Wrong action | Deny | Denied · SIGNATURE_INVALID |
| 16 concurrent submissions | At most one allowed | 1 allowed, 15 denied |
| Unknown key | Deny | Denied · KEY_UNKNOWN |
| Malformed payload | Reject | Rejected · REQUEST_INVALID |
| Request from another lab | Deny | Denied · LAB_NOT_FOUND |
Altogether there are 172 backend tests and 41 frontend tests. I also disabled each security check in the engine one at a time and re-ran the suite to confirm something fails:
| Check disabled | Caught by |
|---|---|
| Atomic single-use update | Replay and concurrency tests |
| Key must match the challenge | Unknown-key tests |
| Revocation check | Revoked-key tests |
| Key bound to vehicle | Key re-bound after issue test |
| Signature verification | Bad-signature and wrong-action tests |
One more mutation, rebuilding the bytes with the key ID from the request instead of from the challenge, wasn’t caught. When I looked at it, it turned out to be equivalent: an earlier check already forces those two IDs to match.
Timeline
| Date | Sprint |
|---|---|
| Nov 5 | Spec review, threat model, protocol core, atomic challenges, lab auth |
| Nov 5 | Browser key generation and enrollment |
| Nov 5 | Challenge-response authentication and real lock and unlock |
| Nov 6 | Revocation and the structured security matrix |
| Nov 6 | Attack Lab with the isolated vulnerable verifier |
| Nov 9 | Authentication Inspector |
| Next | Events API and security analytics |
What it doesn’t prove
- A valid signature proves the request came from the enrolled key, not that the phone is anywhere near the car. Relay attacks, which extend the range between a real key and a car, are out of scope.
- Browser keys aren’t hardware-backed. Anyone who can run script on the page can use the key, even though they can’t export it.
- Concurrency is tested on SQLite. The conditional update is designed to work the same on PostgreSQL, but I haven’t tested that yet.
- Passing eleven curated scenarios doesn’t mean the system is secure in general.
What’s next
An events API and an analytics view of security decisions, then Attack Lab scenarios beyond replay, such as tampering with signed fields.