Allow dashboard users to add passkey -wip

This commit is contained in:
Karl Ludwig Weise
2026-07-30 20:33:05 +02:00
parent c84144a32f
commit e384eb2865
9 changed files with 398 additions and 114 deletions
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+125
View File
@@ -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)
},
};
}
};
@@ -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;
}
};
@@ -0,0 +1,229 @@
<script lang="ts">
import { invalidate } from "$app/navigation";
import { m } from "$i18n/messages.js";
import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Passkey } from "$lib/components/ui/passkey";
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
import logger from "$lib/logger";
import { auth } from "$lib/stores/auth";
import { getPasskeyFormData } from "$lib/utils/passkey";
import { untrack } from "svelte";
import { toast } from "svelte-sonner";
import { writable, type Writable } from "svelte/store";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { superForm } from "sveltekit-superforms/client";
import { formSchema } from "./schema";
let account = $derived($auth.user);
let tenantId: string | undefined = $state();
let passkeyId: string | undefined = $state();
let prfOutput: ArrayBuffer | undefined = $state();
let kyberKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array } | undefined = $state();
let isSubmitting = $state(false);
const form = superForm(
{
deviceName: "",
email: untrack(() => account?.email || ""),
userId: untrack(() => account?.id || ""),
id: "",
attestationObjectBase64: "",
clientDataJSONBase64: "",
challenge: "",
},
{
validators: zodClient(formSchema),
onChange: (event) => {
if (event.paths.includes("email")) {
setProperPasskeyState();
}
},
},
);
const { form: formData, enhance } = form;
const passkeyLoading: Writable<PasskeyState> = writable("click");
const setProperPasskeyState = () => {
const isOk = formSchema.shape.email.safeParse($formData.email).success;
if (isOk) {
$passkeyLoading = "click";
} else {
$passkeyLoading = "initial";
}
};
const onSetPasskey = async () => {
const data = await getPasskeyFormData({
email: $formData.email,
userId: $formData.userId,
setPasskeyFieldState: (v) => ($passkeyLoading = v),
});
if (!data) {
console.error("Unable to getPasskeyFormData");
} else {
passkeyId = data.passkeyId;
kyberKeyPair = data.kyberKeyPair;
prfOutput = data.prfOutput;
$formData.id = data.formData.id;
$formData.attestationObjectBase64 = data.formData.attestationObjectBase64;
$formData.clientDataJSONBase64 = data.formData.clientDataJSONBase64;
$formData.challenge = data.formData.challenge;
}
};
const storeStaffKeyPairForNewPasskey = async () => {
if (tenantId && passkeyId && prfOutput && kyberKeyPair) {
const crypto = new UnifiedAppointmentCrypto();
return await crypto
.storeStaffKeyPairForNewPasskey(
tenantId,
$formData.userId,
passkeyId,
prfOutput,
kyberKeyPair,
)
.then(() => {
toast.success(m["setupPasskey.successKeyPairSaved"]());
})
.catch((error) => {
toast.error(m["setupPasskey.errorKeyPairNotSaved"]());
logger.error("Failed to store staff key pair", {
tenantId,
userId: $formData.userId,
passkeyId,
error,
});
});
} else {
logger.error("Failed to store staff key pair - missing required data", {
tenantId,
userId: $formData.userId,
passkeyId,
hasPrfOutput: !!prfOutput,
hasKyberKeyPair: !!kyberKeyPair,
});
toast.error(m["setupPasskey.errorKeyPairDataMissing"]());
}
};
const onSubmit = async () => {
isSubmitting = false;
try {
const resp = await fetch(`/api/auth/passkeys`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
passkey: {
id: $formData.id,
attestationObject: $formData.attestationObjectBase64,
clientDataJSON: $formData.clientDataJSONBase64,
deviceName: $formData.deviceName,
},
}),
});
if (resp.status >= 400) {
throw Error(`Adding passkey failed. Error: ${await resp.text()}`);
}
const respBody = await resp.json();
console.log("respBody", respBody);
await storeStaffKeyPairForNewPasskey();
// TODO: Add later with respBody
// const crypto = $staffCrypto.crypto;
// if (crypto) {
// crypto.rewrapAllTunnelsForNewPasskey();
// } else {
// throw Error(`Adding passkey failed. Error: staffCrypto is undefined`);
// }
toast.success(m["setupPasskey.success"]());
invalidate("app:account-passkeys");
} catch (error) {
console.error("Unable to add passkey", error);
toast.error(m["setupPasskey.error"]());
} finally {
isSubmitting = false;
}
};
</script>
<Form.Root {enhance}>
<Form.Field {form} name="deviceName">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.deviceName"]()}</Form.Label>
<Input {...props} bind:value={$formData.deviceName} type="text" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
<Form.Description>
{m["form.deviceNameHint"]()}
</Form.Description>
</Form.Field>
<div>
<Form.Field {form} name="email">
<Form.Control>
{#snippet children({ props })}
<Input {...props} bind:value={$formData.email} type="email" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="userId" class="">
<Form.Control>
{#snippet children({ props })}
<Input {...props} bind:value={$formData.userId} type="text" />
{/snippet}
</Form.Control>
</Form.Field>
<Form.Field {form} name="id" class="">
<Form.Control>
{#snippet children({ props })}
<Input {...props} bind:value={$formData.id} type="text" />
{/snippet}
</Form.Control>
</Form.Field>
<Form.Field {form} name="attestationObjectBase64" class="">
<Form.Control>
{#snippet children({ props })}
<Input {...props} bind:value={$formData.attestationObjectBase64} type="text" />
{/snippet}
</Form.Control>
</Form.Field>
<Form.Field {form} name="clientDataJSONBase64" class="">
<Form.Control>
{#snippet children({ props })}
<Input {...props} bind:value={$formData.clientDataJSONBase64} type="text" />
{/snippet}
</Form.Control>
</Form.Field>
<Form.Field {form} name="challenge" class="">
<Form.Control>
{#snippet children({ props })}
<Input {...props} bind:value={$formData.challenge} type="text" />
{/snippet}
</Form.Control>
</Form.Field>
<Label class="mb-2">{m["form.passkey"]()}</Label>
<Passkey.State state={$passkeyLoading} onclick={onSetPasskey} />
</div>
<Form.Button
type="button"
size="lg"
class="w-full"
onclick={onSubmit}
isLoading={isSubmitting}
disabled={isSubmitting}
>
{m["setupPasskey.action"]()}
</Form.Button>
</Form.Root>
@@ -0,0 +1,5 @@
import AddPasskeyForm from "./add-passkey-form.svelte";
export { AddPasskeyForm };
export { formSchema } from "./schema";
export type { FormSchema } from "./schema";
@@ -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;
@@ -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");
@@ -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()}
<PlusIcon /> {m["account.passkeys.add.title"]()}
{/snippet}
<ul>
<li>Do more or less what's done in setup passkey form</li>
<li>send it to POST /api/auth/passkeys</li>
<li>do more or less what grant access form does</li>
</ul>
<AddPasskeyForm />
</ResponsiveDialog>
{#if items.length > 0}