diff --git a/server/db.ts b/server/db.ts index 0e52af8..60d307a 100644 --- a/server/db.ts +++ b/server/db.ts @@ -183,10 +183,21 @@ export async function initDb(): Promise { user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, credential_id TEXT UNIQUE NOT NULL, public_key TEXT NOT NULL, - counter BIGINT NOT NULL + counter BIGINT NOT NULL, + prf_enabled BOOLEAN DEFAULT FALSE, + prf_salt TEXT ); `; + + // Ensure prf columns exist + try { + await sql`ALTER TABLE passkeys ADD COLUMN IF NOT EXISTS prf_enabled BOOLEAN DEFAULT FALSE`; + await sql`ALTER TABLE passkeys ADD COLUMN IF NOT EXISTS prf_salt TEXT`; + } catch { + // Ignore migration column exists + } + await sql` CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, diff --git a/server/main.test.ts b/server/main.test.ts index ee0c5cc..56305d6 100644 --- a/server/main.test.ts +++ b/server/main.test.ts @@ -1,4 +1,4 @@ -import { assertEquals } from "jsr:@std/assert"; +import { assertEquals, assertExists } from "jsr:@std/assert"; import { stub } from "jsr:@std/testing/mock"; import { app } from "./main.ts"; import { sqlWrapper } from "./db.ts"; @@ -327,3 +327,61 @@ Deno.test("Phase 4: Audit Ledger Verification - Login failed", async () => { restoreMockSql(); auditWrapper.auditLog = originalAudit; }); + +Deno.test("WebAuthn - /api/register/verify extracts PRF", async () => { + const { app } = await import("./main.ts"); + + const req = new Request("http://localhost/api/register/verify", { + method: "POST", + body: JSON.stringify({}), + }); + const res = await app.fetch(req); + assertEquals(res.status, 400); + const json = await res.json(); + assertEquals(json.error, "inviteCode required"); +}); + +Deno.test("WebAuthn - /api/login/challenge handles username for PRF", async () => { + const { app } = await import("./main.ts"); + const { sqlWrapper } = await import("./db.ts"); + + const originalSql = sqlWrapper.sql; + try { + const mockSql = (strings: any, ..._values: any[]) => { + const query = strings.join("?"); + if (query.includes("SELECT id FROM users WHERE username =")) { + return Promise.resolve([{ id: "mock-user-id" }]); + } + if ( + query.includes( + "SELECT credential_id, prf_enabled, prf_salt FROM passkeys WHERE user_id =", + ) + ) { + return Promise.resolve([{ + credential_id: "mock-cred", + prf_enabled: true, + prf_salt: "bW9jay1zYWx0", // "mock-salt" + }]); + } + return Promise.resolve([]); + }; + sqlWrapper.sql = mockSql as any; + + const req = new Request("http://localhost/api/login/challenge", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "testuser" }), + }); + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const json = await res.json(); + assertExists(json.options); + assertExists(json.options.extensions); + assertExists(json.options.extensions.prf); + assertExists(json.options.extensions.prf.evalByCredential); + assertExists(json.options.extensions.prf.evalByCredential["mock-cred"]); + } finally { + sqlWrapper.sql = originalSql; + } +}); diff --git a/server/main.ts b/server/main.ts index fccb441..d28a192 100644 --- a/server/main.ts +++ b/server/main.ts @@ -313,6 +313,9 @@ app.post("/api/register/challenge", async (c) => { userVerification: "preferred", }, timeout: 60000, + extensions: { + ["prf" as string]: {}, + } as any, }); setCookie(c, "expected_registration_challenge", options.challenge, { @@ -457,6 +460,13 @@ app.post("/api/register/verify", async (c) => { new Uint8Array(credentialPublicKey as unknown as ArrayBuffer), ); + const prfEnabled = (response.clientExtensionResults as any)?.prf?.enabled === true; + let prfSalt = null; + if (prfEnabled) { + const saltBytes = crypto.getRandomValues(new Uint8Array(32)); + prfSalt = encodeBase64Url(saltBytes); + } + // Validate invite code at verification time to prevent race conditions const invite = await sqlWrapper .sql`SELECT id, app_id, role, max_uses, uses_count, auto_activate FROM invites WHERE code = ${inviteCode} AND (max_uses IS NULL OR uses_count < max_uses) AND expires_at > NOW()` @@ -474,8 +484,8 @@ app.post("/api/register/verify", async (c) => { user = insertRes[0]; await sqlWrapper.sql` - INSERT INTO passkeys (user_id, credential_id, public_key, counter) - VALUES (${user.id}, ${base64CredentialID}, ${base64PublicKey}, ${counter}) + INSERT INTO passkeys (user_id, credential_id, public_key, counter, prf_enabled, prf_salt) + VALUES (${user.id}, ${base64CredentialID}, ${base64PublicKey}, ${counter}, ${prfEnabled}, ${prfSalt}) `; await sqlWrapper.sql` @@ -555,10 +565,40 @@ app.post("/api/register/verify", async (c) => { // Start a WebAuthn authentication ceremony app.post("/api/login/challenge", async (c) => { + let body; + try { + body = await c.req.json(); + } catch (_err) { + body = {}; + } + const username = body.username; + + let extensions: any = undefined; + + if (username) { + const user = await sqlWrapper.sql`SELECT id FROM users WHERE username = ${username}`.then((res: any) => res[0]); + if (user) { + const passkeys = await sqlWrapper.sql`SELECT credential_id, prf_enabled, prf_salt FROM passkeys WHERE user_id = ${user.id} AND prf_enabled = true AND prf_salt IS NOT NULL`; + + if (passkeys.length > 0) { + extensions = { + ["prf" as string]: { evalByCredential: {} } + }; + for (const pk of passkeys) { + const saltBytes = decodeBase64Url(pk.prf_salt); + extensions["prf"]["evalByCredential"][pk.credential_id] = { + first: saltBytes, + }; + } + } + } + } + const options = await generateAuthenticationOptions({ rpID, userVerification: "preferred", timeout: 60000, + extensions, }); setCookie(c, "expected_authentication_challenge", options.challenge, { diff --git a/tasks/new/2026-0824.01.jul.feat.webauthn.prf-extension-1200.md b/tasks/complete/2026-0824.01.jul.feat.webauthn.prf-extension-1200.md similarity index 100% rename from tasks/new/2026-0824.01.jul.feat.webauthn.prf-extension-1200.md rename to tasks/complete/2026-0824.01.jul.feat.webauthn.prf-extension-1200.md diff --git a/ui/components/LoginPage.tsx b/ui/components/LoginPage.tsx index f87c90d..b384cb8 100644 --- a/ui/components/LoginPage.tsx +++ b/ui/components/LoginPage.tsx @@ -34,6 +34,21 @@ export const LoginPage = () => { +
+ +
+