/** * AuthDO — singleton Durable Object for WebAuthn passkey authentication. * * Stores credentials, challenges, and the session-signing secret. The * Worker is its own WebAuthn relying party — no third-party auth. * * Design constraints (from advisory rounds): * - Credentials keyed by credential ID: `cred:` → * `{uid, publicKey, counter, transports, deviceType, backedUp}`. * No `uid → creds` index (usernameless login derives uid from * `response.id`). * - Registration: server-generated uid, `residentKey: 'required'`, * `userVerification: 'required'`. No caller-chosen uids. * - Login: usernameless — `generateAuthenticationOptions` with no * `allowCredentials`. `loginComplete` loads `cred:` to derive uid. * - Counter updated atomically after successful authentication. * - `origin` and `rpID` are trust anchors read from env, never from * client bodies. * - Session = HMAC(secret) over `uid|iat|exp`, ~30-day. * * Storage keys: * - `session-secret` — 256-bit random, generated once * - `cred:` — credential record * - `challenge:` — {purpose, uid?, exp} * - `uid:` — counter for uid generation (ensures uniqueness) */ import { type RegistrationResponseJSON, type AuthenticationResponseJSON, type VerifiedRegistrationResponse, type VerifiedAuthenticationResponse, type WebAuthnCredential, } from '@simplewebauthn/server'; import { defaultWebAuthnOps, type WebAuthnOps, } from './lib/webauthn-ops.ts'; import { createRateLimitStore } from './lib/rate-limit.ts'; import { deleteStorageKeysBatched } from './lib/storage-batch.ts'; /** * Environment for the AuthDO. The Worker passes `ETHERCALC_RP_ID`, * `ETHERCALC_RP_NAME`, and `ETHERCALC_ORIGIN` from trusted config — * never from client bodies. Standalone workerd delivers unset env vars * as `null`, so every field is optional-nullable and `fetch` fails * closed when the trust anchors are missing. */ export interface AuthEnv { readonly ETHERCALC_RP_ID?: string | null; readonly ETHERCALC_RP_NAME?: string | null; readonly ETHERCALC_ORIGIN?: string | null; } /** Validated relying-party trust anchors, derived once per request. */ interface AuthConfig { readonly rpID: string; readonly rpName: string; readonly origin: string; } /** Stored credential record, keyed by `cred:`. */ interface StoredCredential { readonly uid: string; readonly publicKey: Uint8Array; counter: number; readonly transports: NonNullable; deviceType: string; backedUp: boolean; } /** Stored challenge, keyed by `challenge:`. */ interface StoredChallenge { readonly purpose: 'register' | 'login'; readonly uid?: string; readonly exp: number; } /** * Session token payload: HMAC(secret) over its JSON. `auth` is the passkey * authentication time a renewed token inherits; tokens minted by a ceremony * omit it because it equals `iat`. */ interface SessionPayload { readonly uid: string; readonly iat: number; readonly exp: number; readonly auth?: number; } /** Logout-all cutoff for one uid, keyed by `session-revoked:`. */ interface SessionRevocation { readonly before: number; readonly exp: number; } const CHALLENGE_TTL_MS = 120_000; // 2 minutes const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days // Renewal slides `exp` while the session is in use, but never past this // long after the passkey ceremony that started the chain. const SESSION_MAX_LIFETIME_MS = 90 * 24 * 60 * 60 * 1000; // 90 days const SESSION_RENEW_AFTER_MS = 24 * 60 * 60 * 1000; // at most daily const ALARM_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes const MAX_ACTIVE_CHALLENGES = 1_024; const SESSION_REVOCATION_PREFIX = 'session-revoked:'; const CEREMONY_RATE_LIMIT = { capacity: 30, refillPerSec: 30 / 60, } as const; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }); } function plainResponse(body: string, status: number): Response { return new Response(body, { status, headers: { 'Content-Type': 'text/plain' }, }); } function challengeCapacityResponse(): Response { return new Response('Too many active authentication challenges', { status: 503, headers: { 'Content-Type': 'text/plain', 'Retry-After': String(CHALLENGE_TTL_MS / 1000), }, }); } function isJsonObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } async function readJsonObject(request: Request): Promise | null> { try { const value: unknown = await request.json(); return isJsonObject(value) ? value : null; } catch { return null; } } function bytesToBase64url(bytes: Uint8Array): string { let bin = ''; for (const b of bytes) bin += String.fromCharCode(b); return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } function bytesToHex(bytes: Uint8Array): string { let s = ''; for (const b of bytes) s += (b < 0x10 ? '0' : '') + b.toString(16); return s; } /** HMAC-SHA256 over `message` using `secret` (hex string). Returns hex. */ async function hmacHex(secret: string, message: string): Promise { const enc = new TextEncoder(); const key = await crypto.subtle.importKey( 'raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ); const sig = await crypto.subtle.sign('HMAC', key, enc.encode(message)); return bytesToHex(new Uint8Array(sig)); } /** Timing-safe string comparison. */ function timingSafeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); return diff === 0; } /** Encode a session payload as `base64url(payload).hmac`. */ function encodeSession(payload: SessionPayload, mac: string): string { const payloadJson = JSON.stringify(payload); const payloadB64 = bytesToBase64url(new TextEncoder().encode(payloadJson)); return `${payloadB64}.${mac}`; } /** Decode and verify a session token. Returns the payload or null. */ async function decodeSession( token: string, secret: string, ): Promise { const dot = token.lastIndexOf('.'); if (dot < 1) return null; const payloadB64 = token.slice(0, dot); const mac = token.slice(dot + 1); // Recompute MAC over the payload bytes let payloadBytes: Uint8Array; try { const bin = atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/')); payloadBytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) payloadBytes[i] = bin.charCodeAt(i); } catch { return null; } const expectedMac = await hmacHex(secret, new TextDecoder().decode(payloadBytes)); if (!timingSafeEqual(expectedMac, mac)) return null; let parsed: unknown; try { parsed = JSON.parse(new TextDecoder().decode(payloadBytes)); } catch { return null; } if ( parsed === null || typeof parsed !== 'object' || !('uid' in parsed) || typeof parsed.uid !== 'string' || !('iat' in parsed) || typeof parsed.iat !== 'number' || !('exp' in parsed) || typeof parsed.exp !== 'number' || ('auth' in parsed && typeof parsed.auth !== 'number') ) { return null; } if (Date.now() >= parsed.exp) return null; // Stryker disable next-line ConditionalExpression,LogicalOperator : an `auth: undefined` key behaves exactly like an absent one (authenticatedAt uses ??, JSON.stringify drops it) return 'auth' in parsed && typeof parsed.auth === 'number' ? { uid: parsed.uid, iat: parsed.iat, exp: parsed.exp, auth: parsed.auth } : { uid: parsed.uid, iat: parsed.iat, exp: parsed.exp }; } /** When the passkey ceremony behind this token (or its renewal chain) ran. */ function authenticatedAt(payload: SessionPayload): number { return payload.auth ?? payload.iat; } export class AuthDO implements DurableObject { readonly #state: DurableObjectState; readonly #env: AuthEnv; readonly #ops: WebAuthnOps; #sessionSecret: string | null = null; readonly #ceremonyRateLimit = createRateLimitStore(2_048); constructor( state: DurableObjectState, env: AuthEnv, ops: WebAuthnOps = defaultWebAuthnOps, ) { this.#state = state; this.#env = env; this.#ops = ops; } async fetch(request: Request): Promise { const url = new URL(request.url); const path = url.pathname; // Trust anchors come from deploy config, never from clients. A // deployment without them cannot verify ceremonies — fail closed // before touching any storage. const rpID = this.#env.ETHERCALC_RP_ID; const origin = this.#env.ETHERCALC_ORIGIN; if (!rpID || !origin) { return plainResponse('Auth is not configured', 503); } const config: AuthConfig = { rpID, origin, rpName: this.#env.ETHERCALC_RP_NAME || 'EtherCalc', }; if ( (path === '/_auth/register-init' || path === '/_auth/login-init') && request.method === 'POST' ) { const rate = this.#ceremonyRateLimit.consume( request.headers.get('X-EC-Client-IP') ?? 'unknown', CEREMONY_RATE_LIMIT, ); if (!rate.allowed) { return new Response('Too many authentication attempts', { status: 429, headers: { 'Content-Type': 'text/plain', 'Retry-After': String(rate.retryAfterSec), }, }); } } if (path === '/_auth/register-init' && request.method === 'POST') { return this.#registerInit(config); } if (path === '/_auth/register-complete' && request.method === 'POST') { return this.#registerComplete(request, config); } if (path === '/_auth/login-init' && request.method === 'POST') { return this.#loginInit(config); } if (path === '/_auth/login-complete' && request.method === 'POST') { return this.#loginComplete(request, config); } if (path === '/_auth/verify-session' && request.method === 'POST') { return this.#verifySession(request); } if (path === '/_auth/refresh-session' && request.method === 'POST') { return this.#refreshSession(request); } if (path === '/_auth/revoke-session' && request.method === 'POST') { return this.#revokeSession(request); } return plainResponse('Not Found', 404); } /** Get or generate the session-signing secret (256-bit hex). */ async #getSecret(): Promise { if (this.#sessionSecret) return this.#sessionSecret; const stored = await this.#state.storage.get('session-secret'); if (typeof stored === 'string' && stored.length > 0) { this.#sessionSecret = stored; return stored; } const raw = new Uint8Array(32); crypto.getRandomValues(raw); const secret = bytesToHex(raw); await this.#state.storage.put('session-secret', secret); this.#sessionSecret = secret; return secret; } /** Generate a server-unique uid. */ async #generateUid(): Promise { const raw = new Uint8Array(16); crypto.getRandomValues(raw); return bytesToHex(raw); } /** * Store a challenge without evicting another live ceremony. Returns false * at capacity so an attacker cannot invalidate a victim's in-flight login. */ async #storeChallenge( challenge: string, record: StoredChallenge, ): Promise { const key = `challenge:${challenge}`; return this.#state.blockConcurrencyWhile(async () => { const existing = await this.#state.storage.list({ prefix: 'challenge:', limit: MAX_ACTIVE_CHALLENGES + 1, }); const now = Date.now(); const deletions: string[] = []; let liveCount = 0; for (const [existingKey, value] of existing) { if (!Number.isFinite(value.exp) || value.exp <= now) { deletions.push(existingKey); } else if (existingKey !== key) { liveCount += 1; } } if (deletions.length > 0) { await deleteStorageKeysBatched(this.#state.storage, deletions); } if (liveCount >= MAX_ACTIVE_CHALLENGES) return false; await this.#state.storage.put(key, record); await this.#armAlarm(); return true; }); } /** Find and atomically consume one challenge. */ async #consumeChallenge( challenge: string, purpose: 'register' | 'login', ): Promise { const key = `challenge:${challenge}`; return this.#state.blockConcurrencyWhile(async () => { const stored = await this.#state.storage.get(key); if (stored === undefined || stored === null) return null; if (stored.purpose !== purpose) return null; if (Date.now() >= stored.exp) { await this.#state.storage.delete(key); return null; } await this.#state.storage.delete(key); return stored; }); } async #armAlarm(): Promise { const current = await this.#state.storage.getAlarm(); if (current !== null) return; await this.#state.storage.setAlarm(Date.now() + ALARM_INTERVAL_MS); } async alarm(): Promise { const [challenges, revocations] = await Promise.all([ this.#state.storage.list({ prefix: 'challenge:' }), this.#state.storage.list({ prefix: SESSION_REVOCATION_PREFIX, }), ]); const now = Date.now(); const expired: string[] = []; for (const [key, val] of challenges) { if (!Number.isFinite(val.exp) || val.exp <= now) expired.push(key); } for (const [key, val] of revocations) { if (!Number.isFinite(val.exp) || val.exp <= now) expired.push(key); } if (expired.length > 0) { await deleteStorageKeysBatched(this.#state.storage, expired); } const [remainingChallenges, remainingRevocations] = await Promise.all([ this.#state.storage.list({ prefix: 'challenge:', limit: 1 }), this.#state.storage.list({ prefix: SESSION_REVOCATION_PREFIX, limit: 1, }), ]); if (remainingChallenges.size > 0 || remainingRevocations.size > 0) { await this.#state.storage.setAlarm(Date.now() + ALARM_INTERVAL_MS); } } // ─── Registration ────────────────────────────────────────────────────── async #registerInit(config: AuthConfig): Promise { const uid = await this.#generateUid(); const options = await this.#ops.generateRegistrationOptions({ rpName: config.rpName, rpID: config.rpID, userName: uid, userID: new TextEncoder().encode(uid), authenticatorSelection: { residentKey: 'required', userVerification: 'required', }, }); const stored = await this.#storeChallenge(options.challenge, { purpose: 'register', uid, exp: Date.now() + CHALLENGE_TTL_MS, }); if (!stored) return challengeCapacityResponse(); return jsonResponse({ options, uid }); } async #registerComplete( request: Request, config: AuthConfig, ): Promise { const body = await readJsonObject(request); if ( body === null || !isJsonObject(body.response) || typeof body.uid !== 'string' || typeof body.challenge !== 'string' ) { return plainResponse('Missing response, uid, or challenge', 400); } const challengeRecord = await this.#consumeChallenge(body.challenge, 'register'); if (challengeRecord === null || challengeRecord.uid !== body.uid) { return plainResponse('Invalid or expired challenge', 400); } let verified: VerifiedRegistrationResponse; try { verified = await this.#ops.verifyRegistrationResponse({ response: body.response as unknown as RegistrationResponseJSON, expectedChallenge: body.challenge, expectedOrigin: config.origin, expectedRPID: config.rpID, requireUserVerification: true, }); } catch { return plainResponse('Registration verification failed', 400); } if (!verified.verified || !verified.registrationInfo) { return plainResponse('Registration verification failed', 400); } const info = verified.registrationInfo; const credentialID = info.credential.id; const existing = await this.#state.storage.get(`cred:${credentialID}`); if (existing !== undefined) { return plainResponse('Credential already registered', 409); } const cred: StoredCredential = { uid: body.uid, publicKey: info.credential.publicKey, counter: info.credential.counter, transports: info.credential.transports ?? [], deviceType: info.credentialDeviceType, backedUp: info.credentialBackedUp, }; await this.#state.storage.put(`cred:${credentialID}`, cred); const session = await this.#createSession(body.uid); return jsonResponse({ uid: body.uid, session }); } // ─── Authentication (usernameless) ────────────────────────────────────── async #loginInit(config: AuthConfig): Promise { const options = await this.#ops.generateAuthenticationOptions({ rpID: config.rpID, // No allowCredentials — discoverable/usernameless login userVerification: 'required', }); const stored = await this.#storeChallenge(options.challenge, { purpose: 'login', exp: Date.now() + CHALLENGE_TTL_MS, }); if (!stored) return challengeCapacityResponse(); return jsonResponse({ options }); } async #loginComplete( request: Request, config: AuthConfig, ): Promise { const body = await readJsonObject(request); if ( body === null || !isJsonObject(body.response) || typeof body.response.id !== 'string' || typeof body.challenge !== 'string' ) { return plainResponse('Missing response or challenge', 400); } const challengeRecord = await this.#consumeChallenge(body.challenge, 'login'); if (challengeRecord === null) { return plainResponse('Invalid or expired challenge', 401); } const credentialID = body.response.id; const cred = await this.#state.storage.get(`cred:${credentialID}`); if (cred === undefined || cred === null) { return plainResponse('Unknown credential', 401); } const webAuthnCred: WebAuthnCredential = { id: credentialID, publicKey: cred.publicKey, counter: cred.counter, transports: cred.transports, }; let verified: VerifiedAuthenticationResponse; try { verified = await this.#ops.verifyAuthenticationResponse({ response: body.response as unknown as AuthenticationResponseJSON, expectedChallenge: body.challenge, expectedOrigin: config.origin, expectedRPID: config.rpID, credential: webAuthnCred, requireUserVerification: true, }); } catch { return plainResponse('Authentication verification failed', 401); } if (!verified.verified) { return plainResponse('Authentication verification failed', 401); } // Replay-safe counter update: reject a newCounter that would regress // below the stored value (two concurrent logins can both verify // against counter 5, then write 7 followed by 6). The WebAuthn // all-zero counter case (authenticator doesn't track usage) is // allowed through. Also refresh deviceType/backedUp from the // verification result. const authInfo = verified.authenticationInfo; const counterAccepted = await this.#state.blockConcurrencyWhile(async () => { const fresh = await this.#state.storage.get(`cred:${credentialID}`); if (!fresh) return false; const validCounter = (fresh.counter === 0 && authInfo.newCounter === 0) || authInfo.newCounter > fresh.counter; if (!validCounter) return false; fresh.counter = authInfo.newCounter; fresh.deviceType = authInfo.credentialDeviceType; fresh.backedUp = authInfo.credentialBackedUp; await this.#state.storage.put(`cred:${credentialID}`, fresh); return true; }); if (!counterAccepted) { return plainResponse('Authentication counter rejected', 401); } const session = await this.#createSession(cred.uid); return jsonResponse({ uid: cred.uid, session }); } async #createSession(uid: string): Promise { const now = Date.now(); return this.#mintSession({ uid, iat: now, exp: now + SESSION_TTL_MS }); } async #mintSession(payload: SessionPayload): Promise { const secret = await this.#getSecret(); const mac = await hmacHex(secret, JSON.stringify(payload)); return encodeSession(payload, mac); } // ─── Session verification ────────────────────────────────────────────── async #revokeSession(request: Request): Promise { const body = await readJsonObject(request); if (body === null || typeof body.session !== 'string') { return new Response(null, { status: 204 }); } const secret = await this.#getSecret(); const payload = await decodeSession(body.session, secret); if (payload === null) return new Response(null, { status: 204 }); await this.#state.blockConcurrencyWhile(async () => { const key = `${SESSION_REVOCATION_PREFIX}${payload.uid}`; const current = await this.#state.storage.get(key); const now = Date.now(); const before = current && Number.isFinite(current.before) ? Math.max(current.before, now) : now; const exp = current && Number.isFinite(current.exp) ? Math.max(current.exp, now + SESSION_TTL_MS) : now + SESSION_TTL_MS; await this.#state.storage.put(key, { before, exp }); await this.#armAlarm(); }); return new Response(null, { status: 204 }); } /** * Decode a session and apply logout revocation. Revocation compares the * chain's authentication time, not `iat`, so a token renewed after an * earlier login cannot outlive a later logout of that login. */ async #liveSession(session: string): Promise { const secret = await this.#getSecret(); const payload = await decodeSession(session, secret); if (payload === null) return null; const revocation = await this.#state.storage.get( `${SESSION_REVOCATION_PREFIX}${payload.uid}`, ); if ( revocation !== undefined && (!Number.isFinite(revocation.before) || !Number.isFinite(revocation.exp) || authenticatedAt(payload) <= revocation.before) ) { return null; } return payload; } async #verifySession(request: Request): Promise { const body = await readJsonObject(request); if (body === null || typeof body.session !== 'string') { return plainResponse('Missing session', 400); } const payload = await this.#liveSession(body.session); if (payload === null) { return plainResponse('Invalid session', 401); } return jsonResponse({ uid: payload.uid, exp: payload.exp }); } /** * Verify a session and, at most once a day, re-issue it with a fresh * lifetime capped at SESSION_MAX_LIFETIME_MS after its passkey ceremony. * The old token stays valid until its own `exp`; open sockets roll over * to the new one when the client reconnects. */ async #refreshSession(request: Request): Promise { const body = await readJsonObject(request); if (body === null || typeof body.session !== 'string') { // Stryker disable next-line StringLiteral : refreshAuthSession branches on status only; the body is not a contract return plainResponse('Missing session', 400); } const payload = await this.#liveSession(body.session); if (payload === null) { // Stryker disable next-line StringLiteral : refreshAuthSession branches on status only; the body is not a contract return plainResponse('Invalid session', 401); } const now = Date.now(); const auth = authenticatedAt(payload); const exp = Math.min(now + SESSION_TTL_MS, auth + SESSION_MAX_LIFETIME_MS); if (now - payload.iat < SESSION_RENEW_AFTER_MS || exp <= payload.exp) { return jsonResponse({ uid: payload.uid, exp: payload.exp }); } const session = await this.#mintSession({ uid: payload.uid, iat: now, exp, auth }); return jsonResponse({ uid: payload.uid, exp, session }); } }