Merge pull request #18 from mrteye/feat-webauthn-prf-8747559092824518798

feat: WebAuthn PRF Extension for KEK Derivation
This commit is contained in:
Tyler Gillispie 2026-08-24 00:46:51 -07:00 committed by GitHub
commit 4ed20be4a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 201 additions and 7 deletions

View File

@ -200,10 +200,21 @@ export async function initDb(): Promise<void> {
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,

View File

@ -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;
}
});

View File

@ -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, {

View File

@ -34,6 +34,21 @@ export const LoginPage = () => {
</ul>
</div>
<div style={{ marginBottom: "1rem" }}>
<input
type="text"
id="loginUsername"
placeholder="Username (optional for passkeys)"
style={{
padding: "0.5rem",
width: "100%",
maxWidth: "300px",
borderRadius: "4px",
border: "1px solid #ccc"
}}
/>
</div>
<button
type="button"
id="loginBtn"
@ -90,7 +105,8 @@ export const LoginPage = () => {
document.getElementById('statusMessage').textContent = '';
try {
await startWebAuthnLogin();
const username = document.getElementById('loginUsername').value;
await startWebAuthnLogin(username);
} finally {
document.getElementById('loadingIndicator').style.display = 'none';
document.getElementById('loginBtn').disabled = false;

View File

@ -53,6 +53,14 @@ async function startWebAuthnRegistration(username, inviteCode) {
throw error;
}
// Extract PRF client extension result
let extensionResults;
if (typeof attResp.getClientExtensionResults === "function") {
extensionResults = attResp.getClientExtensionResults();
} else {
extensionResults = attResp.clientExtensionResults || {};
}
// 3. Send response back to verify
const verificationResp = await fetch("/api/register/verify", {
method: "POST",
@ -62,7 +70,10 @@ async function startWebAuthnRegistration(username, inviteCode) {
body: JSON.stringify({
username,
inviteCode,
response: attResp,
response: {
...attResp,
clientExtensionResults: extensionResults,
},
}),
});
@ -94,13 +105,17 @@ async function startWebAuthnRegistration(username, inviteCode) {
}
}
async function startWebAuthnLogin() {
async function startWebAuthnLogin(username) {
setStatus("");
try {
// 1. Fetch challenge
const resp = await fetch("/api/login/challenge", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username: username || "" }),
});
let data;
@ -126,6 +141,60 @@ async function startWebAuthnLogin() {
throw error;
}
// Extract PRF extension results
let extensionResults;
if (typeof asseResp.getClientExtensionResults === "function") {
extensionResults = asseResp.getClientExtensionResults();
} else {
extensionResults = asseResp.clientExtensionResults || {};
}
let kekDerived = false;
if (extensionResults?.prf?.results?.first) {
try {
// Base64Url decode the string into Uint8Array
const base64UrlString = extensionResults.prf.results.first;
const base64 = base64UrlString.replace(/-/g, "+").replace(/_/g, "/");
const binaryString = atob(base64);
const prfOutput = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
prfOutput[i] = binaryString.charCodeAt(i);
}
const salt = new Uint8Array(32); // 32 byte salt for HKDF
const info = new TextEncoder().encode("auth-yes:prf:device-share:v1");
const ikm = await crypto.subtle.importKey(
"raw",
prfOutput,
{ name: "HKDF" },
false,
["deriveKey"],
);
const kek = await crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: salt,
info: info,
},
ikm,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
console.log("WebAuthn PRF extension KEK derived successfully");
kekDerived = true;
} catch (err) {
console.error("Failed to derive KEK from PRF output:", err);
}
} else {
console.log(
"WebAuthn PRF extension not supported or no output returned. Proceeding with standard authentication.",
);
}
// 3. Send response back to verify
const verificationResp = await fetch("/api/login/verify", {
method: "POST",