@codefusion-cc/passkeys
v0.1.1
Published
WebAuthn passkeys on WebCrypto alone for a fresh step-up check before something sensitive: registration and assertions verified in a Cloudflare Worker (ES256, Ed25519, RS256), the ceremonies in the page, and a software authenticator for apps' tests
Maintainers
Readme
@codefusion-cc/passkeys
WebAuthn passkeys for a fresh step-up check: before something sensitive (running a command, changing a key), the person proves again with their passkey (Face ID, Touch ID, Windows Hello, a phone, a security key) that it is them. It is meant for apps whose sign-in lives elsewhere (Cloudflare Access, a session cookie): passkeys here are added inside a session that is already authenticated, and used to confirm it, not to sign in.
WebCrypto alone (ES256, Ed25519 and RS256), so the server side runs in Workers and Node 20 and later; its one dependency
is @codefusion-cc/workers-crypto. Storage stays in the app: the package checks, and hands back what to store.
| Entry | Where | What |
| --- | --- | --- |
| @codefusion-cc/passkeys | Worker, server | newChallenge, verifyRegistration, verifyAssertion, assertionCredentialId |
| @codefusion-cc/passkeys/browser | the page | passkeysSupported, createPasskey, usePasskey |
| @codefusion-cc/passkeys/testing | the app's tests | softAuthenticator: registration and step-up end to end without a browser |
npm install @codefusion-cc/passkeysThe relying party
const rp = { rpId: 'console.example.com', origins: ['https://console.example.com'] }rpId is the host name passkeys are bound to: a passkey made for one RP id never works for another, so pick the
host the page runs on (or a parent domain it may claim, if several subdomains share passkeys). origins are the exact
origins the page may run the ceremony on, as browsers write them: scheme, host and a port only when not the default,
no trailing slash. The verifiers throw for an RP id with a scheme, port or path, and for a web origin written any other
way, since either would refuse every ceremony.
Step-up
The server issues a challenge for this session and purpose, the page signs it with a passkey, the server verifies and
records a short step-up window for this session. session.id below is whatever identifies the signed-in session
(with Cloudflare Access, the Access JWT's session); keying on the session and not only the person means another
session of the same person (a stolen cookie) gains nothing from the person's own step-up.
// Worker: POST /api/step-up/options
import { newChallenge } from '@codefusion-cc/passkeys'
const passkeys = await db.passkeysOf(person.id) // the person's StoredPasskeys
if (!passkeys.length) return json({ error: 'no-passkey' }, 409)
const challenge = newChallenge()
await db.saveChallenge({ challenge, sessionId: session.id, personId: person.id, purpose: 'step-up', expiresAt: Date.now() + 2 * 60_000 })
return json({ challenge, rpId: rp.rpId, allow: passkeys.map(({ id, transports }) => ({ id, transports })) })// The page
import { usePasskey } from '@codefusion-cc/passkeys/browser'
const options = await post('/api/step-up/options')
const used = await usePasskey({ challenge: options.challenge, rpId: options.rpId, allow: options.allow })
if (!used.ok) return show(used.reason) // 'cancelled' | 'not-allowed' | 'unsupported' | 'exists' | 'failed'
await post('/api/step-up', { response: used.response })// Worker: POST /api/step-up
import { assertionCredentialId, verifyAssertion } from '@codefusion-cc/passkeys'
const { response } = await request.json()
// Single use: taken (deleted) whether or not what follows succeeds, so a response is never checked twice.
const stored = await db.takeChallenge({ sessionId: session.id, personId: person.id, purpose: 'step-up' })
if (!stored || stored.expiresAt < Date.now()) return json({ error: 'expired' }, 400)
const passkey = await db.passkey(person.id, assertionCredentialId(response)) // only this person's passkeys
if (!passkey) return json({ error: 'unknown-passkey' }, 400)
const result = await verifyAssertion({ response, passkey, challenge: stored.challenge, rp })
if (!result.ok) return json({ error: result.reason }, result.reason === 'counter' ? 409 : 400)
await db.updatePasskey(passkey.id, { signCount: result.signCount, backedUp: result.backedUp, lastUsedAt: Date.now() })
await db.recordStepUp({ sessionId: session.id, personId: person.id, until: Date.now() + 5 * 60_000 })
return json({ ok: true })Then the sensitive route checks the step-up window (until > Date.now(), for this session and person) before doing
anything.
Registration
// Worker: POST /api/passkeys/options
const existing = await db.passkeysOf(person.id)
// Once the person has a passkey, adding another takes a fresh step-up of this session (see the security notes).
if (existing.length && !(await db.steppedUp({ sessionId: session.id, personId: person.id }))) return json({ error: 'step-up' }, 403)
const challenge = newChallenge()
await db.saveChallenge({ challenge, sessionId: session.id, personId: person.id, purpose: 'register', expiresAt: Date.now() + 5 * 60_000 })
return json({ challenge, rp: { id: rp.rpId, name: 'Example Console' }, user: { id: person.webauthnId, name: person.email, displayName: person.name }, exclude: existing.map(({ id, transports }) => ({ id, transports })) })// The page
import { createPasskey, passkeysSupported } from '@codefusion-cc/passkeys/browser'
if (!passkeysSupported()) return show('unsupported')
const options = await post('/api/passkeys/options')
const created = await createPasskey(options)
if (!created.ok) return show(created.reason) // 'exists': this device already holds one of the person's passkeys
await post('/api/passkeys', { response: created.response, label: 'MacBook' })// Worker: POST /api/passkeys
import { verifyRegistration } from '@codefusion-cc/passkeys'
const stored = await db.takeChallenge({ sessionId: session.id, personId: person.id, purpose: 'register' })
if (!stored || stored.expiresAt < Date.now()) return json({ error: 'expired' }, 400)
if ((await db.passkeysOf(person.id)).length && !(await db.steppedUp({ sessionId: session.id, personId: person.id }))) return json({ error: 'step-up' }, 403)
const result = await verifyRegistration({ response: body.response, challenge: stored.challenge, rp })
if (!result.ok) return json({ error: result.reason }, 400)
await db.addPasskey(person.id, { ...result.passkey, label: body.label, createdAt: Date.now() }) // id is uniqueuser.id is base64url (16 to 64 random bytes), made once per person and kept: the authenticator stores it with the
passkey. It must not be an address or anything else that identifies the person to whoever sees the authenticator.
What is checked
- clientDataJSON: valid UTF-8 JSON;
typeiswebauthn.createorwebauthn.get;challengeequals the one issued (compared in constant time);originis one ofrp.originsexactly;crossOriginis not true (no ceremony inside another site's frame). - Authenticator data: the RP id hash is SHA-256 of
rp.rpId; user presence is set; user verification is set unlessrequireUserVerification: false; the backup flags are consistent; at registration, the attested credential id is the response's id, at most 1023 bytes, inside the data; an assertion carries no attested data. - Registration: the algorithm is ES256 (-7), Ed25519 (-8) or RS256 (-257); the public key (the browser's
getPublicKey(), SPKI) is a key of that algorithm (P-256 uncompressed, Ed25519, RSA of 2048 bits or more; in workerd, with exponent 3 or 65537), and the same key, with the same algorithm, as the COSE key in the attested credential data. - Assertion: the signature over authenticator data and the SHA-256 of clientDataJSON verifies with the stored key (ES256 signatures are strict DER); the counter goes up once either it or the stored one is non-zero (WebAuthn §7.2).
- Sizes: every field is bounded before it is decoded, so a hostile response costs no more than a real one.
Both verifiers never throw for the response's content: a refusal names its reason (RegistrationRefusal,
AssertionRefusal). They throw only for what a programming mistake gives: an empty challenge, a relying party without
an RP id or origins (or with one written otherwise than browsers write it), a stored passkey that is not one. When
the runtime's own WebCrypto fails (an algorithm it lacks, a broken binding), they reject with its error: that is no
signature or key refusal, so an app counting failed attempts never locks a person out for an outage.
allow and exclude take ids, or { id, transports } (a StoredPasskey will do): the transports let the browser
reach the passkey's authenticator directly instead of offering every way it knows.
Attestation
The page asks for attestation none, and nothing here verifies one. Attestation proves which make of authenticator
holds a passkey; step-up does not need that, because a passkey is added inside a session that is already the person's.
What a passkey proves is that whoever uses it holds the private key that was registered then. Synced passkeys (iCloud
Keychain, Google Password Manager) carry no useful attestation anyway.
Security notes
- RP id per host name. Use the host the page runs on. A parent domain makes passkeys work on every subdomain, which any subdomain then also gets.
- Challenges server-side, single use, short-lived. Store each challenge with the session, person and purpose
(
step-up,register) it was issued for, take it (delete it) on the first attempt whether or not it succeeds, and refuse it after a few minutes. The verifiers are pure functions: they cannot tell a replayed response from a fresh one when the passkey keeps no counter, which synced passkeys never do. - A short step-up window, for one session. Record the step-up against the session that made it and accept it there for a few minutes; another session of the same person (a stolen cookie) must not ride on it. Ask again for each sensitive action if it is rare.
- Adding or removing a passkey when the person already has one needs a fresh step-up first. Otherwise whoever holds the session (a stolen cookie) adds a passkey of their own and passes every later step-up. A person's first passkey is added on the strength of the session alone, so make that moment visible (a notice, an email).
- Look the passkey up within the person's own.
assertionCredentialIdis the page's claim; find the passkey among the signed-in person's, never among everyone's. countermeans the passkey may have been cloned: refuse, and tell the person, rather than update the counter.
Tests in an app
import { softAuthenticator } from '@codefusion-cc/passkeys/testing'
const authenticator = softAuthenticator({ origin: 'https://console.example.test', rpId: 'console.example.test' })
const registration = await authenticator.register({ challenge: options.challenge }) // POST it to the app
const assertion = await authenticator.assert({ challenge: stepUpOptions.challenge }) // POST it to the app
// What an attacker or a broken client sends:
await authenticator.assert({ challenge, origin: 'https://evil.test' }) // 'origin'
await authenticator.assert({ challenge: 'another' }) // 'challenge'
await authenticator.assert({ challenge, userVerified: false }) // 'user-verification'
await authenticator.assert({ challenge, signCount: 1 }) // 'counter', once it counted past 1
await authenticator.assert({ challenge, tamper: 'signature' }) // 'signature'Options: algorithm (-7, -8 or -257), signCount: 'zero' for a synced passkey's counter, backedUp. Each ceremony
also takes rpId, type, crossOrigin and userPresent. It is written from the WebAuthn and CTAP2 specifications,
not from this package's code, and runs in Node and workerd.
