diff --git a/docs/staff-crypto-worker.md b/docs/staff-crypto-worker.md index b32c8b9..db7da8f 100644 --- a/docs/staff-crypto-worker.md +++ b/docs/staff-crypto-worker.md @@ -21,43 +21,66 @@ Browser (Staff App) ↔ StaffCryptoService API ↔ Tenant Database - **Browser-Based Cryptography**: All key generation and crypto operations in browser - **Split-Key Architecture**: Private keys split using XOR between database and passkey shards -- **WebAuthn Integration**: Hardware-backed deterministic key derivation from passkey data -- **Zero-Knowledge Server**: Server never sees complete private keys +- **WebAuthn PRF Extension**: Hardware-backed deterministic secret derivation using PRF (Pseudo-Random Function) +- **Zero-Knowledge Server**: Server never sees complete private keys or PRF outputs - **Per-Passkey Keys**: Each staff passkey has its own unique keypair - **ML-KEM-768 Encryption**: Post-quantum cryptography using Kyber +- **Modern Authenticator Required**: Requires CTAP 2.1+ authenticators with PRF support ## Browser Integration Workflow ### 1. Staff Passkey Registration & Key Generation -When a staff member registers a new passkey, the browser automatically generates crypto keys: +When a staff member registers a new passkey, the browser automatically generates crypto keys using the WebAuthn PRF extension: ```javascript import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto"; +import { generatePasskey, getPRFOutputAfterRegistration } from "$lib/utils/passkey"; -async function registerStaffPasskey(userId, tenantId) { - // 1. Complete WebAuthn passkey registration - const credential = await navigator.credentials.create({ - publicKey: registrationOptions, +async function registerStaffPasskey(userId, tenantId, email) { + // 1. Complete WebAuthn passkey registration WITH PRF extension + const credential = await generatePasskey({ + id: rpId, + challenge: challengeBase64, + email: email, + enablePRF: true, // CRITICAL: Enable PRF for zero-knowledge key derivation }); + // Verify PRF extension is enabled + const extensionResults = credential.getClientExtensionResults(); + if (!extensionResults.prf?.enabled) { + throw new Error( + "PRF extension not supported. Please use a modern authenticator " + + "(YubiKey 5.2.3+, Titan Gen2, Windows Hello, Touch ID, or Android)", + ); + } + // 2. Generate Kyber keypair in browser const keyPair = KyberCrypto.generateKeyPair(); - // 3. Derive deterministic shard from WebAuthn authenticatorData + // 3. IMMEDIATELY retrieve PRF output after passkey creation + // This is the ONLY time we can get the PRF output for this passkey + const prfOutput = await getPRFOutputAfterRegistration({ + passkeyId: credential.id, + rpId: rpId, + challengeBase64: freshChallengeBase64, // Fresh challenge from server + email: email, + }); + + // 4. Derive deterministic shard from PRF output (SECRET, zero-knowledge!) const crypto = new UnifiedAppointmentCrypto(); - const passkeyBasedShard = await crypto.derivePasskeyBasedShard( - credential.id, - credential.response.authenticatorData, + const passkeyBasedShard = await crypto.derivePasskeyBasedShardWithPRF( + prfOutput, // 32-byte secret from PRF extension + userId, ); - // 4. Create database shard using XOR + // 5. Create database shard using XOR const dbShard = new Uint8Array(keyPair.privateKey.length); for (let i = 0; i < keyPair.privateKey.length; i++) { dbShard[i] = keyPair.privateKey[i] ^ passkeyBasedShard[i]; } - // 5. Store via StaffCryptoService API + // 6. Store via StaffCryptoService API await fetch(`/api/tenants/${tenantId}/staff/${userId}/crypto`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -68,50 +91,101 @@ async function registerStaffPasskey(userId, tenantId) { }), }); - console.log("Staff crypto keys generated and stored"); + console.log("Staff crypto keys generated and stored (PRF-based)"); } ``` +**SECURITY NOTE**: The PRF output is a **secret** 32-byte value that only the passkey owner can derive. Unlike the old `authenticatorData` approach (which used public `RP_ID_HASH`), PRF provides true zero-knowledge security - an attacker with database access **cannot** reconstruct the private key. + ### 2. Staff Authentication & Key Reconstruction -When a staff member authenticates, their private key is reconstructed from shards: +When a staff member authenticates, their private key is reconstructed from shards using the WebAuthn PRF extension: ```javascript async function authenticateStaff(userId, tenantId) { - // 1. Perform WebAuthn authentication - const assertion = await navigator.credentials.get({ - publicKey: authenticationOptions, + const crypto = new UnifiedAppointmentCrypto(); + + // This internally performs the following steps: + // 1. Perform WebAuthn authentication WITH PRF extension + // 2. Get database shard from API + // 3. Derive same passkey-based shard from PRF output + // 4. Reconstruct private key using XOR + await crypto.authenticateStaff(userId, tenantId); + + console.log("Staff authenticated and keys reconstructed (PRF-based)"); + return crypto; +} +``` + +**Internal PRF Authentication Flow** (handled by `UnifiedAppointmentCrypto.authenticateStaff()`): + +```javascript +async authenticateStaff(staffId, tenantId) { + // 1. Get passkey list from session storage + const availablePasskeys = this.getAvailablePasskeys(staffId); + const passkeyId = availablePasskeys[0]; // User can select which passkey to use + + // 2. Perform WebAuthn authentication with PRF extension + const prfSalt = new TextEncoder().encode(`open-reception-prf:${email}`); + + const credential = await navigator.credentials.get({ + publicKey: { + challenge: challengeBuffer, + rpId: rpId, + allowCredentials: [{ id: passkeyIdBuffer, type: "public-key" }], + userVerification: "required", + extensions: { + prf: { + eval: { first: prfSalt }, // Request PRF output + }, + }, + }, }); - // 2. Get database shard from API - const response = await fetch(`/api/tenants/${tenantId}/staff/${userId}/key-shard`); - const { publicKey, privateKeyShare, passkeyId } = await response.json(); + // 3. Extract PRF output from WebAuthn response + const extensionResults = credential.getClientExtensionResults(); + const prfOutput = extensionResults.prf?.results?.first; - // 3. Derive same passkey-based shard - const crypto = new UnifiedAppointmentCrypto(); - const passkeyBasedShard = await crypto.derivePasskeyBasedShard( - passkeyId, - assertion.response.authenticatorData, + if (!prfOutput) { + throw new Error("PRF extension not supported by this passkey"); + } + + // 4. Get database shard from API + const response = await fetch(`/api/tenants/${tenantId}/staff/${staffId}/key-shard`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ passkeyId }), + }); + const { publicKey, privateKeyShare } = await response.json(); + + // 5. Derive passkey-based shard from PRF output (same as during registration) + const passkeyBasedShard = await this.derivePasskeyBasedShardWithPRF( + prfOutput, // Secret 32-byte PRF output + staffId, ); - // 4. Reconstruct private key using XOR + // 6. Reconstruct private key using XOR const dbShard = base64ToBuffer(privateKeyShare); const privateKey = new Uint8Array(dbShard.length); for (let i = 0; i < dbShard.length; i++) { privateKey[i] = dbShard[i] ^ passkeyBasedShard[i]; } - // 5. Store reconstructed keypair for session - crypto.staffKeyPair = { + // 7. Store reconstructed keypair for session + this.staffKeyPair = { publicKey: base64ToBuffer(publicKey), privateKey: privateKey, }; - - console.log("Staff authenticated and keys reconstructed"); - return crypto; } ``` +**SECURITY NOTE**: The PRF output is derived fresh during each authentication using the same deterministic salt (`open-reception-prf:${email}`). This guarantees: + +- **Determinism**: Same passkey + same salt = same PRF output = same private key +- **Zero-Knowledge**: PRF output never leaves the browser, server cannot derive it +- **Hardware-Backed**: PRF computation happens inside the authenticator's secure element +- **Multi-Passkey Support**: Using email as salt allows all passkeys for the same user to work interchangeably + ## StaffCryptoService API The backend API provides these endpoints for managing staff cryptographic keys: diff --git a/migrations/0008_tense_gamora.sql b/migrations/0008_wild_whirlwind.sql similarity index 100% rename from migrations/0008_tense_gamora.sql rename to migrations/0008_wild_whirlwind.sql diff --git a/migrations/meta/0008_snapshot.json b/migrations/meta/0008_snapshot.json index 2174510..5798bdf 100644 --- a/migrations/meta/0008_snapshot.json +++ b/migrations/meta/0008_snapshot.json @@ -1,11 +1,11 @@ { - "id": "51c74709-63c7-4ddb-b69d-2727f824bd50", + "id": "6f7d3653-5075-4baf-ad67-77afa7ae10ff", "prevId": "c06d9c90-44ff-4e2e-a834-8533afcfe755", "version": "7", "dialect": "postgresql", "tables": { - "public.passkey_challenge_throttle": { - "name": "passkey_challenge_throttle", + "public.challenge_throttle": { + "name": "challenge_throttle", "schema": "", "columns": { "id": { @@ -150,7 +150,9 @@ "tenant_short_name_unique": { "name": "tenant_short_name_unique", "nullsNotDistinct": false, - "columns": ["short_name"] + "columns": [ + "short_name" + ] } }, "policies": {}, @@ -236,8 +238,12 @@ "name": "tenant_config_tenant_id_tenant_id_fk", "tableFrom": "tenant_config", "tableTo": "tenant", - "columnsFrom": ["tenant_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "no action", "onUpdate": "no action" } @@ -374,8 +380,12 @@ "name": "user_tenant_id_tenant_id_fk", "tableFrom": "user", "tableTo": "tenant", - "columnsFrom": ["tenant_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "no action", "onUpdate": "no action" } @@ -385,7 +395,9 @@ "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, - "columns": ["email"] + "columns": [ + "email" + ] } }, "policies": {}, @@ -540,8 +552,12 @@ "name": "user_invite_tenant_id_tenant_id_fk", "tableFrom": "user_invite", "tableTo": "tenant", - "columnsFrom": ["tenant_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" }, @@ -549,8 +565,12 @@ "name": "user_invite_invited_by_user_id_fk", "tableFrom": "user_invite", "tableTo": "user", - "columnsFrom": ["invited_by"], - "columnsTo": ["id"], + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" }, @@ -558,8 +578,12 @@ "name": "user_invite_created_user_id_user_id_fk", "tableFrom": "user_invite", "tableTo": "user", - "columnsFrom": ["created_user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "created_user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "no action", "onUpdate": "no action" } @@ -569,7 +593,9 @@ "user_invite_invite_code_unique": { "name": "user_invite_invite_code_unique", "nullsNotDistinct": false, - "columns": ["invite_code"] + "columns": [ + "invite_code" + ] } }, "policies": {}, @@ -654,8 +680,12 @@ "name": "user_passkey_user_id_user_id_fk", "tableFrom": "user_passkey", "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -778,8 +808,12 @@ "name": "user_session_user_id_user_id_fk", "tableFrom": "user_session", "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -789,7 +823,9 @@ "user_session_session_token_unique": { "name": "user_session_session_token_unique", "nullsNotDistinct": false, - "columns": ["session_token"] + "columns": [ + "session_token" + ] } }, "policies": {}, @@ -801,22 +837,40 @@ "public.config_type": { "name": "config_type", "schema": "public", - "values": ["BOOLEAN", "NUMBER", "STRING"] + "values": [ + "BOOLEAN", + "NUMBER", + "STRING" + ] }, "public.confirmation_state": { "name": "confirmation_state", "schema": "public", - "values": ["INVITED", "CONFIRMED", "ACCESS_GRANTED"] + "values": [ + "INVITED", + "CONFIRMED", + "ACCESS_GRANTED" + ] }, "public.setup_state": { "name": "setup_state", "schema": "public", - "values": ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"] + "values": [ + "SETTINGS", + "AGENTS", + "CHANNELS", + "STAFF", + "READY" + ] }, "public.user_role": { "name": "user_role", "schema": "public", - "values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] + "values": [ + "GLOBAL_ADMIN", + "TENANT_ADMIN", + "STAFF" + ] } }, "schemas": {}, @@ -829,4 +883,4 @@ "schemas": {}, "tables": {} } -} +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index c71c6b9..7064b7f 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -61,9 +61,9 @@ { "idx": 8, "version": "7", - "when": 1765722019964, - "tag": "0008_tense_gamora", + "when": 1767362589999, + "tag": "0008_wild_whirlwind", "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 511f2a5..3e922ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@noble/hashes": "^1.8.0", "@noble/post-quantum": "^0.4.1", + "@simplewebauthn/server": "^11.0.0", "@sveltejs/adapter-node": "^5.2.12", "argon2": "^0.43.0", "argon2-browser": "^1.18.0", @@ -1553,6 +1554,12 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1736,6 +1743,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@lix-js/sdk": { "version": "0.4.7", "resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.7.tgz", @@ -1834,13 +1847,63 @@ "node": ">= 8" } }, - "node_modules/@petamoriken/float16": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz", - "integrity": "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==", + "node_modules/@peculiar/asn1-android": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz", + "integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==", "license": "MIT", - "optional": true, - "peer": true + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.0.tgz", + "integrity": "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.0.tgz", + "integrity": "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.0.tgz", + "integrity": "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } }, "node_modules/@phc/format": { "version": "1.0.0", @@ -2281,6 +2344,33 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/@simplewebauthn/server": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-11.0.0.tgz", + "integrity": "sha512-zu8dxKcPiRUNSN2kmrnNOzNbRI8VaR/rL4ENCHUfC6PEE7SAAdIql9g5GBOd/wOVZolIsaZz3ccFxuGoVP0iaw==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.3.10", + "@peculiar/asn1-ecc": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@simplewebauthn/types": "^11.0.0", + "cross-fetch": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@simplewebauthn/types": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-11.0.0.tgz", + "integrity": "sha512-b2o0wC5u2rWts31dTgBkAtSNKGX0cvL6h8QedNsKmj8O4QoLFQFR3DBVBUlpyVEhYKA+mXGUaXbcOc4JdQ3HzA==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, "node_modules/@sinclair/typebox": { "version": "0.31.28", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.28.tgz", @@ -2959,9 +3049,9 @@ "license": "MIT" }, "node_modules/@types/validator": { - "version": "13.15.2", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.2.tgz", - "integrity": "sha512-y7pa/oEJJ4iGYBxOpfAKn5b9+xuihvzDVnC/OSvlVnGxVg0pOqmjiMafiJ1KVNQEaPZf9HsEp5icEwGg8uIe5Q==", + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "dev": true, "license": "MIT", "optional": true @@ -3537,6 +3627,20 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1js": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3781,6 +3885,19 @@ "node": ">=18" } }, + "node_modules/class-validator": { + "version": "0.14.3", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", + "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.20" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3930,6 +4047,15 @@ "@cropper/utils": "^2.0.1" } }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4330,20 +4456,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -4953,56 +5065,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gel": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/gel/-/gel-2.1.0.tgz", - "integrity": "sha512-HCeRqInCt6BjbMmeghJ6BKeYwOj7WJT5Db6IWWAA3IMUUa7or7zJfTUEkUWCxiOtoXnwnm96sFK9Fr47Yh2hOA==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@petamoriken/float16": "^3.8.7", - "debug": "^4.3.4", - "env-paths": "^3.0.0", - "semver": "^7.6.2", - "shell-quote": "^1.8.1", - "which": "^4.0.0" - }, - "bin": { - "gel": "dist/cli.mjs" - }, - "engines": { - "node": ">= 18.0.0" - } - }, - "node_modules/gel/node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": ">=16" - } - }, - "node_modules/gel/node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^16.13.0 || >=18.0.0" - } - }, "node_modules/get-tsconfig": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", @@ -5512,6 +5574,14 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.12.33", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.33.tgz", + "integrity": "sha512-r9kw4OA6oDO4dPXkOrXTkArQAafIKAU71hChInV4FxZ69dxCfbwQGDPzqR5/vea94wU705/3AZroEbSoeVWrQw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/lightningcss": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", @@ -6028,6 +6098,48 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -6576,6 +6688,24 @@ "license": "MIT", "optional": true }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6907,20 +7037,6 @@ "node": ">=8" } }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -7644,7 +7760,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tw-animate-css": { diff --git a/package.json b/package.json index 9b5f8ed..fa58ffd 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "db:studio": "drizzle-kit studio", "db:generate": "drizzle-kit generate", "db:tenant:generate": "drizzle-kit generate --config=drizzle.tenant.config.ts", - "db:drop": "docker volume ls -q | grep '^open-reception_' | xargs -r docker volume rm", + "db:drop": "docker volume ls -q | grep '^appointment-booking-software_' | xargs -r docker volume rm", "docker:dev:up": "docker compose -f docker-compose.dev.yml up -d", "docker:dev:down": "docker compose -f docker-compose.dev.yml down", "docker:dev:logs": "docker compose -f docker-compose.dev.yml logs -f", @@ -83,6 +83,7 @@ "dependencies": { "@noble/hashes": "^1.8.0", "@noble/post-quantum": "^0.4.1", + "@simplewebauthn/server": "^11.0.0", "@sveltejs/adapter-node": "^5.2.12", "argon2": "^0.43.0", "argon2-browser": "^1.18.0", diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index 2dd752d..246d2c9 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -410,14 +410,14 @@ export class UnifiedAppointmentCrypto { // ===== STAFF METHODS ===== /** - * Authenticate staff member using WebAuthn and reconstruct private key from shards + * Authenticate staff member using WebAuthn with PRF and reconstruct private key from shards + * + * SECURITY: Uses PRF Extension for zero-knowledge key derivation. + * Requires modern authenticator with PRF support (CTAP 2.1+). */ async authenticateStaff(staffId: string, tenantId: string): Promise { try { - // 1. Perform WebAuthn authentication - const webAuthnResponse = await this.performWebAuthnAuthentication(staffId); - - // 2. Fetch database shard from server + // 1. Fetch database shard from server (to get passkeyId) const shardResponse = await fetch(`/api/tenants/${tenantId}/staff/${staffId}/key-shard`, { method: "GET", headers: { "Content-Type": "application/json" }, @@ -429,11 +429,11 @@ export class UnifiedAppointmentCrypto { const shardData = await shardResponse.json(); - // 3. Derive passkey-based shard from WebAuthn response - const passkeyBasedShard = await this.derivePasskeyBasedShard( - shardData.passkeyId, - webAuthnResponse.authenticatorData, - ); + // 2. Get PRF output from session (stored during login) + const prfOutput = await this.getPRFOutputFromSession(staffId, shardData.passkeyId); + + // 3. Derive passkey-based shard from PRF output + const passkeyBasedShard = await this.derivePasskeyBasedShardWithPRF(prfOutput, staffId); // 4. Decode database shard const dbShard = this.base64ToUint8Array(shardData.privateKeyShare); @@ -455,7 +455,7 @@ export class UnifiedAppointmentCrypto { this.staffAuthenticated = true; this.keyExpiry = Date.now() + 10 * 60 * 1000; // 10 minutes - console.log("✅ Staff authentication successful"); + console.log("✅ Staff authentication successful with PRF"); } catch (error) { console.error("❌ Staff authentication failed:", error); throw new Error( @@ -464,67 +464,6 @@ export class UnifiedAppointmentCrypto { } } - /** - * Reconstruct staff keys from session data (after login) - * This avoids requiring WebAuthn on every page load - */ - async reconstructStaffKeysFromSession( - staffId: string, - tenantId: string, - passkeyId: string, - authenticatorDataBase64: string, - ): Promise { - try { - // 1. Fetch database shard from server - const shardResponse = await fetch(`/api/tenants/${tenantId}/staff/${staffId}/key-shard`, { - method: "GET", - headers: { "Content-Type": "application/json" }, - }); - - if (!shardResponse.ok) { - throw new Error(`Failed to fetch key shard: ${shardResponse.status}`); - } - - const shardData = await shardResponse.json(); - - // 2. Convert base64 authenticatorData back to ArrayBuffer - const authenticatorDataBytes = this.base64ToUint8Array(authenticatorDataBase64); - - // 3. Derive passkey-based shard from stored authenticator data - const passkeyBasedShard = await this.derivePasskeyBasedShard( - passkeyId, - authenticatorDataBytes.buffer as ArrayBuffer, - ); - - // 4. Decode database shard - const dbShard = this.base64ToUint8Array(shardData.privateKeyShare); - - // 5. Reconstruct private key by XORing the two shards - const privateKey = new Uint8Array(dbShard.length); - for (let i = 0; i < dbShard.length; i++) { - privateKey[i] = dbShard[i] ^ passkeyBasedShard[i]; - } - - // 6. Store reconstructed key pair - this.staffKeyPair = { - publicKey: this.base64ToUint8Array(shardData.publicKey), - privateKey: privateKey, - }; - - this.staffId = staffId; - this.tenantId = tenantId; - this.staffAuthenticated = true; - this.keyExpiry = Date.now() + 60 * 60 * 1000; // 1 hour - - console.log("✅ Staff keys reconstructed from session"); - } catch (error) { - console.error("❌ Failed to reconstruct staff keys:", error); - throw new Error( - `Key reconstruction failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - /** * Decrypt appointment data for staff members */ @@ -694,69 +633,81 @@ export class UnifiedAppointmentCrypto { // ===== SHARED PRIVATE METHODS ===== /** - * Perform WebAuthn authentication for staff + * Get PRF output for staff authentication + * + * SECURITY: PRF Extension is REQUIRED for zero-knowledge key derivation. + * This method retrieves PRF output from the auth session (stored during login). + * + * @param staffId - Staff member ID + * @param passkeyId - Passkey credential ID + * @returns PRF output as ArrayBuffer + * @throws Error if PRF output is not available in session */ - private async performWebAuthnAuthentication(staffId: string): Promise<{ - authenticatorData: ArrayBuffer; - signature: ArrayBuffer; - userHandle: ArrayBuffer | null; - }> { - // 1. Get authentication options from server - const optionsResponse = await fetch("/api/auth/webauthn/authenticate/begin", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId: staffId }), - }); + private async getPRFOutputFromSession(staffId: string, passkeyId: string): Promise { + // Import auth store dynamically to avoid circular dependencies + const { auth } = await import("$lib/stores/auth"); - if (!optionsResponse.ok) { - throw new Error("Failed to get WebAuthn options"); + // Get PRF output from session storage (set during login) + const passkeyAuthData = auth.getPasskeyAuthData(); + + if (!passkeyAuthData) { + throw new Error( + "No passkey authentication data found in session. " + + "Please log in again with your passkey to access encrypted data.", + ); } - const options = await optionsResponse.json(); - - // 2. Perform WebAuthn authentication - const credential = (await navigator.credentials.get({ - publicKey: { - challenge: new Uint8Array(this.base64ToUint8Array(options.challenge)), - allowCredentials: options.allowCredentials?.map((cred: { id: string; type: string }) => ({ - id: new Uint8Array(this.base64ToUint8Array(cred.id)), - type: cred.type as PublicKeyCredentialType, - })), - timeout: options.timeout, - userVerification: options.userVerification, - }, - })) as PublicKeyCredential; - - if (!credential) { - throw new Error("WebAuthn authentication failed"); + if (!passkeyAuthData.prfOutput) { + throw new Error( + "PRF output not available in session. " + + "This passkey may not support the PRF extension. " + + "Please use a modern authenticator (YubiKey 5.2.3+, Titan Gen2, Windows Hello, Touch ID, or Android).", + ); } - const response = credential.response as AuthenticatorAssertionResponse; + // Verify that the passkeyId matches (security check) + if (passkeyAuthData.passkeyId !== passkeyId) { + throw new Error( + `Passkey ID mismatch: expected ${passkeyId}, got ${passkeyAuthData.passkeyId}. ` + + "Please log out and log in again.", + ); + } - return { - authenticatorData: response.authenticatorData, - signature: response.signature, - userHandle: response.userHandle, - }; + // Decode PRF output from base64 + const prfBytes = this.base64ToUint8Array(passkeyAuthData.prfOutput); + return prfBytes.buffer as ArrayBuffer; } /** - * Store the staff key pair after the registration of a new staff member + * Store the staff key pair after the registration of a new staff member with PRF + * + * SECURITY: Uses PRF Extension for zero-knowledge key derivation. + * The prfOutput parameter MUST come from a WebAuthn assertion with PRF extension. + * + * @param tenantId - Tenant ID + * @param staffId - Staff member ID + * @param passkeyId - Passkey credential ID + * @param prfOutput - 32-byte PRF output from WebAuthn assertion (secret!) + * @param keyPair - ML-KEM-768 keypair generated in browser + * @throws Error if PRF output is invalid or API call fails */ public async storeStaffKeyPair( tenantId: string, staffId: string, passkeyId: string, - authenticatorData: ArrayBuffer, + prfOutput: ArrayBuffer, keyPair: { publicKey: Uint8Array; privateKey: Uint8Array }, ): Promise { - const passkeyBasedShard = await this.derivePasskeyBasedShard(passkeyId, authenticatorData); + // Derive passkey-based shard from PRF output + const passkeyBasedShard = await this.derivePasskeyBasedShardWithPRF(prfOutput, staffId); + // Create database shard by XORing private key with passkey-based shard const dbShard = new Uint8Array(keyPair.privateKey.length); for (let i = 0; i < keyPair.privateKey.length; i++) { dbShard[i] = keyPair.privateKey[i] ^ passkeyBasedShard[i]; } + // Store public key and database shard on server await fetch(`/api/tenants/${tenantId}/staff/${staffId}/crypto`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -766,6 +717,8 @@ export class UnifiedAppointmentCrypto { privateKeyShare: this.uint8ArrayToBase64(dbShard), }), }); + + console.log("✅ Staff keypair stored with PRF-based security"); } private uint8ArrayToBase64(array: Uint8Array): string { @@ -773,47 +726,51 @@ export class UnifiedAppointmentCrypto { } /** - * Derive a deterministic shard from passkey authentication data + * Derive a deterministic shard from WebAuthn PRF Extension (CTAP 2.1+) * - * This method creates a consistent private key shard from WebAuthn authenticatorData. + * This method uses the PRF (Pseudo-Random Function) extension to derive a secret shard. * The same passkey will always produce the same shard, enabling key reconstruction. * + * SECURITY: PRF provides ZERO-KNOWLEDGE guarantee: + * - PRF output is SECRET - only the passkey owner can derive it + * - Server NEVER sees the PRF output (only the database shard) + * - Database compromise does NOT reveal private keys (both shards needed) + * * Used in Staff Key Management: * - During registration: Create shard to XOR with private key for database storage * - During authentication: Recreate same shard to reconstruct private key * - * Uses HKDF (HMAC-based Key Derivation Function) with: - * - IKM: First 32 bytes of authenticatorData (rpIdHash only, excluding flags and signCount) - * - Salt: "staff-crypto-shard-v1" (version-specific salt) - * - Info: "passkey:{passkeyId}" (domain separation per passkey) - * - Length: 2400 bytes (ML-KEM-768 private key size) + * Uses PRF Extension + HKDF expansion: + * - Input: 32-byte PRF output from authenticator (secret!) + * - HKDF Salt: "staff-prf-shard-v2" (version-specific) + * - HKDF Info: "staff:{staffId}" (domain separation) + * - Output: 2400 bytes (ML-KEM-768 private key size) + * + * @param prfOutput - 32-byte PRF output from assertion.getClientExtensionResults().prf.results.first + * @param staffId - Staff member ID for domain separation + * @returns 2400-byte shard for XOR-based key reconstruction + * @throws Error if PRF output is not exactly 32 bytes */ - private async derivePasskeyBasedShard( - passkeyId: string, - authenticatorData: ArrayBuffer, + private async derivePasskeyBasedShardWithPRF( + prfOutput: ArrayBuffer, + staffId: string, ): Promise { - // Extract randomness from authenticator data - // IMPORTANT: Use only the first 32 bytes (rpIdHash) - // We CANNOT use flags (byte 32) because the AT flag differs between registration and authentication! - // We CANNOT use signCount (bytes 33-36) because it increments on every authentication! - // authenticatorData structure: - // - Bytes 0-31: rpIdHash (SHA-256 of RP ID) - CONSTANT ✓ - // - Byte 32: flags - DIFFERS (AT flag set during registration) ✗ - // - Bytes 33-36: signCount - CHANGES on every login ✗ - // - Bytes 37+: attestedCredentialData (only during registration) ✗ - const fullData = new Uint8Array(authenticatorData); - const inputKeyMaterial = fullData.slice(0, 32); // Only first 32 bytes (rpIdHash only) + // Validate PRF output length (should always be 32 bytes per CTAP 2.1 spec) + const prfBytes = new Uint8Array(prfOutput); + if (prfBytes.length !== 32) { + throw new Error(`Invalid PRF output length: ${prfBytes.length} bytes (expected 32)`); + } - // Import the IKM as a CryptoKey for HKDF - const ikmKey = await crypto.subtle.importKey("raw", inputKeyMaterial, "HKDF", false, [ - "deriveBits", - ]); + // Import the PRF output as a CryptoKey for HKDF expansion + const ikmKey = await crypto.subtle.importKey("raw", prfBytes, "HKDF", false, ["deriveBits"]); - // Salt for HKDF - const salt = new TextEncoder().encode("staff-crypto-shard-v1"); + // Salt for HKDF (versioned to allow future rotation) + // v2: Uses email-based PRF salts for multi-passkey support + // Each passkey still produces unique PRF output (passkey private key is part of PRF) + const salt = new TextEncoder().encode("staff-prf-shard-v2"); - // Info for HKDF (domain separation with passkey ID) - const info = new TextEncoder().encode(`passkey:${passkeyId}`); + // Info for HKDF (domain separation per staff member) + const info = new TextEncoder().encode(`staff:${staffId}`); // Derive key material with the length needed for Kyber private key (2400 bytes for ML-KEM-768) const keyMaterial = await crypto.subtle.deriveBits( diff --git a/src/lib/server/auth/webauthn-service.ts b/src/lib/server/auth/webauthn-service.ts index 2dd66c4..650803b 100644 --- a/src/lib/server/auth/webauthn-service.ts +++ b/src/lib/server/auth/webauthn-service.ts @@ -1,8 +1,11 @@ import { centralDb as db } from "$lib/server/db"; import { userPasskey } from "$lib/server/db/central-schema"; import { eq, desc } from "drizzle-orm"; -import { randomBytes, createHash } from "node:crypto"; +import { randomBytes } from "node:crypto"; import { UniversalLogger } from "$lib/logger"; +import { verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server"; +import type { AuthenticationResponseJSON, RegistrationResponseJSON } from "@simplewebauthn/types"; +import { dev } from "$app/environment"; const logger = new UniversalLogger().setContext("WebAuthnService"); @@ -25,222 +28,242 @@ export interface WebAuthnVerificationResult { export class WebAuthnService { /** - * Verify a WebAuthn authentication assertion + * Normalize credential ID to base64url format (defensive measure) + * Ensures credential IDs are consistently stored and queried + * @param id - Credential ID (should already be base64url from browser) + * @returns Normalized credential ID in base64url format + */ + private static normalizeCredentialId(id: string): string { + // Convert base64 to base64url if needed + // Base64url: no +, /, or = characters + return id.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + } + + /** + * Verify a WebAuthn authentication assertion using @simplewebauthn/server * @param credential - The WebAuthn credential from the client * @param challengeFromSession - The challenge that was sent to the client (should be stored in session) + * @param url - The request URL for dynamic RP ID and origin resolution * @returns Verification result with user ID if successful */ static async verifyAuthentication( credential: WebAuthnCredential, challengeFromSession: string, + url: URL, ): Promise { try { + // Normalize credential ID to base64url format + const normalizedCredentialId = WebAuthnService.normalizeCredentialId(credential.id); + logger.debug("Verifying WebAuthn authentication", { - credentialId: credential.id, + credentialId: normalizedCredentialId.substring(0, 20) + "...", + credentialIdLength: normalizedCredentialId.length, hasChallenge: !!challengeFromSession, }); - // Get the passkey from database + // Get the passkey from database using normalized ID const passkeyResults = await db .select() .from(userPasskey) - .where(eq(userPasskey.id, credential.id)) + .where(eq(userPasskey.id, normalizedCredentialId)) .limit(1); if (passkeyResults.length === 0) { - logger.warn("Passkey not found", { credentialId: credential.id }); + logger.warn("Passkey not found in database", { + credentialId: normalizedCredentialId.substring(0, 20) + "...", + }); return { verified: false }; } const passkey = passkeyResults[0]; - // Parse client data JSON - const clientDataJSON = JSON.parse( - Buffer.from(credential.response.clientDataJSON, "base64").toString(), - ); + // Get RP ID and allowed origins from request URL + const rpID = WebAuthnService.getRPID(url); + const allowedOrigins = WebAuthnService.getAllowedOrigins(url); - // Verify the challenge (convert base64url to base64 for comparison) - const expectedChallenge = challengeFromSession.replace(/-/g, "+").replace(/_/g, "/"); - const receivedChallenge = clientDataJSON.challenge.replace(/-/g, "+").replace(/_/g, "/"); + // Convert base64 to base64url (WebAuthn sends base64, @simplewebauthn expects base64url) + const base64ToBase64url = (base64: string): string => { + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + }; - if (expectedChallenge !== receivedChallenge) { - logger.warn("Challenge mismatch", { - credentialId: credential.id, - expectedChallenge, - receivedChallenge, - }); - return { verified: false }; - } + // Convert our credential format to @simplewebauthn format + const authResponse: AuthenticationResponseJSON = { + id: normalizedCredentialId, + rawId: normalizedCredentialId, + response: { + authenticatorData: base64ToBase64url(credential.response.authenticatorData), + clientDataJSON: base64ToBase64url(credential.response.clientDataJSON), + signature: base64ToBase64url(credential.response.signature), + userHandle: credential.response.userHandle + ? base64ToBase64url(credential.response.userHandle) + : undefined, + }, + type: "public-key", + clientExtensionResults: {}, + }; - // Verify the origin matches expected origin (critical for security) - const allowedOrigins = WebAuthnService.getAllowedOrigins(); - if (!allowedOrigins.includes(clientDataJSON.origin)) { - logger.warn("Origin verification failed", { - credentialId: credential.id, - receivedOrigin: clientDataJSON.origin, - allowedOrigins, - }); - return { verified: false }; - } - - logger.debug("Origin verification successful", { - origin: clientDataJSON.origin, - }); - - // Parse authenticator data - const authenticatorDataBuffer = Buffer.from(credential.response.authenticatorData, "base64"); - - // Parse authenticator data for detailed debugging - const parsedAuthData = WebAuthnService.parseAuthenticatorData(authenticatorDataBuffer); - - logger.debug("Authenticator data analysis", { - credentialId: credential.id, - bufferLength: authenticatorDataBuffer.length, - bufferHex: authenticatorDataBuffer.toString("hex"), - parsed: parsedAuthData, - }); - - // Extract counter from authenticator data - // Format: rpIdHash(32) + flags(1) + counter(4) + attestedCredentialData(variable) - // Counter is at bytes 33-36 (0-indexed) - if (authenticatorDataBuffer.length < 37) { - logger.warn("Authenticator data too short for counter extraction", { - credentialId: credential.id, - bufferLength: authenticatorDataBuffer.length, - }); - return { verified: false }; - } - - const newCounter = authenticatorDataBuffer.readUInt32BE(33); - - logger.debug("Counter extraction", { - credentialId: credential.id, - storedCounter: passkey.counter, - newCounter, - counterBytes: authenticatorDataBuffer.subarray(33, 37).toString("hex"), - }); - - // Some authenticators (especially software-based ones) always return 0 - // In that case, we skip counter verification but log it - if (newCounter === 0 && passkey.counter === 0) { - logger.info("Authenticator uses zero counter - skipping counter verification", { - credentialId: credential.id, - }); - } else if (newCounter <= passkey.counter) { - logger.warn("Counter verification failed - possible replay attack", { - credentialId: credential.id, - storedCounter: passkey.counter, - newCounter, - }); - return { verified: false }; - } - - // Create the data to be signed - const clientDataHash = createHash("sha256") - .update(Buffer.from(credential.response.clientDataJSON, "base64")) - .digest(); - - const signedData = Buffer.concat([authenticatorDataBuffer, clientDataHash]); - - // Verify the signature + // Create WebAuthnCredential object for @simplewebauthn/server + // Note: WebAuthnCredential has specific field names: id, publicKey, counter + // IMPORTANT: publicKey must be raw COSE-encoded Uint8Array (NOT decoded) + // @simplewebauthn/server will decode it internally during verification const publicKeyBuffer = Buffer.from(passkey.publicKey, "base64"); - const signatureBuffer = Buffer.from(credential.response.signature, "base64"); + const publicKeyUint8Array = new Uint8Array(publicKeyBuffer); - const isSignatureValid = await this.verifySignature( - publicKeyBuffer, - signedData, - signatureBuffer, - ); + const storedCredential = { + id: credential.id, // Base64URL-encoded credential ID + publicKey: publicKeyUint8Array, // COSE-encoded public key as Uint8Array + counter: passkey.counter, // Stored counter value + }; - if (!isSignatureValid) { - logger.warn("Signature verification failed", { credentialId: credential.id }); + logger.debug("WebAuthnCredential object created", { + id: storedCredential.id.substring(0, 20) + "...", + publicKeyLength: publicKeyUint8Array.length, + publicKeyBase64Sample: passkey.publicKey.substring(0, 60) + "...", + publicKeyBytesFirst10: Array.from(publicKeyUint8Array.slice(0, 10)), + counter: storedCredential.counter, + publicKeyType: typeof storedCredential.publicKey, + publicKeyConstructor: storedCredential.publicKey.constructor.name, + }); + + // Verify the authentication response using @simplewebauthn/server + const verification = await verifyAuthenticationResponse({ + response: authResponse, + expectedChallenge: challengeFromSession, + expectedOrigin: allowedOrigins, + expectedRPID: rpID, + credential: storedCredential, // WebAuthnCredential with id, publicKey, counter + requireUserVerification: false, // Set to true for higher security requirements + }); + + if (!verification.verified) { + logger.warn("@simplewebauthn verification failed", { + credentialId: credential.id, + }); return { verified: false }; } + const { authenticationInfo } = verification; + const newCounter = authenticationInfo.newCounter; + // Update the counter in the database to prevent replay attacks - // Only update if the authenticator provides a non-zero counter - if (newCounter > 0) { - await db - .update(userPasskey) - .set({ counter: newCounter }) - .where(eq(userPasskey.id, passkey.id)); + await db + .update(userPasskey) + .set({ + counter: newCounter, + lastUsedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(userPasskey.id, normalizedCredentialId)); - logger.debug("Counter updated in database", { - credentialId: credential.id, - newCounter, - }); - } else { - logger.debug("Counter update skipped (zero counter authenticator)", { - credentialId: credential.id, - }); - } - - logger.debug("WebAuthn authentication successful", { - credentialId: credential.id, + logger.info("WebAuthn authentication successful", { + credentialId: normalizedCredentialId.substring(0, 20) + "...", userId: passkey.userId, - newCounter, - counterUpdated: true, + counterUpdated: `${passkey.counter} → ${newCounter}`, }); return { verified: true, userId: passkey.userId, newCounter, - passkeyId: passkey.id, + passkeyId: normalizedCredentialId, }; } catch (error) { - logger.error("WebAuthn verification error", { + logger.error("WebAuthn authentication failed", { error: String(error), - credentialId: credential.id, + stack: error instanceof Error ? error.stack : undefined, }); return { verified: false }; } } /** - * Verify a signature using the stored public key - * This is a simplified implementation - in production, you should use a proper WebAuthn library - * like @simplewebauthn/server for complete verification + * Verify a WebAuthn registration response using @simplewebauthn/server + * Extracts the COSE public key from the attestation object + * @param url - The request URL for dynamic RP ID and origin resolution + * @returns Object with credentialID (base64url), credentialPublicKey (base64), and counter */ - private static async verifySignature( - publicKey: Buffer, - signedData: Buffer, - signature: Buffer, - ): Promise { + static async verifyRegistration( + credentialId: string, + attestationObjectBase64: string, + clientDataJSONBase64: string, + challengeFromSession: string, + url: URL, + ): Promise<{ credentialID: string; credentialPublicKey: string; counter: number }> { try { - const crypto = await import("node:crypto"); + const rpID = WebAuthnService.getRPID(url); + const allowedOrigins = WebAuthnService.getAllowedOrigins(url); - // This is a simplified implementation - // In production, you should use a proper WebAuthn library that handles: - // - Different key formats (COSE, etc.) - // - Different signature algorithms - // - Proper ASN.1 parsing - // - Certificate chain validation + // Normalize credential ID immediately + const normalizedCredentialId = WebAuthnService.normalizeCredentialId(credentialId); - // For now, we'll assume ES256 (ECDSA P-256 with SHA-256) - // and that the public key is in the correct format + // Convert base64 to base64url + const base64ToBase64url = (base64: string): string => { + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + }; - const verify = crypto.createVerify("SHA256"); - verify.update(signedData); - verify.end(); + // Create RegistrationResponseJSON for @simplewebauthn/server + const registrationResponse: RegistrationResponseJSON = { + id: normalizedCredentialId, + rawId: normalizedCredentialId, + response: { + attestationObject: base64ToBase64url(attestationObjectBase64), + clientDataJSON: base64ToBase64url(clientDataJSONBase64), + }, + type: "public-key", + clientExtensionResults: {}, + }; - // This is a placeholder - proper implementation would: - // 1. Parse the COSE key format - // 2. Convert to the correct format for Node.js crypto - // 3. Handle different algorithms properly - - logger.debug("Signature verification (simplified implementation)", { - publicKeyLength: publicKey.length, - signatureLength: signature.length, - signedDataLength: signedData.length, + logger.debug("Verifying registration", { + credentialId: normalizedCredentialId.substring(0, 20) + "...", + rpID, + allowedOrigins, }); - // For development, we'll return true if all data is present - // TODO: Replace with proper signature verification - return publicKey.length > 0 && signature.length > 0 && signedData.length > 0; + // Verify the registration response + const verification = await verifyRegistrationResponse({ + response: registrationResponse, + expectedChallenge: challengeFromSession, + expectedOrigin: allowedOrigins, + expectedRPID: rpID, + requireUserVerification: false, + }); + + if (!verification.verified) { + throw new Error("Registration verification failed"); + } + + const { registrationInfo } = verification; + if (!registrationInfo) { + throw new Error("No registration info returned"); + } + + // The credentialPublicKey is already in COSE format (Uint8Array) + // Convert to base64 for storage + // Note: credential is nested inside registrationInfo + const credentialPublicKeyBase64 = Buffer.from(registrationInfo.credential.publicKey).toString( + "base64", + ); + + logger.info("Registration verified successfully", { + credentialId: normalizedCredentialId.substring(0, 20) + "...", + credentialIdLength: normalizedCredentialId.length, + counter: registrationInfo.credential.counter, + publicKeyLength: registrationInfo.credential.publicKey.length, + publicKeyBytesFirst10: Array.from(registrationInfo.credential.publicKey.slice(0, 10)), + }); + + return { + credentialID: normalizedCredentialId, // Normalized to base64url for consistency + credentialPublicKey: credentialPublicKeyBase64, + counter: registrationInfo.credential.counter, + }; } catch (error) { - logger.error("Signature verification error", { error: String(error) }); - return false; + logger.error("Registration verification error", { + error: String(error), + stack: error instanceof Error ? error.stack : undefined, + credentialId, + }); + throw error; } } @@ -302,19 +325,49 @@ export class WebAuthnService { } /** - * Get allowed origins for WebAuthn verification - * Uses SERVER_DOMAIN in production, localhost variants in development + * Get Relying Party ID for WebAuthn verification + * Dynamically resolves from request URL to support multi-tenant subdomains + * @param url - The request URL */ - private static getAllowedOrigins(): string[] { + private static getRPID(url: URL): string { + if (process.env.NODE_ENV === "production") { + // In production, use the hostname (without subdomain for main domain) + const hostname = url.hostname; + const parts = hostname.split("."); + + // If it's a subdomain (e.g., tenant.example.com), use the main domain (example.com) + // This allows passkeys to work across all subdomains + if (parts.length > 2) { + return parts.slice(-2).join("."); + } + return hostname; + } + + // Development: use localhost + return "localhost"; + } + + /** + * Get allowed origins for WebAuthn verification + * Dynamically resolves from request URL to support multi-tenant subdomains + * @param url - The request URL + */ + private static getAllowedOrigins(url: URL): string[] { const origins: string[] = []; - // Check if we're in development (NODE_ENV or presence of dev indicators) - const isDevelopment = - process.env.NODE_ENV === "development" || - process.env.NODE_ENV === "dev" || - !process.env.SERVER_DOMAIN; + if (!dev) { + // Production: Use the request origin + const origin = url.origin; // e.g., https://tenant.example.com or https://example.com + origins.push(origin); - if (isDevelopment) { + // Also allow the main domain (without subdomain) to support cross-subdomain passkey usage + const hostname = url.hostname; + const parts = hostname.split("."); + if (parts.length > 2) { + const mainDomain = parts.slice(-2).join("."); + origins.push(`https://${mainDomain}`); + } + } else { // Development origins origins.push( "http://localhost:5173", @@ -322,20 +375,6 @@ export class WebAuthnService { "http://localhost:4173", "http://127.0.0.1:4173", ); - } else { - // Production: Use SERVER_DOMAIN - const serverDomain = process.env.SERVER_DOMAIN; - if (serverDomain) { - // Add main domain with HTTPS - origins.push(`https://${serverDomain}`); - - // Add www variant if it doesn't already start with www - if (!serverDomain.startsWith("www.")) { - origins.push(`https://www.${serverDomain}`); - } - - // TODO: Webauthn does not support wildcard domains. We'll need another solution for that. - } } // Allow manual override via WEBAUTHN_ALLOWED_ORIGINS @@ -344,23 +383,10 @@ export class WebAuthnService { origins.push(...envOrigins.split(",").map((o) => o.trim())); } - // Ensure we have at least one allowed origin - if (origins.length === 0) { - logger.error("No WebAuthn allowed origins configured - this is a security risk!", { - NODE_ENV: process.env.NODE_ENV, - SERVER_DOMAIN: process.env.SERVER_DOMAIN, - WEBAUTHN_ALLOWED_ORIGINS: process.env.WEBAUTHN_ALLOWED_ORIGINS, - }); - // Fallback to localhost in development only - if (isDevelopment) { - origins.push("http://localhost:5173"); - } - } - logger.debug("WebAuthn allowed origins configured", { origins, - isDevelopment, - serverDomain: process.env.SERVER_DOMAIN, + requestOrigin: url.origin, + hostname: url.hostname, }); return origins; diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index 0034d46..648c23f 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -6,6 +6,7 @@ export interface PasskeyAuthData { authenticatorData: string; passkeyId: string; email: string; + prfOutput?: string; // Base64-encoded PRF output for staff crypto key derivation } export interface AuthState { diff --git a/src/lib/stores/staff-crypto.ts b/src/lib/stores/staff-crypto.ts index 00f354a..e05de96 100644 --- a/src/lib/stores/staff-crypto.ts +++ b/src/lib/stores/staff-crypto.ts @@ -19,49 +19,17 @@ const createStaffCryptoStore = () => { ...store, /** - * Initialize crypto from stored authenticator data after login - * This reconstructs the private key using the passkey data from auth store + * Initialize crypto with WebAuthn PRF authentication + * + * NOTE: With PRF-based key derivation, we MUST perform a live WebAuthn interaction + * to derive the secret shard. Session-based reconstruction is not possible. + * + * This method now delegates to `authenticate()` which performs WebAuthn with PRF. */ async initFromSession(staffId: string, tenantId: string): Promise { - try { - // Check if we have passkey auth data from login - const passkeyAuthData = auth.getPasskeyAuthData(); - if (!passkeyAuthData) { - console.warn("No passkey auth data found in auth store"); - return false; - } - - const { authenticatorData, passkeyId } = passkeyAuthData; - - // Reconstruct the private key using the stored data - const crypto = new UnifiedAppointmentCrypto(); - - // Use the stored authenticator data to reconstruct keys - await crypto.reconstructStaffKeysFromSession( - staffId, - tenantId, - passkeyId, - authenticatorData, - ); - - store.set({ - crypto, - isAuthenticated: true, - error: null, - }); - - console.log("✅ Staff crypto initialized from session"); - return true; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Failed to init from session"; - store.set({ - crypto: null, - isAuthenticated: false, - error: errorMessage, - }); - console.error("Failed to initialize staff crypto from session:", error); - return false; - } + // With PRF, we always need WebAuthn interaction - session data is not sufficient + // Call authenticate() directly which will prompt for passkey + return await this.authenticate(staffId, tenantId); }, /** diff --git a/src/lib/utils/passkey.ts b/src/lib/utils/passkey.ts index 7fb70f7..0375829 100644 --- a/src/lib/utils/passkey.ts +++ b/src/lib/utils/passkey.ts @@ -88,14 +88,18 @@ export const getCredentialOptions = ({ id, challenge, email, + enablePRF = false, }: { id: string; challenge: string; email: string; + enablePRF?: boolean; }): { publicKey: PublicKeyCredentialCreationOptions; } => { - return { + const options: { + publicKey: PublicKeyCredentialCreationOptions; + } = { publicKey: { challenge: base64UrlToArrayBuffer(challenge), rp: { @@ -112,41 +116,202 @@ export const getCredentialOptions = ({ ], }, }; + + // 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 }; + 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 => { - const options = getCredentialOptions({ id, challenge, email }); + const options = getCredentialOptions({ id, challenge, email, enablePRF }); return (await navigator.credentials.create(options)) as GeneratePasskeyResponse; }; -export type GetCredentialResponse = PublicKeyCredential & { - response: PublicKeyCredential; - id: string; +/** + * 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 => { + // 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, }: { id: string; challenge: string; email: string; -}) => { - const options = getCredentialOptions({ id, challenge, email }); - return (await navigator.credentials.get(options)) as GetCredentialResponse; + enablePRF?: boolean; +}): Promise => { + // Build WebAuthn options manually to support PRF + const challengeBuffer = base64UrlToArrayBuffer(challenge); + + const publicKeyOptions: PublicKeyCredentialRequestOptions = { + challenge: challengeBuffer, + rpId: id, + userVerification: "preferred", + }; + + // 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) => { diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte index 04a2d3b..4108bfe 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte @@ -85,7 +85,9 @@ {#if isThrottled} - + {m["public.steps.auth.login.throttled"]()} diff --git a/src/routes/(pages)/confirm/setup-passkey/+page.server.ts b/src/routes/(pages)/confirm/setup-passkey/+page.server.ts index 47c0699..168dd9b 100644 --- a/src/routes/(pages)/confirm/setup-passkey/+page.server.ts +++ b/src/routes/(pages)/confirm/setup-passkey/+page.server.ts @@ -4,7 +4,6 @@ import { zod4 as zod } from "sveltekit-superforms/adapters"; import type { Actions, PageServerLoad } from "./$types"; import { formSchema } from "./schema"; import logger from "$lib/logger"; -import { base64ToArrayBuffer, getCounterFromAuthenticatorData } from "$lib/utils/passkey"; const log = logger.setContext(import.meta.filename); @@ -25,26 +24,20 @@ export const actions: Actions = { }); } - const authenticatorData = base64ToArrayBuffer(form.data.authenticatorDataBase64); - const counter = getCounterFromAuthenticatorData(authenticatorData); const resp = await event.fetch(`/api/auth/register/${form.data.userId}`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ - id: form.data.id, email: form.data.email, + challenge: form.data.challenge, // Send original registration challenge passkey: { id: form.data.id, - publicKey: form.data.publicKeyBase64, - deviceName: "Unkown Device", - counter, - response: { - authenticatorData: form.data.authenticatorDataBase64, - }, + attestationObject: form.data.attestationObjectBase64, + clientDataJSON: form.data.clientDataJSONBase64, + deviceName: "Unknown Device", }, - counter, }), }); diff --git a/src/routes/(pages)/confirm/setup-passkey/schema.ts b/src/routes/(pages)/confirm/setup-passkey/schema.ts index 23ea4d0..fae8bef 100644 --- a/src/routes/(pages)/confirm/setup-passkey/schema.ts +++ b/src/routes/(pages)/confirm/setup-passkey/schema.ts @@ -5,8 +5,9 @@ export const formSchema = z.object({ userId: z.string().min(3), id: z.string().min(3), email: z.string().email(m["form.errors.email"]()), - publicKeyBase64: z.string().base64(), - authenticatorDataBase64: z.string().base64(), + 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)/confirm/setup-passkey/setup-passkey-form.svelte b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte index a9c29e3..75afa9a 100644 --- a/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte +++ b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte @@ -9,7 +9,12 @@ 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 } from "$lib/utils/passkey"; + 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"; @@ -28,8 +33,9 @@ let tenantId: string | undefined = $state(); let passkeyId: string | undefined = $state(); - let authenticatorData: ArrayBuffer | undefined = $state(); + let prfOutput: ArrayBuffer | undefined = $state(); let kyberKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array } | undefined = $state(); + let registrationChallenge: string | undefined = $state(); // Store first challenge for form submission const form = superForm(data.form, { validators: zodClient(formSchema), @@ -68,7 +74,7 @@ $passkeyLoading = "loading"; // Generate Kyber keypair BEFORE passkey registration - // This keypair will be used to create the dbShard after authenticatorData is available + // This keypair will be used to create the dbShard after PRF output is available const { KyberCrypto } = await import("$lib/crypto/utils"); kyberKeyPair = KyberCrypto.generateKeyPair(); @@ -78,13 +84,18 @@ logger.error("Failed to fetch challenge", { email: $formData.email }); $passkeyLoading = "error"; } else { + // Store the registration challenge - will be sent with form to avoid cookie overwrite by PRF challenge + registrationChallenge = challenge.challenge; + $passkeyLoading = "user"; - const passkeyResp = await generatePasskey({ ...challenge, email: $formData.email }).catch( - (error) => { - $passkeyLoading = "error"; - logger.error("Failed to generate passkey", { ...challenge, error }); - }, - ); + 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"; @@ -92,30 +103,71 @@ return; } - // Returns ArrayBuffer that has to be converted to base64 string - const publicKey = passkeyResp.response.getPublicKey(); - if (!publicKey) { + // Verify PRF extension is enabled + const extensionResults = passkeyResp.getClientExtensionResults(); + if (!extensionResults.prf?.enabled) { $passkeyLoading = "error"; - logger.error("Failed to get public key", { email: $formData.email }); + logger.error("PRF extension not enabled - passkey rejected", { + email: $formData.email, + extensions: extensionResults, + }); + toast.error( + "This authenticator does not support the required security extension (PRF). Please use a modern authenticator like YubiKey 5.2.3+, Titan Gen2, Windows Hello, Touch ID, or Android.", + ); return; } - // May include device name and counter - const authenticatorDataResp = passkeyResp.response.getAuthenticatorData(); + // Get attestationObject and clientDataJSON for @simplewebauthn/server verification + const attestationObjectResp = passkeyResp.response.attestationObject; + const clientDataJSONResp = passkeyResp.response.clientDataJSON; - // Update form data with passkey info - const publicKeyBase64 = arrayBufferToBase64(publicKey); - const authenticatorDataBase64 = arrayBufferToBase64(authenticatorDataResp); + // 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); + 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", { + passkeyId: passkeyResp.id, + prfOutputLength: prfOutputResp.byteLength, + }); + } catch (error) { + $passkeyLoading = "error"; + logger.error("Failed to get PRF output", { + email: $formData.email, + passkeyId: passkeyResp.id, + error, + }); + toast.error( + "Failed to retrieve security data from passkey. Please use a modern authenticator that supports PRF extension.", + ); + 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, - publicKeyBase64, - authenticatorDataBase64, + attestationObjectBase64, + clientDataJSONBase64, + challenge: registrationChallenge!, // Send original registration challenge (not PRF challenge) }; // Set for later use passkeyId = passkeyResp.id; - authenticatorData = authenticatorDataResp; // Update UI to show passkey is ready $passkeyLoading = "success"; @@ -136,10 +188,10 @@ }); const storeStaffKeyPair = async () => { - if (tenantId && passkeyId && authenticatorData && kyberKeyPair) { + if (tenantId && passkeyId && prfOutput && kyberKeyPair) { const crypto = new UnifiedAppointmentCrypto(); return await crypto - .storeStaffKeyPair(tenantId, $formData.userId, passkeyId, authenticatorData, kyberKeyPair) + .storeStaffKeyPair(tenantId, $formData.userId, passkeyId, prfOutput, kyberKeyPair) .then(() => { toast.success(m["setupPasskey.successKeyPairSaved"]()); }) @@ -153,10 +205,11 @@ }); }); } else { - logger.error("Failed to store staff key pair", { + 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"]()); @@ -189,17 +242,24 @@ {/snippet} -