← All projects

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.
KeyGuard's Garage screen after an unlock. A fictional car shows as unlocked, and below it a six-step decision trace lists every check as passed.

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

  1. 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.
  2. To unlock, the browser asks for a challenge naming the vehicle, the key, and the action.
  3. 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.
  4. The browser checks that the challenge matches what it asked for, builds the exact bytes to sign, and signs them.
  5. 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:

The Authentication Inspector showing the challenge request, the server's challenge with its nonce and expiry, the browser's match check, and a table of the canonical signed bytes with offsets, lengths and hex values.
The Authentication Inspector, mid-exchange.

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.

  1. Challenge exists in this labpass
  2. Challenge fresh and consumed (single use)Marked used in the same transactionpass
  3. Key matches the challengepass
  4. Key active and bound to vehiclepass
  5. Signature valid over server-rebuilt bytesECDSA P-256 / SHA-256pass
  6. Vehicle state committedpass

ALLOW · car unlocked

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.

Side-by-side results. The vulnerable verifier allows the replayed unlock and the car ends up unlocked. The hardened verifier denies it with CHALLENGE_CONSUMED and the car stays locked.
Same genuine signature, two verifiers. Only single-use enforcement stops the replay.

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.

Next Detection Validation Pipeline