Files
appointment-booking-software/src/lib/utils/passkey.ts
T

341 lines
9.1 KiB
TypeScript

import logger from "$lib/logger";
type WebAuthnAllowCredential = {
id: string;
type: "public-key";
transports?: AuthenticatorTransport[];
};
export const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
try {
const bytes = new Uint8Array(buffer);
let binary = "";
// Simple loop, no fancy operations
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
} catch (error) {
logger.error("Failed to convert ArrayBuffer to Base64", {
error,
notes:
"If you see this error locally, the best way to use passkeys is Chromium as of writing this",
});
throw error;
}
};
export const base64UrlToArrayBuffer = (base64url: string) => {
// Replace URL-safe characters and add padding
const base64 = base64url
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(base64url.length + ((4 - (base64url.length % 4)) % 4), "=");
return base64ToArrayBuffer(base64);
};
export function base64ToArrayBuffer(base64: string) {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
export const fetchChallenge = async (email: string) => {
const resp = await fetch("/api/auth/challenge", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
});
let data;
try {
data = await resp.json();
} catch (error) {
logger.error("Failed to parse challenge response", { email, error });
return null;
}
// Handle throttling
if (resp.status === 429) {
const retryAfterSeconds = Math.ceil((data.retryAfterMs || 60000) / 1000);
logger.warn("Challenge request throttled", {
email,
retryAfterSeconds,
});
if (retryAfterSeconds > 0) {
throw new Error(
`Too many failed attempts. Please try again in ${retryAfterSeconds} seconds.`,
);
} else {
throw new Error("Too many failed attempts. Please try again later.");
}
}
if (!resp.ok) {
logger.error("Failed to fetch challenge", { email, status: resp.status });
return null;
}
return {
id: data.rpId,
challenge: data.challenge,
allowCredentials: data.allowCredentials,
};
};
export const getCredentialOptions = ({
id,
challenge,
email,
enablePRF = false,
}: {
id: string;
challenge: string;
email: string;
enablePRF?: boolean;
}): {
publicKey: PublicKeyCredentialCreationOptions;
} => {
const options: {
publicKey: PublicKeyCredentialCreationOptions;
} = {
publicKey: {
challenge: base64UrlToArrayBuffer(challenge),
rp: {
id,
name: "Open Reception",
},
user: {
id: new Uint8Array(16),
name: email,
displayName: email,
},
pubKeyCredParams: [
{ alg: -7, type: "public-key" }, // ES256
],
authenticatorSelection: {
authenticatorAttachment: undefined, // Allow any authenticator (platform or cross-platform)
residentKey: "preferred",
userVerification: "preferred",
},
},
};
// Enable PRF extension for zero-knowledge key derivation
if (enablePRF) {
options.publicKey.extensions = {
prf: {},
};
}
return options;
};
export type GeneratePasskeyResponse = {
response: AuthenticatorAttestationResponse;
id: string;
getClientExtensionResults: () => { deviceName?: string; prf?: { enabled?: boolean } };
} | null;
export const generatePasskey = async ({
id,
challenge,
email,
enablePRF = false,
}: {
id: string;
challenge: string;
email: string;
enablePRF?: boolean;
}): Promise<GeneratePasskeyResponse> => {
const options = getCredentialOptions({ id, challenge, email, enablePRF });
return (await navigator.credentials.create(options)) as GeneratePasskeyResponse;
};
/**
* Get PRF output from a passkey immediately after registration
*
* SECURITY: This must be called immediately after passkey creation to derive
* the secret shard for zero-knowledge key splitting.
*
* MULTI-PASSKEY SUPPORT: Uses email as PRF salt so that all passkeys for the same user
* can be used interchangeably. Each passkey will produce a different PRF output for
* the same salt, allowing each to have its own database shard.
*
* @param passkeyId - The credential ID of the newly created passkey
* @param rpId - Relying Party ID (domain)
* @param challengeBase64 - Fresh challenge from server (base64url encoded)
* @param email - User's email address (used as PRF salt for multi-passkey support)
* @returns PRF output (32 bytes) or null if PRF is not supported
* @throws Error if passkey doesn't support PRF or authentication fails
*/
export const getPRFOutputAfterRegistration = async ({
passkeyId,
rpId,
challengeBase64,
email,
}: {
passkeyId: string;
rpId: string;
challengeBase64: string;
email: string;
}): Promise<ArrayBuffer> => {
// Prepare PRF salt (use email as deterministic salt for multi-passkey support)
const prfSalt = new TextEncoder().encode(`open-reception-prf:${email}`);
// Perform WebAuthn get() with PRF extension
const credential = (await navigator.credentials.get({
publicKey: {
challenge: base64UrlToArrayBuffer(challengeBase64),
rpId: rpId,
allowCredentials: [
{
id: base64UrlToArrayBuffer(passkeyId),
type: "public-key",
},
],
userVerification: "required",
extensions: {
prf: {
eval: {
first: prfSalt,
},
},
},
},
})) as PublicKeyCredential & {
getClientExtensionResults: () => {
prf?: {
enabled?: boolean;
results?: {
first?: ArrayBuffer;
};
};
};
};
if (!credential) {
throw new Error("Failed to authenticate with new passkey");
}
// Extract PRF output
const extensionResults = credential.getClientExtensionResults();
const prfResults = extensionResults.prf;
if (!prfResults || !prfResults.results || !prfResults.results.first) {
throw new Error(
"PRF extension not supported by this passkey. " +
"Please use a modern authenticator (YubiKey 5.2.3+, Titan Gen2, Windows Hello, Touch ID, or Android)",
);
}
// PRF output is BufferSource, ensure we return ArrayBuffer
const prfOutput = prfResults.results.first;
if (prfOutput instanceof ArrayBuffer) {
return prfOutput;
} else {
// Convert ArrayBufferView to ArrayBuffer
return prfOutput.buffer.slice(
prfOutput.byteOffset,
prfOutput.byteOffset + prfOutput.byteLength,
) as ArrayBuffer;
}
};
export type GetCredentialResponse = PublicKeyCredential & {
prfOutput?: ArrayBuffer; // PRF output if enablePRF was true
};
export const getCredential = async ({
id,
challenge,
email,
enablePRF = false,
allowCredentials,
}: {
id: string;
challenge: string;
email: string;
enablePRF?: boolean;
allowCredentials?: WebAuthnAllowCredential[];
}): Promise<GetCredentialResponse> => {
// Build WebAuthn options manually to support PRF
const challengeBuffer = base64UrlToArrayBuffer(challenge);
const publicKeyOptions: PublicKeyCredentialRequestOptions = {
challenge: challengeBuffer,
rpId: id,
userVerification: "preferred",
allowCredentials: allowCredentials?.map((credential) => ({
id: base64UrlToArrayBuffer(credential.id),
type: credential.type,
transports: credential.transports,
})),
};
// Add PRF extension if enabled
// MULTI-PASSKEY SUPPORT: Use email as salt so all passkeys for the same user work
if (enablePRF) {
const prfSalt = new TextEncoder().encode(`open-reception-prf:${email}`);
publicKeyOptions.extensions = {
prf: {
eval: {
first: prfSalt,
},
},
};
}
const credential = (await navigator.credentials.get({
publicKey: publicKeyOptions,
})) as PublicKeyCredential;
if (!credential) {
throw new Error("Failed to get credential");
}
let prfOutput: ArrayBuffer | undefined;
// Extract PRF output if it was enabled
if (enablePRF) {
const extensionResults = credential.getClientExtensionResults() as {
prf?: {
enabled?: boolean;
results?: {
first?: BufferSource;
};
};
};
if (extensionResults.prf?.results?.first) {
const prfResult = extensionResults.prf.results.first;
// Convert BufferSource to ArrayBuffer
if (prfResult instanceof ArrayBuffer) {
prfOutput = prfResult;
} else {
prfOutput = prfResult.buffer.slice(
prfResult.byteOffset,
prfResult.byteOffset + prfResult.byteLength,
) as ArrayBuffer;
}
}
}
// Return credential with optional prfOutput attached
return Object.assign(credential, { prfOutput }) as GetCredentialResponse;
};
export const getCounterFromAuthenticatorData = (authenticatorData: ArrayBuffer) => {
const view = new DataView(authenticatorData);
// Counter is at offset 33, 4 bytes, big-endian
return view.getUint32(33, false); // false = big-endian
};