feat: implement WebAuthn PRF extension for client-side HKDF key derivation
* Added `prf_enabled` and `prf_salt` columns to the `passkeys` table. * Updated registration API endpoints to request and store the PRF extension capability and generate a secure salt. * Updated the login API endpoints to map stored PRF salts into the `evalByCredential` array for the WebAuthn challenge. * Enhanced the client-side WebAuthn SDK (`auth-client.js`) to extract the PRF Base64URL string output, decode it into a `Uint8Array`, and securely derive a 256-bit AES-GCM Key Encryption Key (KEK) via `crypto.subtle.deriveKey` using the `auth-yes:prf:device-share:v1` info string. * Implemented graceful fallbacks throughout the stack to ensure registration and standard logins proceed if PRF is unsupported. * Added corresponding unit tests to verify PRF flow and rejection logic. * Verified visual and functional changes for the optional username input on the login page via Playwright scripts. Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
This commit is contained in:
parent
b34475b4fb
commit
e1555f14fc
13
server/db.ts
13
server/db.ts
@ -183,10 +183,21 @@ export async function initDb(): Promise<void> {
|
|||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
credential_id TEXT UNIQUE NOT NULL,
|
credential_id TEXT UNIQUE NOT NULL,
|
||||||
public_key TEXT 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`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|||||||
@ -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 { stub } from "jsr:@std/testing/mock";
|
||||||
import { app } from "./main.ts";
|
import { app } from "./main.ts";
|
||||||
import { sqlWrapper } from "./db.ts";
|
import { sqlWrapper } from "./db.ts";
|
||||||
@ -327,3 +327,61 @@ Deno.test("Phase 4: Audit Ledger Verification - Login failed", async () => {
|
|||||||
restoreMockSql();
|
restoreMockSql();
|
||||||
auditWrapper.auditLog = originalAudit;
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -313,6 +313,9 @@ app.post("/api/register/challenge", async (c) => {
|
|||||||
userVerification: "preferred",
|
userVerification: "preferred",
|
||||||
},
|
},
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
|
extensions: {
|
||||||
|
["prf" as string]: {},
|
||||||
|
} as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
setCookie(c, "expected_registration_challenge", options.challenge, {
|
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),
|
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
|
// Validate invite code at verification time to prevent race conditions
|
||||||
const invite = await sqlWrapper
|
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()`
|
.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];
|
user = insertRes[0];
|
||||||
|
|
||||||
await sqlWrapper.sql`
|
await sqlWrapper.sql`
|
||||||
INSERT INTO passkeys (user_id, credential_id, public_key, counter)
|
INSERT INTO passkeys (user_id, credential_id, public_key, counter, prf_enabled, prf_salt)
|
||||||
VALUES (${user.id}, ${base64CredentialID}, ${base64PublicKey}, ${counter})
|
VALUES (${user.id}, ${base64CredentialID}, ${base64PublicKey}, ${counter}, ${prfEnabled}, ${prfSalt})
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await sqlWrapper.sql`
|
await sqlWrapper.sql`
|
||||||
@ -555,10 +565,40 @@ app.post("/api/register/verify", async (c) => {
|
|||||||
|
|
||||||
// Start a WebAuthn authentication ceremony
|
// Start a WebAuthn authentication ceremony
|
||||||
app.post("/api/login/challenge", async (c) => {
|
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({
|
const options = await generateAuthenticationOptions({
|
||||||
rpID,
|
rpID,
|
||||||
userVerification: "preferred",
|
userVerification: "preferred",
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
|
extensions,
|
||||||
});
|
});
|
||||||
|
|
||||||
setCookie(c, "expected_authentication_challenge", options.challenge, {
|
setCookie(c, "expected_authentication_challenge", options.challenge, {
|
||||||
|
|||||||
@ -34,6 +34,21 @@ export const LoginPage = () => {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="loginBtn"
|
id="loginBtn"
|
||||||
@ -90,7 +105,8 @@ export const LoginPage = () => {
|
|||||||
document.getElementById('statusMessage').textContent = '';
|
document.getElementById('statusMessage').textContent = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await startWebAuthnLogin();
|
const username = document.getElementById('loginUsername').value;
|
||||||
|
await startWebAuthnLogin(username);
|
||||||
} finally {
|
} finally {
|
||||||
document.getElementById('loadingIndicator').style.display = 'none';
|
document.getElementById('loadingIndicator').style.display = 'none';
|
||||||
document.getElementById('loginBtn').disabled = false;
|
document.getElementById('loginBtn').disabled = false;
|
||||||
|
|||||||
@ -53,6 +53,14 @@ async function startWebAuthnRegistration(username, inviteCode) {
|
|||||||
throw error;
|
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
|
// 3. Send response back to verify
|
||||||
const verificationResp = await fetch("/api/register/verify", {
|
const verificationResp = await fetch("/api/register/verify", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -62,7 +70,10 @@ async function startWebAuthnRegistration(username, inviteCode) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username,
|
username,
|
||||||
inviteCode,
|
inviteCode,
|
||||||
response: attResp,
|
response: {
|
||||||
|
...attResp,
|
||||||
|
clientExtensionResults: extensionResults,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -94,13 +105,17 @@ async function startWebAuthnRegistration(username, inviteCode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startWebAuthnLogin() {
|
async function startWebAuthnLogin(username) {
|
||||||
setStatus("");
|
setStatus("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Fetch challenge
|
// 1. Fetch challenge
|
||||||
const resp = await fetch("/api/login/challenge", {
|
const resp = await fetch("/api/login/challenge", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username: username || "" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
let data;
|
let data;
|
||||||
@ -126,6 +141,60 @@ async function startWebAuthnLogin() {
|
|||||||
throw error;
|
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
|
// 3. Send response back to verify
|
||||||
const verificationResp = await fetch("/api/login/verify", {
|
const verificationResp = await fetch("/api/login/verify", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user