* 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>
233 lines
6.3 KiB
JavaScript
233 lines
6.3 KiB
JavaScript
// deno-lint-ignore-file
|
|
const { startRegistration, startAuthentication } = SimpleWebAuthnBrowser;
|
|
|
|
function setStatus(msg, isError = false) {
|
|
const el = document.getElementById("statusMessage");
|
|
if (el) {
|
|
el.textContent = msg;
|
|
el.className = isError ? "error" : "success";
|
|
}
|
|
}
|
|
|
|
async function startWebAuthnRegistration(username, inviteCode) {
|
|
setStatus("");
|
|
if (!username || !inviteCode) {
|
|
setStatus("Username and Invite Code are required.", true);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 1. Fetch challenge from API
|
|
const resp = await fetch("/api/register/challenge", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ username, inviteCode }),
|
|
});
|
|
|
|
let data;
|
|
try {
|
|
data = await resp.json();
|
|
} catch {
|
|
const text = await resp.text().catch(() => "");
|
|
setStatus(`Challenge request failed (${resp.status}): ${text}`, true);
|
|
return;
|
|
}
|
|
|
|
if (!resp.ok) {
|
|
setStatus(data.error || "Failed to get registration challenge", true);
|
|
return;
|
|
}
|
|
|
|
// 2. Pass challenge to authenticator
|
|
let attResp;
|
|
try {
|
|
attResp = await startRegistration({ optionsJSON: data.options });
|
|
} catch (error) {
|
|
if (error.name === "InvalidStateError") {
|
|
setStatus("Authenticator was probably already registered.", true);
|
|
} else {
|
|
setStatus(error.message || "Registration failed on device", true);
|
|
}
|
|
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",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
username,
|
|
inviteCode,
|
|
response: {
|
|
...attResp,
|
|
clientExtensionResults: extensionResults,
|
|
},
|
|
}),
|
|
});
|
|
|
|
let verificationJSON;
|
|
try {
|
|
verificationJSON = await verificationResp.json();
|
|
} catch {
|
|
const text = await verificationResp.text().catch(() => "");
|
|
setStatus(
|
|
`Verification failed (${verificationResp.status}): ${text}`,
|
|
true,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (verificationJSON.success) {
|
|
setStatus("Registration successful! You can now log in.");
|
|
setTimeout(() => {
|
|
globalThis.location.href = "/login";
|
|
}, 2000);
|
|
} else {
|
|
setStatus(
|
|
verificationJSON.error || "Registration verification failed",
|
|
true,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
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;
|
|
try {
|
|
data = await resp.json();
|
|
} catch {
|
|
const text = await resp.text().catch(() => "");
|
|
setStatus(`Login challenge failed (${resp.status}): ${text}`, true);
|
|
return;
|
|
}
|
|
|
|
if (!resp.ok) {
|
|
setStatus(data.error || "Failed to get login challenge", true);
|
|
return;
|
|
}
|
|
|
|
// 2. Pass challenge to authenticator
|
|
let asseResp;
|
|
try {
|
|
asseResp = await startAuthentication({ optionsJSON: data.options });
|
|
} catch (error) {
|
|
setStatus(error.message || "Authentication failed on device", true);
|
|
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",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
response: asseResp,
|
|
}),
|
|
});
|
|
|
|
let verificationJSON;
|
|
try {
|
|
verificationJSON = await verificationResp.json();
|
|
} catch {
|
|
const text = await verificationResp.text().catch(() => "");
|
|
setStatus(
|
|
`Login verification failed (${verificationResp.status}): ${text}`,
|
|
true,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (verificationJSON.success) {
|
|
setStatus("Login successful! Redirecting...");
|
|
setTimeout(() => {
|
|
globalThis.location.href = "/dashboard";
|
|
}, 1000);
|
|
} else {
|
|
setStatus(verificationJSON.error || "Login verification failed", true);
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
}
|