Security hotfix prf (#146)

* Use PRF extension for deterministic Zero Knowledge Shards

* Webauthn validation lib

* Use attestation and validate passkeys

* Use attestation objects and cose-format keys in frontend. Added additional checks so that we don't create orphaned users when validation fails.

* Fixed type check error

* Fix PRF salt documentation to match implementation (#147)

* Initial plan

* Fix PRF salt documentation to match implementation

Co-authored-by: hbel <7416029+hbel@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: hbel <7416029+hbel@users.noreply.github.com>

* Use dev instead of node env

* Migration fixes

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: hbel <7416029+hbel@users.noreply.github.com>
This commit is contained in:
Hendrik
2026-01-02 15:08:23 +01:00
committed by GitHub
co-authored by hbel copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Copilot
parent 337fc2e70b
commit b81706a8d9
25 changed files with 1168 additions and 1444 deletions
+106 -32
View File
@@ -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: