A single-server demo showing the benefits of a hardware-bound session: a stolen session cookie - leaked via infostealer malware, XSS exfiltration, phishing, a compromised backup, whatever - is worthless on its own. Every authenticated request, including reads, must carry a live proof that the browser holding the cookie is the same one that the session is hardware-bound to.
Built for the YubiKey 5.8 / CTAP 2.3 developer hackathon. It uses the headline 5.8 capability - hardware-backed signatures via previewSign + ARKG - to mint that binding once per short-lived epoch, then verifies every request against it with a cheap HMAC. The demo app behind the session is a personal account dashboard (SSN, date of birth, phone, address, a saved payment card, recovery email, trusted devices) - deliberately the kind of PII payload an infostealer actually goes after when it steals a cookie. The subject of this submission is the session-binding mechanism, not the app sitting behind it.
Limitation: this defeats the entire class of off-machine attacker - anyone who only has a copy of the cookie. It does not defend against an attacker whose code is already running inside your live browser (XSS, a malicious extension): that code shares the page's JS memory, so it can read the binding key exactly like the app itself and mint valid requests. The demo shows this directly rather than asserting it - see the two attacker scenarios further along in this README.
In hardware mode the rolling-proof epoch is signed by the YubiKey via previewSign/ARKG.
Prerequisite - enroll the ARKG seed once (needs the key; Windows admin
terminal). This produces arkg_enrollment.json; copy it into this folder:
python enroll_arkg.py # writes arkg_enrollment.json (defaults to --flags unattended;
# run --help for the touch/PIN presence-policy variants)
pip install cryptography fido2 # the only two dependencies
# cryptography - always
# fido2 - talking to the key (--hardware, enrolment)
Terminal 1 - the app server:
python server.py --hardware # loads arkg_enrollment.json; serves http://localhost:8000
Terminal 2 - the local signing agent (talks to the YubiKey; Windows admin):
python signing_agent.py # http://localhost:8801, POST /sign
Then open http://localhost:8000, create an account / sign in with the passkey,
and the browser will drive prepare → agent /sign → commit to bind the session
on the key. The sign-in establishment asks for a touch (up=true); background
refreshes are silent (up=false). Expect roughly a one-second pause on each
epoch signature - during testing, this 1s pause became apparent on the preview 5.8 YubiKey,
and it's why the application signs once per epoch and binds each request with a cheap HMAC rather than signing every request.
Notes:
- The agent's
RP_ID(inertcookie.local) must match what the ARKG credential was enrolled under; the webRP_ID(localhost) is separate and drives the passkey ceremonies. They don't need to agree. - If the page runs on a port other than 8000, update
PAGE_ORIGINinsigning_agent.py(CORS) and pass--portto match. - Verify the whole route with software crypto first - no device needed:
python test_hw_route.pyrunsprepare → sign → commit → bound readend-to-end, so if that passes, only the ARKG-derive and the on-key signature are left to exercise live.
The manual enroll_arkg.py run above is fine for this hackathon demo, but the fact that
it requires - an elevated terminal and a separate command isn't a good end-user
experience, and isn't actually necessary long-term.
Why it exists: previewSign/ARKG's generateKey extension isn't yet part
of the browser-exposed WebAuthn surface - navigator.credentials.create()
doesn't know about it, only raw CTAP does.
The fix: previewSign/ARKG is early-access on 5.8 - once it graduates into the standard WebAuthn extension surface, browsers expose it directly and the native helper requirement is resolved.
Every authenticated route - including GET /api/profile - requires a
rolling-proof binding keyed by bk, a per-epoch key that lives only in
browser memory and the server's session state, never in the cookie.
- Once per epoch (a short window) the client produces one hardware
signature over an epoch proof:
{session, origin, epoch id, start, lifetime, commitment to bk}. In--hardwaremode this is a real previewSign/ARKG signature on the YubiKey. - Every request in that epoch carries a cheap
HMAC(bk, canonical_request)- no hardware round-trip, unlimited rate.
canonical.py/canonical.jskeep client and server byte-identical on what gets bound: method, path, body hash, origin, session id, timestamp, and a strictly-increasing counter (verifier.py's sliding-window replay check).
- no hardware round-trip, unlimited rate.
A stolen cookie carries none of this. It can't read the SSN, the saved card, the address - let alone hijack the recovery email - the entire infostealer / stolen-cookie / phished-session class of attack is dead against this session, not just login.
server.py HTTP server: WebAuthn RP + sessions + rolling-proof + UI
webauthn_rp.py minimal stdlib-only WebAuthn (CBOR/COSE parse, assertion verify)
rolling_proof.py epoch proofs + MAC-bound request verification (tested)
verifier.py ECDSA-P256 verify + sliding-window replay (tested)
canonical.py canonical request contract (byte-identical to canonical.js)
canonical.js browser port of the request contract
web/index.html sign-in / sign-up / app / attacker UI
app.js WebAuthn client + transparent secured-fetch + attacker scenarios
signing_agent.py (hardware mode) local previewSign/ARKG signer for the epoch
enroll_arkg.py (hardware mode, one-time) enrolls the ARKG seed -> arkg_enrollment.json
test_hw_route.py end-to-end test of the hardware epoch route (see Tests below)
users.json persisted identities (created on first run; gitignored)
users.json (created next to server.py on first registration) persists
usernames, WebAuthn public-key credentials, and the synthetic PII profile
(SSN, DOB, phone, address, card, recovery email, trusted devices) across
server restarts - no private key material is ever in it.
Everything else is deliberately memory-only and dies with the process:
sessions, WebAuthn challenges, and - most importantly - the rolling-proof
epoch and bk itself, which must never touch disk or they'd defeat their
own purpose.
- Default mode uses real WebAuthn with a software epoch signer.
--hardwareputs the epoch proof on the key. - The demo shows the cookie and doesn't set
HttpOnlyso you can see it get stolen; production would setHttpOnly; Secure; SameSite. The point stands: even a fully-stolen cookie is inert withoutbk. - In-browser attackers are explicitly not defended against, and the demo shows this rather than hiding it. Closing that gap would mean requiring a fresh per-action WebAuthn gesture on top of the session binding - deliberately left out of this submission.
This project was built with substantial assistance from Claude Code (Anthropic), used throughout as a pair-programmer, code reviewer, and debugging partner:
The concept and security design behind Keybound are my own - the rolling-proof session binding, the per-epoch signing model that works around ARKG's ~1 second signature cost, and the threat model it's built to defeat. I used Claude Code (Anthropic) to turn that design into working software quickly: writing the implementation, debugging the YubiKey and CTAP integration, and iterating on the demo. Every architectural and product decision was mine; the AI accelerated the build, not the thinking.