mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
341 lines
9.1 KiB
TypeScript
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
|
|
};
|