import { env } from "cloudflare:workers"; import { accountDb } from "./scoped-db"; import { locate, registerLocator } from "./scoped-db/locators"; import { badRequest, conflict } from "./errors"; import { assertBelowCap, CAPS } from "./limits"; import { consumeAuthToken, storeAuthToken } from "./auth-tokens"; import { SUPPORTED_ALGORITHMS, verifyAssertion, verifyRegistration } from "./webauthn"; import type { RelyingParty } from "./webauthn"; import { toBase64Url } from "~/lib/base64url"; import type { Viewer } from "~/lib/types"; const CHALLENGE_TTL_SECONDS = 5 * 60; const CHALLENGE_BYTES = 32; const CEREMONY_TIMEOUT_MS = 120_000; type Purpose = "register" | "login" | "second-factor"; export type PasskeyCreationOptions = { challenge: string; rp: { id: string; name: string }; user: { id: string; name: string; displayName: string }; pubKeyCredParams: { type: "public-key"; alg: number }[]; excludeCredentials: { type: "public-key"; id: string }[]; timeout: number; }; export type PasskeyRequestOptions = { challenge: string; rpId: string; allowCredentials: { type: "public-key"; id: string }[]; timeout: number; }; export type RegistrationPayload = { challenge: string; clientDataJSON: string; attestationObject: string; transports: string[]; }; export type AssertionPayload = { challenge: string; credentialId: string; clientDataJSON: string; authenticatorData: string; signature: string; }; type PasskeyRow = { id: string; user_id: string; public_key: string; algorithm: number; sign_count: number; }; function relyingParty(): RelyingParty { const url = new URL(env.PUBLIC_URL); return { id: url.hostname, origin: url.origin }; } async function issueChallenge(purpose: Purpose, userId: string | null): Promise { const challenge = toBase64Url(crypto.getRandomValues(new Uint8Array(CHALLENGE_BYTES))); await storeAuthToken("webauthn", challenge, userId, purpose, CHALLENGE_TTL_SECONDS); return challenge; } /** Challenges are single-use; the atomic delete stops concurrent replays of one assertion. */ async function consumeChallenge(challenge: string, purpose: Purpose, userId: string | null): Promise { const record = await consumeAuthToken("webauthn", challenge); if (!record || record.payload !== purpose || record.userId !== userId) { throw badRequest("Passkey challenge expired; try again"); } } async function credentialDescriptors(userId: string): Promise<{ type: "public-key"; id: string }[]> { const rows = await accountDb(userId).all<{ id: string }>("SELECT id FROM passkeys WHERE user_id = ?", userId); return rows.map((row) => ({ type: "public-key", id: row.id })); } export async function passkeyCreationOptions(viewer: Viewer): Promise { const rp = relyingParty(); return { challenge: await issueChallenge("register", viewer.id), rp: { id: rp.id, name: env.FORGE_NAME || "gitfor" }, user: { id: toBase64Url(new TextEncoder().encode(viewer.id)), name: viewer.name, displayName: viewer.displayName ?? viewer.name, }, pubKeyCredParams: SUPPORTED_ALGORITHMS.map((alg) => ({ type: "public-key", alg })), excludeCredentials: await credentialDescriptors(viewer.id), timeout: CEREMONY_TIMEOUT_MS, }; } export async function registerPasskey(viewer: Viewer, name: string, payload: RegistrationPayload): Promise { await consumeChallenge(payload.challenge, "register", viewer.id); const db = accountDb(viewer.id); const count = await db.first<{ count: number }>( "SELECT COUNT(*) AS count FROM passkeys WHERE user_id = ?", viewer.id, ); assertBelowCap(count?.count ?? 0, CAPS.passkeysPerUser, "passkeys"); const verified = await verifyRegistration(payload, payload.challenge, relyingParty()).catch((error: unknown) => { throw badRequest(error instanceof Error ? error.message : "Invalid passkey"); }); // Credential ids are unique across accounts; the locator is the global index. if (await locate("passkey", verified.credentialId)) { throw conflict("This passkey is already registered"); } await db.run( "INSERT INTO passkeys (id, user_id, name, public_key, algorithm, sign_count, transports, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", verified.credentialId, viewer.id, name, JSON.stringify(verified.publicKeyJwk), verified.algorithm, verified.signCount, JSON.stringify(payload.transports.slice(0, 8)), Date.now(), ); await registerLocator("passkey", verified.credentialId, viewer.id); } /** `userId` null means passwordless sign-in with a discoverable credential. */ export async function passkeyRequestOptions(userId: string | null): Promise { return { challenge: await issueChallenge(userId ? "second-factor" : "login", userId), rpId: relyingParty().id, allowCredentials: userId ? await credentialDescriptors(userId) : [], timeout: CEREMONY_TIMEOUT_MS, }; } /** Verifies an assertion and returns the account it belongs to. */ export async function verifyPasskeyAssertion(payload: AssertionPayload, userId: string | null): Promise { await consumeChallenge(payload.challenge, userId ? "second-factor" : "login", userId); const holder = userId ?? (await locate("passkey", payload.credentialId)); if (!holder) throw badRequest("Unknown passkey"); const db = accountDb(holder); const row = await db.first( "SELECT id, user_id, public_key, algorithm, sign_count FROM passkeys WHERE id = ?", payload.credentialId, ); if (!row || (userId !== null && row.user_id !== userId)) throw badRequest("Unknown passkey"); const result = await verifyAssertion( payload, { publicKeyJwk: JSON.parse(row.public_key), algorithm: row.algorithm, signCount: row.sign_count }, payload.challenge, relyingParty(), ).catch((error: unknown) => { throw badRequest(error instanceof Error ? error.message : "Passkey verification failed"); }); if (userId === null && !result.userVerified) throw badRequest("Passwordless sign-in requires user verification"); // AUTH-P12: conditional on the counter still being lower, so two concurrent uses of one assertion cannot both pass. // Authenticators that never count (always 0) are exempt, as in verifyAssertion. const updated = await db.run( "UPDATE passkeys SET sign_count = ?1, last_used_at = ?2 WHERE id = ?3 AND (sign_count < ?1 OR (?1 = 0 AND sign_count = 0))", result.signCount, Date.now(), row.id, ); if (updated.meta.changes === 0) throw badRequest("This passkey assertion was already used"); return row.user_id; }