From e384eb2865508a4bfd5fea1d9ae19f408b2ba1af Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Thu, 30 Jul 2026 20:33:05 +0200 Subject: [PATCH] Allow dashboard users to add passkey -wip --- project.inlang/messages/de.json | 4 +- project.inlang/messages/en.json | 4 +- src/lib/utils/passkey.ts | 125 ++++++++++ .../setup-passkey/setup-passkey-form.svelte | 122 ++-------- .../add-passkey-form/add-passkey-form.svelte | 229 ++++++++++++++++++ .../(components)/add-passkey-form/index.ts | 5 + .../(components)/add-passkey-form/schema.ts | 14 ++ .../account/passkeys/+page.server.ts | 2 + .../dashboard/account/passkeys/+page.svelte | 7 +- 9 files changed, 398 insertions(+), 114 deletions(-) create mode 100644 src/routes/(pages)/dashboard/account/passkeys/(components)/add-passkey-form/add-passkey-form.svelte create mode 100644 src/routes/(pages)/dashboard/account/passkeys/(components)/add-passkey-form/index.ts create mode 100644 src/routes/(pages)/dashboard/account/passkeys/(components)/add-passkey-form/schema.ts diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 922bccd..68a0bb5 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -85,7 +85,9 @@ "pin": "PIN", "pinHint": "Speichere Deine PIN an einem sicheren Ort, z.B. in einem Passwort-Manager.", "locale": "Sprache", - "localePlaceholder": "Sprache wählen" + "localePlaceholder": "Sprache wählen", + "deviceName": "Passkey Name", + "deviceNameHint": "Something you will recognize" }, "login": { "or": "Oder", diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 9a7405f..1afe2fe 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -101,7 +101,9 @@ "pin": "PIN", "pinHint": "Save your PIN in a secure place, like a password-manager.", "locale": "Language", - "localePlaceholder": "Select Language" + "localePlaceholder": "Select Language", + "deviceName": "Passkey Name", + "deviceNameHint": "Etwas, das Du wiedererkennen wirst" }, "login": { "or": "Or", diff --git a/src/lib/utils/passkey.ts b/src/lib/utils/passkey.ts index df31b9b..3870bd5 100644 --- a/src/lib/utils/passkey.ts +++ b/src/lib/utils/passkey.ts @@ -1,5 +1,8 @@ +import { m } from "$i18n/messages"; +import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte"; import logger from "$lib/logger"; import { normalizeEmail } from "$lib/utils"; +import { toast } from "svelte-sonner"; type WebAuthnAllowCredential = { id: string; @@ -349,3 +352,125 @@ export const getCounterFromAuthenticatorData = (authenticatorData: ArrayBuffer) // Counter is at offset 33, 4 bytes, big-endian return view.getUint32(33, false); // false = big-endian }; + +export const getPasskeyFormData = async ({ + email, + userId, + setPasskeyFieldState, +}: { + email: string; + userId: string; + setPasskeyFieldState: (newState: PasskeyState) => void; +}) => { + setPasskeyFieldState("loading"); + + // Generate Kyber keypair BEFORE passkey registration + // This keypair will be used to create the dbShard after PRF output is available + const { KyberCrypto } = await import("$lib/crypto/utils"); + const kyberKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array } | undefined = + KyberCrypto.generateKeyPair(); + + const challenge = await fetchChallenge(email, userId); + let registrationChallenge: string | undefined; + + if (!challenge) { + logger.error("Failed to fetch challenge", { email: email }); + setPasskeyFieldState("error"); + } else { + // Store the registration challenge - will be sent with form to avoid cookie overwrite by PRF challenge + registrationChallenge = challenge.challenge; + + setPasskeyFieldState("user"); + const passkeyResp = await generatePasskey({ + ...challenge, + email: email, + enablePRF: true, // CRITICAL: Enable PRF extension for zero-knowledge key derivation + }).catch((error) => { + setPasskeyFieldState("error"); + logger.error("Failed to generate passkey", { ...challenge, error }); + }); + + if (!passkeyResp) { + setPasskeyFieldState("error"); + logger.error("Passkey response is falsy"); + return; + } + + // Verify PRF extension is enabled + const extensionResults = passkeyResp.getClientExtensionResults(); + if (!extensionResults.prf?.enabled) { + setPasskeyFieldState("error"); + logger.error("PRF extension not enabled - passkey rejected", { + email: email, + extensions: extensionResults, + }); + toast.warning(m["setupPasskey.errorAuthenticatorNotSupported"]()); + return; + } + + // Get attestationObject and clientDataJSON for @simplewebauthn/server verification + const attestationObjectResp = passkeyResp.response.attestationObject; + const clientDataJSONResp = passkeyResp.response.clientDataJSON; + + // UX Primer for second passkey UI + const isConfirmed = confirm(m["setupPasskey.confirmPrfRetrival"]()); + if (!isConfirmed) { + setPasskeyFieldState("error"); + toast.error(m["setupPasskey.errorPrfOutputNotTriggered"]()); + logger.warn("PRF challenge primer not confirmed"); + return; + } + + // CRITICAL: Get PRF output immediately after passkey creation + // This is the only time we can retrieve the PRF output + // Uses email as salt for multi-passkey support + let prfOutput: ArrayBuffer | undefined; + try { + const prfChallenge = await fetchChallenge(email, userId); + if (!prfChallenge) { + throw new Error("Failed to fetch PRF challenge"); + } + + const prfOutputResp = await getPRFOutputAfterRegistration({ + passkeyId: passkeyResp.id, + rpId: prfChallenge.id, + challengeBase64: prfChallenge.challenge, + email: email, // Use email as PRF salt for multi-passkey support + }); + + prfOutput = prfOutputResp; + logger.info("PRF output retrieved successfully", { + prfOutputLength: prfOutputResp.byteLength, + }); + } catch (error) { + setPasskeyFieldState("error"); + logger.error("Failed to get PRF output", { + email: email, + error, + }); + toast.error(m["setupPasskey.errorGettingPrfOutput"]()); + return; + } + + // Update form data with passkey info - send full attestation for proper COSE key extraction + const attestationObjectBase64 = arrayBufferToBase64(attestationObjectResp); + const clientDataJSONBase64 = arrayBufferToBase64(clientDataJSONResp); + + // Update UI to show passkey is ready + setPasskeyFieldState("success"); + + return { + passkeyId: passkeyResp.id, + prfOutput: prfOutput, + kyberKeyPair, + formData: { + email, + userId, + id: passkeyResp.id, + attestationObjectBase64, + clientDataJSONBase64, + challenge: registrationChallenge!, // Send original registration challenge (not PRF challenge) + }, + }; + } +}; diff --git a/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte index f3a29d1..dd14531 100644 --- a/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte +++ b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte @@ -9,12 +9,6 @@ import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte"; import { ROUTES } from "$lib/const/routes"; import logger from "$lib/logger"; - import { - arrayBufferToBase64, - fetchChallenge, - generatePasskey, - getPRFOutputAfterRegistration, - } from "$lib/utils/passkey"; import { toast } from "svelte-sonner"; import { writable, type Writable } from "svelte/store"; import { type Infer, type SuperValidated } from "sveltekit-superforms"; @@ -24,6 +18,7 @@ import { onMount } from "svelte"; import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto"; import { resolve } from "$app/paths"; + import { getPasskeyFormData } from "$lib/utils/passkey"; let { data, @@ -36,7 +31,6 @@ let passkeyId: string | undefined = $state(); let prfOutput: ArrayBuffer | undefined = $state(); let kyberKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array } | undefined = $state(); - let registrationChallenge: string | undefined = $state(); // svelte-ignore state_referenced_locally const form = superForm(data.form, { @@ -73,109 +67,23 @@ }; const onSetPasskey = async () => { - $passkeyLoading = "loading"; + const data = await getPasskeyFormData({ + email: $formData.email, + userId: $formData.userId, + setPasskeyFieldState: (v) => ($passkeyLoading = v), + }); - // Generate Kyber keypair BEFORE passkey registration - // This keypair will be used to create the dbShard after PRF output is available - const { KyberCrypto } = await import("$lib/crypto/utils"); - kyberKeyPair = KyberCrypto.generateKeyPair(); - - const challenge = await fetchChallenge($formData.email, $formData.userId); - - if (!challenge) { - logger.error("Failed to fetch challenge", { email: $formData.email }); - $passkeyLoading = "error"; + if (!data) { + console.error("Unable to getPasskeyFormData"); } else { - // Store the registration challenge - will be sent with form to avoid cookie overwrite by PRF challenge - registrationChallenge = challenge.challenge; + passkeyId = data.passkeyId; + kyberKeyPair = data.kyberKeyPair; + prfOutput = data.prfOutput; - $passkeyLoading = "user"; - const passkeyResp = await generatePasskey({ - ...challenge, - email: $formData.email, - enablePRF: true, // CRITICAL: Enable PRF extension for zero-knowledge key derivation - }).catch((error) => { - $passkeyLoading = "error"; - logger.error("Failed to generate passkey", { ...challenge, error }); - }); - - if (!passkeyResp) { - $passkeyLoading = "error"; - logger.error("Passkey response is falsy"); - return; - } - - // Verify PRF extension is enabled - const extensionResults = passkeyResp.getClientExtensionResults(); - if (!extensionResults.prf?.enabled) { - $passkeyLoading = "error"; - logger.error("PRF extension not enabled - passkey rejected", { - email: $formData.email, - extensions: extensionResults, - }); - toast.warning(m["setupPasskey.errorAuthenticatorNotSupported"]()); - return; - } - - // Get attestationObject and clientDataJSON for @simplewebauthn/server verification - const attestationObjectResp = passkeyResp.response.attestationObject; - const clientDataJSONResp = passkeyResp.response.clientDataJSON; - - // UX Primer for second passkey UI - const isConfirmed = confirm(m["setupPasskey.confirmPrfRetrival"]()); - if (!isConfirmed) { - $passkeyLoading = "error"; - toast.error(m["setupPasskey.errorPrfOutputNotTriggered"]()); - logger.warn("PRF challenge primer not confirmed"); - return; - } - - // CRITICAL: Get PRF output immediately after passkey creation - // This is the only time we can retrieve the PRF output - // Uses email as salt for multi-passkey support - try { - const prfChallenge = await fetchChallenge($formData.email, $formData.userId); - if (!prfChallenge) { - throw new Error("Failed to fetch PRF challenge"); - } - - const prfOutputResp = await getPRFOutputAfterRegistration({ - passkeyId: passkeyResp.id, - rpId: prfChallenge.id, - challengeBase64: prfChallenge.challenge, - email: $formData.email, // Use email as PRF salt for multi-passkey support - }); - - prfOutput = prfOutputResp; - logger.info("PRF output retrieved successfully", { - prfOutputLength: prfOutputResp.byteLength, - }); - } catch (error) { - $passkeyLoading = "error"; - logger.error("Failed to get PRF output", { - email: $formData.email, - error, - }); - toast.error(m["setupPasskey.errorGettingPrfOutput"]()); - return; - } - - // Update form data with passkey info - send full attestation for proper COSE key extraction - const attestationObjectBase64 = arrayBufferToBase64(attestationObjectResp); - const clientDataJSONBase64 = arrayBufferToBase64(clientDataJSONResp); - $formData = { - ...$formData, - id: passkeyResp.id, - attestationObjectBase64, - clientDataJSONBase64, - challenge: registrationChallenge!, // Send original registration challenge (not PRF challenge) - }; - - // Set for later use - passkeyId = passkeyResp.id; - - // Update UI to show passkey is ready - $passkeyLoading = "success"; + $formData.id = data.formData.id; + $formData.attestationObjectBase64 = data.formData.attestationObjectBase64; + $formData.clientDataJSONBase64 = data.formData.clientDataJSONBase64; + $formData.challenge = data.formData.challenge; } }; diff --git a/src/routes/(pages)/dashboard/account/passkeys/(components)/add-passkey-form/add-passkey-form.svelte b/src/routes/(pages)/dashboard/account/passkeys/(components)/add-passkey-form/add-passkey-form.svelte new file mode 100644 index 0000000..5847480 --- /dev/null +++ b/src/routes/(pages)/dashboard/account/passkeys/(components)/add-passkey-form/add-passkey-form.svelte @@ -0,0 +1,229 @@ + + + + + + {#snippet children({ props })} + {m["form.deviceName"]()} + + {/snippet} + + + + {m["form.deviceNameHint"]()} + + +
+ + + {#snippet children({ props })} + + {/snippet} + + + + + + {#snippet children({ props })} + + {/snippet} + + + + + {#snippet children({ props })} + + {/snippet} + + + + + {#snippet children({ props })} + + {/snippet} + + + + + {#snippet children({ props })} + + {/snippet} + + + + + {#snippet children({ props })} + + {/snippet} + + + + +
+ + {m["setupPasskey.action"]()} + +
diff --git a/src/routes/(pages)/dashboard/account/passkeys/(components)/add-passkey-form/index.ts b/src/routes/(pages)/dashboard/account/passkeys/(components)/add-passkey-form/index.ts new file mode 100644 index 0000000..b064c74 --- /dev/null +++ b/src/routes/(pages)/dashboard/account/passkeys/(components)/add-passkey-form/index.ts @@ -0,0 +1,5 @@ +import AddPasskeyForm from "./add-passkey-form.svelte"; + +export { AddPasskeyForm }; +export { formSchema } from "./schema"; +export type { FormSchema } from "./schema"; diff --git a/src/routes/(pages)/dashboard/account/passkeys/(components)/add-passkey-form/schema.ts b/src/routes/(pages)/dashboard/account/passkeys/(components)/add-passkey-form/schema.ts new file mode 100644 index 0000000..0e5fe22 --- /dev/null +++ b/src/routes/(pages)/dashboard/account/passkeys/(components)/add-passkey-form/schema.ts @@ -0,0 +1,14 @@ +import { m } from "$i18n/messages"; +import { z } from "zod"; + +export const formSchema = z.object({ + deviceName: z.string().min(3), + userId: z.string().min(3), + id: z.string().min(3), + email: z.string().email(m["form.errors.email"]()), + attestationObjectBase64: z.string().base64(), + clientDataJSONBase64: z.string().base64(), + challenge: z.string().min(20), // Original registration challenge (before PRF challenge overwrites cookie) +}); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/dashboard/account/passkeys/+page.server.ts b/src/routes/(pages)/dashboard/account/passkeys/+page.server.ts index ad7ab03..2d74d33 100644 --- a/src/routes/(pages)/dashboard/account/passkeys/+page.server.ts +++ b/src/routes/(pages)/dashboard/account/passkeys/+page.server.ts @@ -9,6 +9,8 @@ import { formSchema as editFormSchema } from "./(components)/edit-passkey-form"; const log = logger.setContext(import.meta.filename); export const load = async (event) => { + event.depends(`app:account-passkeys`); + const user = event.locals.user; if (!user) { log.error("User trying to access their passkeys, but has no user"); diff --git a/src/routes/(pages)/dashboard/account/passkeys/+page.svelte b/src/routes/(pages)/dashboard/account/passkeys/+page.svelte index b9a75bc..a760e80 100644 --- a/src/routes/(pages)/dashboard/account/passkeys/+page.svelte +++ b/src/routes/(pages)/dashboard/account/passkeys/+page.svelte @@ -14,6 +14,7 @@ import { getLocalTimeZone } from "@internationalized/date"; import { UserKey, Pen, PlusIcon, Trash2 } from "@lucide/svelte"; import { EditPasskeyForm } from "./(components)/edit-passkey-form"; + import { AddPasskeyForm } from "./(components)/add-passkey-form"; const { data } = $props(); let curItem: RedactedPasskeyHydrated | null = $state(null); @@ -50,11 +51,7 @@ {#snippet triggerLabel()} {m["account.passkeys.add.title"]()} {/snippet} - + {#if items.length > 0}