Merge pull request #18 from mrteye/feat-webauthn-prf-8747559092824518798
feat: WebAuthn PRF Extension for KEK Derivation
This commit is contained in:
commit
4ed20be4a2
13
server/db.ts
13
server/db.ts
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user