mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-29 04:04:51 +02:00
1642 lines
56 KiB
TypeScript
1642 lines
56 KiB
TypeScript
/**
|
|
* Unified End-to-End Encryption for Appointments
|
|
*
|
|
* This class implements browser-side cryptography for both clients and staff members.
|
|
* All sensitive operations happen in the browser with zero-knowledge architecture.
|
|
*
|
|
* ## Client (Patient) Usage:
|
|
* ```javascript
|
|
* const crypto = new UnifiedAppointmentCrypto();
|
|
* await crypto.initNewClient(email, pin, tenantId);
|
|
* await crypto.loginExistingClient(email, pin, tenantId);
|
|
* const appointment = await crypto.createAppointment(appointmentData, appointmentDate, channelId, tenantId);
|
|
* const myAppointments = await crypto.getMyAppointments(tenantId);
|
|
* ```
|
|
*
|
|
* ## Staff Usage:
|
|
* ```javascript
|
|
* const crypto = new UnifiedAppointmentCrypto();
|
|
* await crypto.authenticateStaff(staffId, tenantId);
|
|
* const appointments = await crypto.getStaffAppointments(tenantId);
|
|
* const decrypted = await crypto.decryptStaffAppointment(encryptedData);
|
|
* ```
|
|
*
|
|
* ## Staff Key Management (during Passkey Registration):
|
|
* ```javascript
|
|
* // 1. Generate Kyber keypair in browser
|
|
* const keyPair = KyberCrypto.generateKeyPair();
|
|
*
|
|
* // 2. Derive passkey-based shard from WebAuthn authenticatorData
|
|
* const passkeyBasedShard = await this.derivePasskeyBasedShard(passkeyId, authenticatorData);
|
|
*
|
|
* // 3. Create database shard using XOR split
|
|
* const dbShard = new Uint8Array(keyPair.privateKey.length);
|
|
* for (let i = 0; i < keyPair.privateKey.length; i++) {
|
|
* dbShard[i] = keyPair.privateKey[i] ^ passkeyBasedShard[i];
|
|
* }
|
|
*
|
|
* // 4. Store keys via StaffCryptoService API
|
|
* await fetch(`/api/tenants/${tenantId}/staff/${userId}/crypto`, {
|
|
* method: 'POST',
|
|
* body: JSON.stringify({
|
|
* passkeyId,
|
|
* publicKey: this.uint8ArrayToBase64(keyPair.publicKey),
|
|
* privateKeyShare: this.uint8ArrayToBase64(dbShard)
|
|
* })
|
|
* });
|
|
* ```
|
|
*/
|
|
|
|
import type { BootstrapChallengeResponse, BootstrapVerifyResponse } from "$lib/types/appointment";
|
|
import { OptimizedArgon2 } from "$lib/crypto/hashing";
|
|
import { AESCrypto, BufferUtils, KyberCrypto, ShamirSecretSharing } from "$lib/crypto/utils";
|
|
import type { ClientTunnelResponse } from "$lib/server/services/appointment-service";
|
|
import { pinThrottleStore } from "$lib/stores/pin-throttle";
|
|
|
|
// Type definitions for unified cryptography
|
|
interface ClientKeyPair {
|
|
publicKey: string;
|
|
privateKey: string;
|
|
}
|
|
|
|
interface StaffKeyPair {
|
|
publicKey: Uint8Array;
|
|
privateKey: Uint8Array;
|
|
}
|
|
|
|
interface EncryptedData {
|
|
encryptedPayload: string;
|
|
iv: string;
|
|
authTag: string;
|
|
}
|
|
|
|
export interface AppointmentData {
|
|
salutation?: string;
|
|
name: string;
|
|
email: string;
|
|
shareEmail: boolean;
|
|
phone?: string;
|
|
locale?: string;
|
|
}
|
|
|
|
export type AppointmentDataByStaff = Omit<AppointmentData, "email"> & {
|
|
email?: string;
|
|
};
|
|
|
|
interface StaffPublicKey {
|
|
userId: string;
|
|
publicKey: string;
|
|
}
|
|
|
|
export interface DecryptedAppointment {
|
|
id: string;
|
|
appointmentDate: string;
|
|
status: string;
|
|
name: string;
|
|
email: string;
|
|
shareEmail: boolean;
|
|
phone?: string;
|
|
}
|
|
|
|
interface MyAppointmentsResponse {
|
|
appointments: Array<{
|
|
id: string;
|
|
appointmentDate: string;
|
|
status: string;
|
|
encryptedData: EncryptedData;
|
|
}>;
|
|
}
|
|
|
|
type StaffKeyShares = Array<{ userId: string; encryptedTunnelKey: string }>;
|
|
|
|
type EncryptableTunnelConfig = {
|
|
emailHash: string;
|
|
decryptedTunnelKey: CryptoKey;
|
|
staffKeyShares: StaffKeyShares;
|
|
clientPublicKey: string;
|
|
tunnelId: string;
|
|
};
|
|
|
|
/**
|
|
* Generate deterministic SHA-256 hash of email for privacy-preserving lookups
|
|
*/
|
|
export const hashEmail = async (email: string): Promise<string> => {
|
|
const emailNormalized = email.toLowerCase().trim();
|
|
const encoder = new TextEncoder();
|
|
const data = encoder.encode(emailNormalized);
|
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
return Array.from(new Uint8Array(hashBuffer))
|
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
.join("");
|
|
};
|
|
|
|
export class UnifiedAppointmentCrypto {
|
|
// Client-specific properties
|
|
private tunnelKey: CryptoKey | null = null;
|
|
private clientKeyPair: ClientKeyPair | null = null;
|
|
private emailHash: string | null = null;
|
|
private tunnelId: string | null = null;
|
|
private clientAuthenticated: boolean = false;
|
|
private serverPrivateKeyShare: string | null = null; // Server share of the private key
|
|
private pin: string | null = null;
|
|
private bookingAccessToken: string | null = null;
|
|
|
|
// Staff-specific properties
|
|
private staffKeyPair: StaffKeyPair | null = null;
|
|
private staffId: string | null = null;
|
|
private tenantId: string | null = null;
|
|
private staffAuthenticated: boolean = false;
|
|
private keyExpiry: number | null = null;
|
|
|
|
// Shared crypto utilities
|
|
private kyberCrypto: KyberCrypto = new KyberCrypto();
|
|
private aesCrypto: AESCrypto = new AESCrypto();
|
|
private shamirSharing: ShamirSecretSharing = new ShamirSecretSharing();
|
|
|
|
// ===== CLIENT (PATIENT) METHODS =====
|
|
|
|
/**
|
|
* Run a precheck
|
|
*/
|
|
async preCheck(email: string, tenantId: string): Promise<boolean> {
|
|
try {
|
|
const emailHash = await hashEmail(email);
|
|
|
|
const response = await fetch(`/api/tenants/${tenantId}/appointments/challenge`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ emailHash }),
|
|
});
|
|
|
|
return response.ok;
|
|
} catch (error) {
|
|
console.error("❌ Error running pre-check:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initializes a new client with E2E encryption
|
|
*/
|
|
async initNewClient(email: string, pin: string, tenantId: string): Promise<void> {
|
|
try {
|
|
// 1. Generate email hash for privacy-preserving lookup
|
|
this.emailHash = await hashEmail(email);
|
|
|
|
// 2. Generate tunnel ID
|
|
this.tunnelId = this.generateTunnelId();
|
|
|
|
// 3. Generate ML-KEM-768 keypair
|
|
this.clientKeyPair = await this.generateClientKeyPair();
|
|
|
|
// 4. Generate tunnel key for AES encryption
|
|
this.tunnelKey = await this.generateTunnelKey();
|
|
|
|
// 5. Create server share of private key (PIN-based split)
|
|
// Server share will be sent to server during appointment creation
|
|
this.serverPrivateKeyShare = await this.createPrivateKeyShare(
|
|
this.clientKeyPair.privateKey,
|
|
pin,
|
|
);
|
|
|
|
// 6. Complete bootstrap challenge to obtain a short-lived booking token
|
|
await this.bootstrapNewClientAccess(tenantId);
|
|
|
|
// 7. Fetch staff public keys from server
|
|
const staffPublicKeys = await this.fetchStaffPublicKeys(tenantId);
|
|
|
|
// 8. Encrypt tunnel key for all staff members
|
|
// Note: staffKeyShares will be used during actual appointment creation
|
|
await this.encryptTunnelKeyForStaff(staffPublicKeys);
|
|
|
|
// 9. Encrypt tunnel key for client (for later use)
|
|
// Note: clientKeyShare will be used during actual appointment creation
|
|
await this.encryptTunnelKeyForClient();
|
|
|
|
console.log("✅ New client initialized", {});
|
|
|
|
this.clientAuthenticated = true;
|
|
} catch (error) {
|
|
console.error("❌ Error during client initialization:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel an appointment for a client
|
|
*/
|
|
async cancelAppointmentByClient(opts: {
|
|
tenant: string;
|
|
appointment: string;
|
|
email: string;
|
|
}): Promise<void> {
|
|
try {
|
|
if (this.pin === null) {
|
|
throw new Error(
|
|
"⚠️ Secure session timed out. Abort cancellation of appointment. Please login again",
|
|
);
|
|
}
|
|
|
|
// 1. Generate email hash
|
|
this.emailHash = await hashEmail(opts.email);
|
|
|
|
// 2. Request challenge from server
|
|
const challengeResponse = await fetch(`/api/tenants/${opts.tenant}/appointments/challenge`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ emailHash: this.emailHash }),
|
|
});
|
|
|
|
if (!challengeResponse.ok) {
|
|
if (challengeResponse.status === 429) {
|
|
const errorData = await challengeResponse.json();
|
|
const retryAfterMs = errorData.retryAfterMs || 60000;
|
|
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
|
|
|
|
// Store throttle state for frontend to enforce
|
|
pinThrottleStore.setThrottle(this.emailHash, retryAfterMs, errorData.failedAttempts || 0);
|
|
|
|
if (retryAfterSeconds > 0) {
|
|
throw new Error(
|
|
`Too many failed attempts. Please try again in ${retryAfterSeconds} seconds.`,
|
|
);
|
|
} else {
|
|
throw new Error("Too many failed attempts. Please try again later.");
|
|
}
|
|
}
|
|
throw new Error("Challenge could not be retrieved");
|
|
}
|
|
|
|
const challengeData = await challengeResponse.json();
|
|
|
|
// 3. Reconstruct private key from PIN and server share
|
|
const privateKey = await this.reconstructPrivateKey(this.pin, challengeData.privateKeyShare);
|
|
|
|
// 4. Decrypt challenge
|
|
const decryptedChallenge = await this.decryptChallenge(
|
|
challengeData.encryptedChallenge,
|
|
privateKey,
|
|
);
|
|
|
|
// 5. Send challenge response to server
|
|
const cancelResponse = await fetch(
|
|
`/api/tenants/${opts.tenant}/appointments/${opts.appointment}/delete-by-client`,
|
|
{
|
|
method: "DELETE",
|
|
body: JSON.stringify({
|
|
emailHash: this.emailHash,
|
|
challengeId: challengeData.challengeId,
|
|
challengeResponse: decryptedChallenge,
|
|
}),
|
|
},
|
|
);
|
|
|
|
if (!cancelResponse.ok) {
|
|
const errorData = await cancelResponse.json();
|
|
console.error("❌ Cancelling appointment failed:", errorData);
|
|
if (cancelResponse.status === 429) {
|
|
const retryAfterMs = errorData.retryAfterMs || 60000;
|
|
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
|
|
|
|
// Store throttle state for frontend to enforce
|
|
pinThrottleStore.setThrottle(this.emailHash, retryAfterMs, errorData.failedAttempts || 0);
|
|
|
|
if (retryAfterSeconds > 0) {
|
|
throw new Error(
|
|
`Too many failed attempts. Please try again in ${retryAfterSeconds} seconds.`,
|
|
);
|
|
} else {
|
|
throw new Error("Too many failed attempts. Please try again later.");
|
|
}
|
|
}
|
|
throw new Error("Challenge verification failed");
|
|
}
|
|
|
|
// Clear throttle on successful authentication
|
|
pinThrottleStore.clearThrottle();
|
|
} catch (error) {
|
|
console.error("❌ Error during client cancels appointment:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Authenticates an existing client using challenge-response
|
|
*/
|
|
async loginExistingClient(email: string, pin: string, tenantId: string): Promise<void> {
|
|
try {
|
|
// 1. Generate email hash
|
|
this.emailHash = await hashEmail(email);
|
|
|
|
// 2. Request challenge from server
|
|
const challengeResponse = await fetch(`/api/tenants/${tenantId}/appointments/challenge`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ emailHash: this.emailHash }),
|
|
});
|
|
|
|
if (!challengeResponse.ok) {
|
|
if (challengeResponse.status === 429) {
|
|
const errorData = await challengeResponse.json();
|
|
const retryAfterMs = errorData.retryAfterMs || 60000;
|
|
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
|
|
|
|
// Store throttle state for frontend to enforce
|
|
pinThrottleStore.setThrottle(this.emailHash, retryAfterMs, errorData.failedAttempts || 0);
|
|
|
|
if (retryAfterSeconds > 0) {
|
|
throw new Error(
|
|
`Too many failed attempts. Please try again in ${retryAfterSeconds} seconds.`,
|
|
);
|
|
} else {
|
|
throw new Error("Too many failed attempts. Please try again later.");
|
|
}
|
|
}
|
|
throw new Error("Challenge could not be retrieved");
|
|
}
|
|
|
|
const challengeData = await challengeResponse.json();
|
|
|
|
// 3. Reconstruct private key from PIN and server share
|
|
const privateKey = await this.reconstructPrivateKey(pin, challengeData.privateKeyShare);
|
|
|
|
// 4. Decrypt challenge
|
|
const decryptedChallenge = await this.decryptChallenge(
|
|
challengeData.encryptedChallenge,
|
|
privateKey,
|
|
);
|
|
|
|
// 5. Send challenge response to server
|
|
const verificationResponse = await fetch(
|
|
`/api/tenants/${tenantId}/appointments/verify-challenge`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
challengeId: challengeData.challengeId,
|
|
challengeResponse: decryptedChallenge,
|
|
}),
|
|
},
|
|
);
|
|
|
|
if (!verificationResponse.ok) {
|
|
const errorData = await verificationResponse.json();
|
|
console.error("❌ Challenge verification failed:", errorData);
|
|
if (verificationResponse.status === 429) {
|
|
const retryAfterMs = errorData.retryAfterMs || 60000;
|
|
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
|
|
|
|
// Store throttle state for frontend to enforce
|
|
pinThrottleStore.setThrottle(this.emailHash, retryAfterMs, errorData.failedAttempts || 0);
|
|
|
|
if (retryAfterSeconds > 0) {
|
|
throw new Error(
|
|
`Too many failed attempts. Please try again in ${retryAfterSeconds} seconds.`,
|
|
);
|
|
} else {
|
|
throw new Error("Too many failed attempts. Please try again later.");
|
|
}
|
|
}
|
|
throw new Error("Challenge verification failed");
|
|
}
|
|
|
|
const verificationData = await verificationResponse.json();
|
|
|
|
// 6. Decrypt tunnel key and store tunnel ID
|
|
this.tunnelKey = await this.decryptTunnelKey(verificationData.encryptedTunnelKey, privateKey);
|
|
this.tunnelId = verificationData.tunnelId;
|
|
this.bookingAccessToken = verificationData.bookingAccessToken;
|
|
|
|
this.clientAuthenticated = true;
|
|
|
|
// Clear throttle on successful authentication
|
|
pinThrottleStore.clearThrottle();
|
|
this.pin = pin;
|
|
} catch (error) {
|
|
console.error("❌ Error during client login:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a new encrypted appointment
|
|
*/
|
|
async createAppointment(
|
|
appointmentData: AppointmentData,
|
|
appointmentDate: string,
|
|
agentId: string,
|
|
channelId: string,
|
|
duration: number,
|
|
tenantId: string,
|
|
isFirstAppointment: boolean = false,
|
|
clientLanguage: string = "de",
|
|
): Promise<string> {
|
|
if (!this.clientAuthenticated || !this.tunnelKey) {
|
|
throw new Error("Client not authenticated");
|
|
}
|
|
|
|
try {
|
|
// 1. Encrypt appointment data
|
|
const encryptedAppointment = await this.encryptAppointmentData(appointmentData);
|
|
|
|
// 2. Call appropriate endpoint
|
|
const endpoint = isFirstAppointment
|
|
? `/api/tenants/${tenantId}/appointments/create-new-client`
|
|
: `/api/tenants/${tenantId}/appointments/add-to-tunnel`;
|
|
|
|
const appointmentTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
|
|
const requestData = isFirstAppointment
|
|
? {
|
|
// New client
|
|
tunnelId: this.tunnelId,
|
|
agentId,
|
|
channelId,
|
|
appointmentDate,
|
|
appointmentTimeZone,
|
|
duration,
|
|
emailHash: this.emailHash,
|
|
clientEmail: appointmentData.shareEmail ? appointmentData.email : undefined,
|
|
clientLanguage,
|
|
clientPublicKey: this.clientKeyPair?.publicKey,
|
|
privateKeyShare: await this.getPrivateKeyShare(),
|
|
encryptedAppointment,
|
|
staffKeyShares: await this.getStaffKeyShares(tenantId),
|
|
clientKeyShare: await this.getClientKeyShare(),
|
|
clientEncryptedTunnelKey: await this.encryptTunnelKeyForClient(),
|
|
salutation: appointmentData.salutation,
|
|
}
|
|
: {
|
|
// Existing client
|
|
emailHash: this.emailHash,
|
|
tunnelId: this.tunnelId!,
|
|
agentId,
|
|
channelId,
|
|
appointmentDate,
|
|
appointmentTimeZone,
|
|
duration,
|
|
clientEmail: appointmentData.shareEmail ? appointmentData.email : undefined,
|
|
clientLanguage,
|
|
encryptedAppointment,
|
|
salutation: appointmentData.salutation,
|
|
};
|
|
|
|
const response = await fetch(endpoint, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(isFirstAppointment && this.bookingAccessToken
|
|
? { Authorization: `Bearer ${this.bookingAccessToken}` }
|
|
: {}),
|
|
},
|
|
body: JSON.stringify(requestData),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Appointment could not be created");
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
console.log("✅ Encrypted appointment created:", result.id);
|
|
return result.id;
|
|
} catch (error) {
|
|
console.error("❌ Error creating appointment:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private createTunnelForNewClient = async (params: {
|
|
tenantId: string;
|
|
email: string;
|
|
}): Promise<EncryptableTunnelConfig> => {
|
|
const usedPin = crypto.randomUUID().slice(0, 6);
|
|
|
|
// Create tunnel
|
|
await this.initNewClient(params.email, usedPin, params.tenantId);
|
|
|
|
if (!this.tunnelId) {
|
|
throw new Error("Failed to use initialized client tunnel");
|
|
}
|
|
|
|
if (!this.tunnelKey) {
|
|
throw new Error("Failed to use initialized client tunnel key");
|
|
}
|
|
|
|
if (!this.clientKeyPair?.publicKey) {
|
|
throw new Error("Failed to use initialized client public key");
|
|
}
|
|
|
|
return {
|
|
tunnelId: this.tunnelId,
|
|
emailHash: await hashEmail(params.email),
|
|
clientPublicKey: this.clientKeyPair?.publicKey,
|
|
decryptedTunnelKey: this.tunnelKey,
|
|
staffKeyShares: await this.getStaffKeyShares(params.tenantId),
|
|
};
|
|
};
|
|
|
|
private useTunnelForExistingClient = async (params: {
|
|
tunnel: ClientTunnelResponse;
|
|
tenantId: string;
|
|
email: string;
|
|
}): Promise<EncryptableTunnelConfig> => {
|
|
const decryptedTunnelKey = await this.decryptTunnelKeyByStaff(
|
|
params.tunnel.currentStaffEncryptedTunnelKey!,
|
|
);
|
|
return {
|
|
tunnelId: params.tunnel.id,
|
|
emailHash: await hashEmail(params.email),
|
|
clientPublicKey: params.tunnel.clientPublicKey,
|
|
decryptedTunnelKey,
|
|
staffKeyShares: [], // Not needed for existing clients as tunnel key is already encrypted for staff in this flow
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Creates a new encrypted appointment that is created by a staff member
|
|
*/
|
|
async createAppointmentByStaff(params: {
|
|
appointmentData: AppointmentDataByStaff;
|
|
appointmentDate: Date;
|
|
agentId: string;
|
|
channelId: string;
|
|
duration: number;
|
|
tenantId: string;
|
|
email?: string;
|
|
hasNoEmail: boolean;
|
|
tunnel: ClientTunnelResponse | undefined;
|
|
}): Promise<string> {
|
|
if (!this.staffAuthenticated || !this.staffKeyPair) {
|
|
throw new Error("Staff Member not authenticated");
|
|
}
|
|
|
|
try {
|
|
// If new client, create new tunnel and then get it
|
|
const usedEmail = params.email ?? `${crypto.randomUUID()}@client.noemail`;
|
|
const tunnelConfig = !params.tunnel
|
|
? await this.createTunnelForNewClient({ tenantId: params.tenantId, email: usedEmail })
|
|
: await this.useTunnelForExistingClient({
|
|
tunnel: params.tunnel,
|
|
tenantId: params.tenantId,
|
|
email: usedEmail,
|
|
});
|
|
|
|
// Encrypt appointment data
|
|
const encryptedAppointment = await this.encryptAppointmentData(
|
|
params.appointmentData,
|
|
tunnelConfig.decryptedTunnelKey,
|
|
);
|
|
|
|
// Set tunnelKey & clientKeyPair for encryptTunnelKeyForClient
|
|
this.tunnelKey = tunnelConfig.decryptedTunnelKey;
|
|
this.clientKeyPair = {
|
|
publicKey: tunnelConfig.clientPublicKey,
|
|
privateKey: "", // Not needed here
|
|
};
|
|
|
|
// Call endpoint
|
|
const appointmentTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
const sendEmail = params.appointmentData.shareEmail && Boolean(params.email);
|
|
const requestData = {
|
|
clientEmail: params.appointmentData.shareEmail ? usedEmail : undefined,
|
|
hasNoEmail: params.hasNoEmail,
|
|
emailHash: await hashEmail(usedEmail),
|
|
appointmentDate: params.appointmentDate.toISOString(),
|
|
appointmentTimeZone,
|
|
duration: params.duration,
|
|
agentId: params.agentId,
|
|
channelId: params.channelId,
|
|
encryptedAppointment,
|
|
sendEmail,
|
|
clientLanguage: params.appointmentData.locale,
|
|
tunnelId: tunnelConfig.tunnelId,
|
|
clientPublicKey: tunnelConfig.clientPublicKey,
|
|
staffKeyShares: tunnelConfig.staffKeyShares,
|
|
privateKeyShare: await this.getClientKeyShare(),
|
|
clientEncryptedTunnelKey: await this.encryptTunnelKeyForClient(),
|
|
};
|
|
const response = await fetch(`/api/tenants/${params.tenantId}/appointments/staff-create`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(requestData),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Appointment could not be created");
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
console.log("✅ Encrypted appointment created:", result.id);
|
|
return result.id;
|
|
} catch (error) {
|
|
console.error("❌ Error creating appointment:", error);
|
|
throw error;
|
|
} finally {
|
|
// Clear sensitive data from class properties to prevent leaks on reuse
|
|
this.tunnelKey = null;
|
|
this.clientKeyPair = null;
|
|
this.emailHash = null;
|
|
this.tunnelId = null;
|
|
this.serverPrivateKeyShare = null;
|
|
this.clientAuthenticated = false;
|
|
this.pin = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Allows to check, if a client is authenticated
|
|
*/
|
|
isClientAuthenticated() {
|
|
return this.clientAuthenticated && this.tunnelKey;
|
|
}
|
|
|
|
/**
|
|
* Retrieves all appointments for this client
|
|
*/
|
|
async getMyAppointments(tenantId: string): Promise<DecryptedAppointment[]> {
|
|
if (!this.clientAuthenticated || !this.tunnelKey) {
|
|
throw new Error("Client not authenticated");
|
|
}
|
|
|
|
if (!this.bookingAccessToken) {
|
|
throw new Error("Missing booking access token. Please authenticate first.");
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/tenants/${tenantId}/appointments/my-appointments`, {
|
|
method: "GET",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-Email-Hash": this.emailHash!,
|
|
Authorization: `Bearer ${this.bookingAccessToken}`,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Appointments could not be retrieved");
|
|
}
|
|
|
|
const data: MyAppointmentsResponse = await response.json();
|
|
|
|
// Decrypt each appointment
|
|
const decryptedAppointments: DecryptedAppointment[] = [];
|
|
for (const encryptedAppt of data.appointments) {
|
|
try {
|
|
const decryptedData = await this.decryptAppointmentData(encryptedAppt.encryptedData);
|
|
decryptedAppointments.push({
|
|
id: encryptedAppt.id,
|
|
appointmentDate: encryptedAppt.appointmentDate,
|
|
status: encryptedAppt.status,
|
|
...decryptedData,
|
|
});
|
|
} catch (error) {
|
|
console.warn("Failed to decrypt appointment", encryptedAppt.id, error);
|
|
}
|
|
}
|
|
|
|
return decryptedAppointments;
|
|
} catch (error) {
|
|
console.error("❌ Error retrieving appointments:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ===== STAFF METHODS =====
|
|
|
|
/**
|
|
* 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<void> {
|
|
try {
|
|
// 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" },
|
|
});
|
|
|
|
if (!shardResponse.ok) {
|
|
throw new Error(`Failed to fetch key shard: ${shardResponse.status}`);
|
|
}
|
|
|
|
const shardData = await shardResponse.json();
|
|
|
|
// 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);
|
|
|
|
// 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() + 10 * 60 * 1000; // 10 minutes
|
|
|
|
console.log("✅ Staff authentication successful with PRF");
|
|
} catch (error) {
|
|
console.error("❌ Staff authentication failed:", error);
|
|
throw new Error(
|
|
`Staff authentication failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decrypt appointment data for staff members
|
|
*/
|
|
async decryptStaffAppointment(encryptedData: {
|
|
encryptedAppointment: EncryptedData;
|
|
staffKeyShare: string;
|
|
}): Promise<AppointmentData> {
|
|
if (!this.staffAuthenticated || !this.staffKeyPair) {
|
|
throw new Error("Staff not authenticated");
|
|
}
|
|
|
|
if (this.keyExpiry && Date.now() > this.keyExpiry) {
|
|
throw new Error("Staff session expired - please authenticate again");
|
|
}
|
|
|
|
try {
|
|
// Parse the staffKeyShare which now contains: encapsulatedSecret || iv || encryptedTunnelKey
|
|
const staffKeyShareBytes = this.hexToUint8Array(encryptedData.staffKeyShare);
|
|
|
|
// ML-KEM-768 encapsulated secret is 1088 bytes
|
|
const ENCAPSULATED_SECRET_LENGTH = 1088;
|
|
const IV_LENGTH = 12;
|
|
|
|
if (staffKeyShareBytes.length < ENCAPSULATED_SECRET_LENGTH + IV_LENGTH) {
|
|
throw new Error(
|
|
`staffKeyShare too short: ${staffKeyShareBytes.length} bytes, expected at least ${ENCAPSULATED_SECRET_LENGTH + IV_LENGTH}`,
|
|
);
|
|
}
|
|
|
|
const encapsulatedSecret = staffKeyShareBytes.slice(0, ENCAPSULATED_SECRET_LENGTH);
|
|
const iv = staffKeyShareBytes.slice(
|
|
ENCAPSULATED_SECRET_LENGTH,
|
|
ENCAPSULATED_SECRET_LENGTH + IV_LENGTH,
|
|
);
|
|
const encryptedTunnelKey = staffKeyShareBytes.slice(ENCAPSULATED_SECRET_LENGTH + IV_LENGTH);
|
|
|
|
// 1. Decapsulate to get shared secret
|
|
const sharedSecret = KyberCrypto.decapsulate(
|
|
this.staffKeyPair.privateKey,
|
|
encapsulatedSecret,
|
|
);
|
|
|
|
// 2. Use first 32 bytes of shared secret as AES key
|
|
const aesKeyBytes = sharedSecret.slice(0, 32);
|
|
|
|
// Import as CryptoKey for Web Crypto API
|
|
const aesKey = await crypto.subtle.importKey("raw", aesKeyBytes, { name: "AES-GCM" }, false, [
|
|
"decrypt",
|
|
]);
|
|
|
|
// 3. Decrypt the tunnel key with AES-GCM (encrypted already includes authTag)
|
|
const decryptedTunnelKey = await crypto.subtle.decrypt(
|
|
{ name: "AES-GCM", iv },
|
|
aesKey,
|
|
encryptedTunnelKey,
|
|
);
|
|
const tunnelKeyBytes = new Uint8Array(decryptedTunnelKey);
|
|
|
|
// 4. Import tunnel key as CryptoKey
|
|
const tunnelKey = await crypto.subtle.importKey(
|
|
"raw",
|
|
tunnelKeyBytes,
|
|
{ name: "AES-GCM" },
|
|
false,
|
|
["decrypt"],
|
|
);
|
|
|
|
// 5. Now decrypt the actual appointment data
|
|
const appointmentIv = this.hexToUint8Array(encryptedData.encryptedAppointment.iv);
|
|
const ciphertext = this.hexToUint8Array(encryptedData.encryptedAppointment.encryptedPayload);
|
|
const authTag = this.hexToUint8Array(encryptedData.encryptedAppointment.authTag);
|
|
|
|
// Combine ciphertext and auth tag for Web Crypto API
|
|
const encrypted = new Uint8Array(ciphertext.length + authTag.length);
|
|
encrypted.set(ciphertext);
|
|
encrypted.set(authTag, ciphertext.length);
|
|
|
|
const decrypted = await crypto.subtle.decrypt(
|
|
{ name: "AES-GCM", iv: appointmentIv },
|
|
tunnelKey,
|
|
encrypted,
|
|
);
|
|
|
|
const decoder = new TextDecoder();
|
|
const plaintext = decoder.decode(decrypted);
|
|
return JSON.parse(plaintext);
|
|
} catch (error) {
|
|
console.error("❌ Failed to decrypt staff appointment:", error);
|
|
throw new Error(
|
|
`Decryption failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all appointments for staff in a tenant
|
|
*/
|
|
async getStaffAppointments(tenantId: string): Promise<DecryptedAppointment[]> {
|
|
if (!this.staffAuthenticated) {
|
|
throw new Error("Staff not authenticated");
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/tenants/${tenantId}/appointments/staff-appointments`, {
|
|
method: "GET",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-Staff-ID": this.staffId!,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to fetch staff appointments");
|
|
}
|
|
|
|
const data = await response.json();
|
|
const decryptedAppointments: DecryptedAppointment[] = [];
|
|
|
|
for (const encryptedAppt of data.appointments) {
|
|
try {
|
|
const decryptedData = await this.decryptStaffAppointment({
|
|
encryptedAppointment: encryptedAppt.encryptedData,
|
|
staffKeyShare: encryptedAppt.staffKeyShare,
|
|
});
|
|
|
|
decryptedAppointments.push({
|
|
id: encryptedAppt.id,
|
|
appointmentDate: encryptedAppt.appointmentDate,
|
|
status: encryptedAppt.status,
|
|
...decryptedData,
|
|
});
|
|
} catch (error) {
|
|
console.warn("Failed to decrypt staff appointment", encryptedAppt.id, error);
|
|
}
|
|
}
|
|
|
|
return decryptedAppointments;
|
|
} catch (error) {
|
|
console.error("❌ Error retrieving staff appointments:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Logout staff member and clear sensitive data
|
|
*/
|
|
logoutStaff(): void {
|
|
this.staffKeyPair = null;
|
|
this.staffId = null;
|
|
this.tenantId = null;
|
|
this.staffAuthenticated = false;
|
|
this.keyExpiry = null;
|
|
}
|
|
|
|
/**
|
|
* Logout client and clear sensitive data
|
|
*/
|
|
logoutClient(): void {
|
|
this.tunnelKey = null;
|
|
this.clientKeyPair = null;
|
|
this.emailHash = null;
|
|
this.tunnelId = null;
|
|
this.serverPrivateKeyShare = null;
|
|
this.bookingAccessToken = null;
|
|
this.clientAuthenticated = false;
|
|
this.pin = null;
|
|
}
|
|
|
|
// ===== SHARED PRIVATE METHODS =====
|
|
|
|
/**
|
|
* 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 getPRFOutputFromSession(staffId: string, passkeyId: string): Promise<ArrayBuffer> {
|
|
// Import auth store dynamically to avoid circular dependencies
|
|
const { auth } = await import("$lib/stores/auth");
|
|
|
|
// 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.",
|
|
);
|
|
}
|
|
|
|
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).",
|
|
);
|
|
}
|
|
|
|
// 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.",
|
|
);
|
|
}
|
|
|
|
// 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 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,
|
|
prfOutput: ArrayBuffer,
|
|
keyPair: { publicKey: Uint8Array; privateKey: Uint8Array },
|
|
): Promise<void> {
|
|
// 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" },
|
|
body: JSON.stringify({
|
|
passkeyId,
|
|
publicKey: this.uint8ArrayToBase64(keyPair.publicKey),
|
|
privateKeyShare: this.uint8ArrayToBase64(dbShard),
|
|
}),
|
|
});
|
|
|
|
console.log("✅ Staff keypair stored with PRF-based security");
|
|
}
|
|
|
|
private uint8ArrayToBase64(array: Uint8Array): string {
|
|
return btoa(String.fromCharCode.apply(null, Array.from(array)));
|
|
}
|
|
|
|
/**
|
|
* Derive a deterministic shard from WebAuthn PRF Extension (CTAP 2.1+)
|
|
*
|
|
* 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 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 derivePasskeyBasedShardWithPRF(
|
|
prfOutput: ArrayBuffer,
|
|
staffId: string,
|
|
): Promise<Uint8Array> {
|
|
// 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 PRF output as a CryptoKey for HKDF expansion
|
|
const ikmKey = await crypto.subtle.importKey("raw", prfBytes, "HKDF", false, ["deriveBits"]);
|
|
|
|
// 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 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(
|
|
{
|
|
name: "HKDF",
|
|
hash: "SHA-256",
|
|
salt: salt,
|
|
info: info,
|
|
},
|
|
ikmKey,
|
|
2400 * 8, // 2400 bytes * 8 bits
|
|
);
|
|
|
|
return new Uint8Array(keyMaterial);
|
|
}
|
|
|
|
/**
|
|
* Generate ML-KEM-768 keypair for clients
|
|
*/
|
|
private async generateClientKeyPair(): Promise<ClientKeyPair> {
|
|
const keyPair = KyberCrypto.generateKeyPair();
|
|
return {
|
|
publicKey: Array.from(keyPair.publicKey)
|
|
.map((b: number) => b.toString(16).padStart(2, "0"))
|
|
.join(""),
|
|
privateKey: Array.from(keyPair.privateKey)
|
|
.map((b: number) => b.toString(16).padStart(2, "0"))
|
|
.join(""),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate AES-256-GCM tunnel key
|
|
*/
|
|
private async generateTunnelKey(): Promise<CryptoKey> {
|
|
return await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [
|
|
"encrypt",
|
|
"decrypt",
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Encrypt appointment data with the tunnel key
|
|
*/
|
|
private async encryptAppointmentData(
|
|
data: AppointmentData | AppointmentDataByStaff,
|
|
tunnelKey?: CryptoKey,
|
|
): Promise<EncryptedData> {
|
|
const usedTunnelKey = tunnelKey ?? this.tunnelKey;
|
|
if (!usedTunnelKey) throw new Error("No tunnel key available");
|
|
|
|
const encoder = new TextEncoder();
|
|
const plaintext = encoder.encode(JSON.stringify(data));
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
|
|
const encrypted = await crypto.subtle.encrypt(
|
|
{ name: "AES-GCM", iv },
|
|
usedTunnelKey,
|
|
plaintext,
|
|
);
|
|
|
|
const encryptedArray = new Uint8Array(encrypted);
|
|
const authTag = encryptedArray.slice(-16);
|
|
const ciphertext = encryptedArray.slice(0, -16);
|
|
|
|
return {
|
|
encryptedPayload: Array.from(ciphertext)
|
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
.join(""),
|
|
iv: Array.from(iv)
|
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
.join(""),
|
|
authTag: Array.from(authTag)
|
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
.join(""),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Decrypt appointment data with the tunnel key
|
|
*/
|
|
private async decryptAppointmentData(encryptedData: EncryptedData): Promise<AppointmentData> {
|
|
if (!this.tunnelKey) throw new Error("No tunnel key available");
|
|
|
|
const iv = this.hexToUint8Array(encryptedData.iv);
|
|
const ciphertext = this.hexToUint8Array(encryptedData.encryptedPayload);
|
|
const authTag = this.hexToUint8Array(encryptedData.authTag);
|
|
|
|
// Combine ciphertext and auth tag
|
|
const encrypted = new Uint8Array(ciphertext.length + authTag.length);
|
|
encrypted.set(ciphertext);
|
|
encrypted.set(authTag, ciphertext.length);
|
|
|
|
const decrypted = await crypto.subtle.decrypt(
|
|
{ name: "AES-GCM", iv: new Uint8Array(iv) },
|
|
this.tunnelKey,
|
|
encrypted,
|
|
);
|
|
|
|
const decoder = new TextDecoder();
|
|
const plaintext = decoder.decode(decrypted);
|
|
return JSON.parse(plaintext);
|
|
}
|
|
|
|
// ===== UTILITY METHODS =====
|
|
|
|
private generateTunnelId(): string {
|
|
return crypto.randomUUID();
|
|
}
|
|
|
|
/**
|
|
* Creates a server share of the private key using Shamir Secret Sharing (2-of-2)
|
|
*
|
|
* This implements a 2-party key splitting scheme where:
|
|
* - PIN-derived share (x=1): Deterministically derived from PIN + email hash (client can always recreate)
|
|
* - Server share (x=2): Stored in database
|
|
*
|
|
* Reconstruction requires both shares (2-of-2 threshold).
|
|
*
|
|
* Security properties:
|
|
* - Each share reveals NO information about the private key (information-theoretic security)
|
|
* - Both shares are required for reconstruction
|
|
* - Server share is useless without the PIN
|
|
*
|
|
* This allows the client to authenticate from any device:
|
|
* 1. Client provides email + PIN
|
|
* 2. Server returns the server share via challenge API
|
|
* 3. Client derives PIN share deterministically and reconstructs the private key
|
|
*
|
|
* @param privateKey - The ML-KEM-768 private key (hex string)
|
|
* @param pin - User's PIN for deterministic share derivation
|
|
* @returns Server share (hex string) to be stored in database
|
|
*/
|
|
private async createPrivateKeyShare(privateKey: string, pin: string): Promise<string> {
|
|
const privateKeyBytes = this.hexToUint8Array(privateKey);
|
|
|
|
// Derive a deterministic y-value for the PIN-based share (x=1)
|
|
// ML-KEM-768 private key is 2400 bytes, so we need a hash of that length
|
|
const pinHash = await OptimizedArgon2.deriveKeyFromPIN(pin, this.emailHash || "", {
|
|
hashLength: privateKeyBytes.length,
|
|
});
|
|
|
|
// Use ShamirSecretSharing to create the shares with deterministic first share
|
|
const shares = ShamirSecretSharing.splitSecretWithDeterministicShare(privateKeyBytes, pinHash);
|
|
|
|
// TEST: Immediately reconstruct to verify
|
|
const testReconstruct = ShamirSecretSharing.reconstructSecret(shares);
|
|
const match = privateKeyBytes.every((v, i) => v === testReconstruct[i]);
|
|
|
|
if (!match) {
|
|
console.error("❌ CRITICAL: Shamir reconstruction failed immediately after split!");
|
|
}
|
|
|
|
// Return the server share (x=2)
|
|
return this.uint8ArrayToHex(shares[1].y);
|
|
}
|
|
|
|
async fetchStaffPublicKeys(tenantId: string): Promise<StaffPublicKey[]> {
|
|
if (!this.bookingAccessToken) {
|
|
throw new Error("Missing booking access token. Please authenticate or complete bootstrap.");
|
|
}
|
|
|
|
const response = await fetch(`/api/tenants/${tenantId}/appointments/staff-public-keys`, {
|
|
method: "GET",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${this.bookingAccessToken}`,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch staff public keys: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return data.staffPublicKeys;
|
|
}
|
|
|
|
async fetchStaffPublicKeysByStaff(tenantId: string): Promise<StaffPublicKey[]> {
|
|
const response = await fetch(
|
|
`/api/tenants/${tenantId}/appointments/staff-public-keys-by-staff`,
|
|
{
|
|
method: "GET",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${this.bookingAccessToken}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch staff public keys for staff: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return data.staffPublicKeys;
|
|
}
|
|
|
|
private async bootstrapNewClientAccess(tenantId: string): Promise<void> {
|
|
if (!this.tunnelId || !this.clientKeyPair) {
|
|
throw new Error("Bootstrap requires generated client tunnel and key pair");
|
|
}
|
|
|
|
const challengeResponse = await fetch(
|
|
`/api/tenants/${tenantId}/appointments/bootstrap-challenge`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
tunnelId: this.tunnelId,
|
|
clientPublicKey: this.clientKeyPair.publicKey,
|
|
emailHash: this.emailHash ?? undefined,
|
|
}),
|
|
},
|
|
);
|
|
|
|
if (!challengeResponse.ok) {
|
|
throw new Error(`Failed to request bootstrap challenge: ${challengeResponse.statusText}`);
|
|
}
|
|
|
|
const challengeData: BootstrapChallengeResponse = await challengeResponse.json();
|
|
const counter = await this.solveBootstrapProofOfWork(
|
|
challengeData.nonce,
|
|
this.tunnelId,
|
|
this.clientKeyPair.publicKey,
|
|
challengeData.difficulty,
|
|
);
|
|
|
|
const verifyResponse = await fetch(`/api/tenants/${tenantId}/appointments/bootstrap-verify`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
challengeId: challengeData.challengeId,
|
|
tunnelId: this.tunnelId,
|
|
clientPublicKey: this.clientKeyPair.publicKey,
|
|
counter,
|
|
emailHash: this.emailHash ?? undefined,
|
|
}),
|
|
});
|
|
|
|
if (!verifyResponse.ok) {
|
|
throw new Error(`Failed to verify bootstrap challenge: ${verifyResponse.statusText}`);
|
|
}
|
|
|
|
const verificationData: BootstrapVerifyResponse = await verifyResponse.json();
|
|
this.bookingAccessToken = verificationData.bookingAccessToken;
|
|
}
|
|
|
|
private async solveBootstrapProofOfWork(
|
|
nonce: string,
|
|
tunnelId: string,
|
|
clientPublicKey: string,
|
|
difficulty: number,
|
|
): Promise<number> {
|
|
const targetPrefix = "0".repeat(difficulty);
|
|
const encoder = new TextEncoder();
|
|
|
|
for (let counter = 0; ; counter += 1) {
|
|
const input = `${nonce}:${tunnelId}:${clientPublicKey}:${counter}`;
|
|
const digestBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(input));
|
|
const digestHex = Array.from(new Uint8Array(digestBuffer))
|
|
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
.join("");
|
|
|
|
if (digestHex.startsWith(targetPrefix)) {
|
|
return counter;
|
|
}
|
|
}
|
|
}
|
|
|
|
// getTenantId method removed - tenantId is now always passed explicitly
|
|
|
|
async encryptTunnelKeyForStaff(
|
|
staffKeys: StaffPublicKey[],
|
|
externalTunnelKey?: CryptoKey,
|
|
): Promise<Array<{ userId: string; encryptedTunnelKey: string }>> {
|
|
const usedKey = externalTunnelKey ?? this.tunnelKey;
|
|
if (!usedKey) throw new Error("No tunnel key available");
|
|
|
|
// Export tunnel key as raw bytes
|
|
const tunnelKeyBytes = await crypto.subtle.exportKey("raw", usedKey);
|
|
const tunnelKeyArray = new Uint8Array(tunnelKeyBytes);
|
|
|
|
const results = [];
|
|
|
|
for (const staff of staffKeys) {
|
|
// Public key is stored as Base64, not Hex
|
|
const staffPublicKeyBytes = this.base64ToUint8Array(staff.publicKey);
|
|
|
|
// Kyber encapsulation creates a shared secret
|
|
const { sharedSecret, encapsulatedSecret } = KyberCrypto.encapsulate(staffPublicKeyBytes);
|
|
|
|
// Use the first 32 bytes of shared secret as AES key (same as decryption)
|
|
const aesKeyBytes = sharedSecret.slice(0, 32);
|
|
|
|
// Import as CryptoKey for Web Crypto API
|
|
const aesKey = await crypto.subtle.importKey("raw", aesKeyBytes, { name: "AES-GCM" }, false, [
|
|
"encrypt",
|
|
]);
|
|
|
|
// Generate IV for AES-GCM
|
|
const iv = BufferUtils.randomBytes(12);
|
|
|
|
// Encrypt tunnel key with AES-GCM
|
|
const encrypted = await crypto.subtle.encrypt(
|
|
{ name: "AES-GCM", iv },
|
|
aesKey,
|
|
tunnelKeyArray,
|
|
);
|
|
|
|
// encrypted contains ciphertext + 16-byte auth tag
|
|
const encryptedArray = new Uint8Array(encrypted);
|
|
|
|
// Store: encapsulatedSecret || iv || encrypted (ciphertext+authTag)
|
|
const combined = new Uint8Array(
|
|
encapsulatedSecret.length + iv.length + encryptedArray.length,
|
|
);
|
|
combined.set(encapsulatedSecret, 0);
|
|
combined.set(iv, encapsulatedSecret.length);
|
|
combined.set(encryptedArray, encapsulatedSecret.length + iv.length);
|
|
|
|
results.push({
|
|
userId: staff.userId,
|
|
encryptedTunnelKey: this.uint8ArrayToHex(combined),
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
private async encryptTunnelKeyForClient(): Promise<string> {
|
|
if (!this.tunnelKey || !this.clientKeyPair)
|
|
throw new Error("Tunnel key or client key not available");
|
|
|
|
// Export tunnel key as raw bytes
|
|
const tunnelKeyBytes = await crypto.subtle.exportKey("raw", this.tunnelKey);
|
|
const tunnelKeyArray = new Uint8Array(tunnelKeyBytes);
|
|
|
|
const clientPublicKeyBytes = this.hexToUint8Array(this.clientKeyPair.publicKey);
|
|
|
|
// Kyber encapsulation creates a shared secret
|
|
const { sharedSecret, encapsulatedSecret } = KyberCrypto.encapsulate(clientPublicKeyBytes);
|
|
|
|
// Use the first 32 bytes of shared secret as AES key
|
|
const aesKeyBytes = sharedSecret.slice(0, 32);
|
|
|
|
// Import as CryptoKey for Web Crypto API
|
|
const aesKey = await crypto.subtle.importKey("raw", aesKeyBytes, { name: "AES-GCM" }, false, [
|
|
"encrypt",
|
|
]);
|
|
|
|
// Generate IV for AES-GCM
|
|
const iv = BufferUtils.randomBytes(12);
|
|
|
|
// Encrypt tunnel key with AES-GCM
|
|
const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, tunnelKeyArray);
|
|
|
|
// encrypted contains ciphertext + 16-byte auth tag
|
|
const encryptedArray = new Uint8Array(encrypted);
|
|
|
|
// Store: encapsulatedSecret || iv || encrypted (ciphertext+authTag)
|
|
const combined = new Uint8Array(encapsulatedSecret.length + iv.length + encryptedArray.length);
|
|
combined.set(encapsulatedSecret, 0);
|
|
combined.set(iv, encapsulatedSecret.length);
|
|
combined.set(encryptedArray, encapsulatedSecret.length + iv.length);
|
|
|
|
return this.uint8ArrayToHex(combined);
|
|
}
|
|
|
|
private async reconstructPrivateKey(pin: string, serverShare: string): Promise<string> {
|
|
if (!this.emailHash) {
|
|
throw new Error("Email hash not available for key reconstruction");
|
|
}
|
|
// 1. Derive PIN-based share deterministically (x=1, y=pinShareY)
|
|
const serverShareBytes = this.hexToUint8Array(serverShare);
|
|
// ML-KEM-768 private key is 2400 bytes, derive hash of same length
|
|
const pinHash = await OptimizedArgon2.deriveKeyFromPIN(pin, this.emailHash, {
|
|
hashLength: serverShareBytes.length,
|
|
});
|
|
|
|
// 2. Reconstruct using proper Shamir Secret Sharing
|
|
const shares = [
|
|
{ x: 1, y: pinHash },
|
|
{ x: 2, y: serverShareBytes },
|
|
];
|
|
const privateKey = ShamirSecretSharing.reconstructSecret(shares);
|
|
|
|
return this.uint8ArrayToHex(privateKey);
|
|
}
|
|
|
|
private async decryptChallenge(encryptedChallenge: string, privateKey: string): Promise<string> {
|
|
const privateKeyBytes = this.hexToUint8Array(privateKey);
|
|
const fullChallengeBytes = this.hexToUint8Array(encryptedChallenge);
|
|
|
|
// Split the encryptedChallenge into:
|
|
// 1. Encapsulated secret (first 1088 bytes for ML-KEM-768)
|
|
// 2. Encrypted challenge (remaining bytes)
|
|
const encapsulatedSecret = fullChallengeBytes.slice(0, 1088);
|
|
const encryptedChallengeBuffer = fullChallengeBytes.slice(1088);
|
|
|
|
// Decapsulate to get the shared secret
|
|
const sharedSecret = KyberCrypto.decapsulate(privateKeyBytes, encapsulatedSecret);
|
|
|
|
// XOR the encrypted challenge with the shared secret to decrypt
|
|
const challenge = new Uint8Array(encryptedChallengeBuffer.length);
|
|
for (let i = 0; i < encryptedChallengeBuffer.length; i++) {
|
|
challenge[i] = encryptedChallengeBuffer[i] ^ sharedSecret[i];
|
|
}
|
|
|
|
// The decrypted challenge is raw bytes (32 bytes from randomBytes)
|
|
// Convert to base64 string for server verification
|
|
const challengeBase64 = this.uint8ArrayToBase64(challenge);
|
|
|
|
// Return the base64-encoded challenge (server expects it in base64 format)
|
|
return challengeBase64;
|
|
}
|
|
|
|
private async decryptTunnelKey(
|
|
encryptedTunnelKey: string,
|
|
privateKey: string,
|
|
): Promise<CryptoKey> {
|
|
const privateKeyBytes = this.hexToUint8Array(privateKey);
|
|
const encryptedKeyBytes = this.hexToUint8Array(encryptedTunnelKey);
|
|
|
|
// Parse the encrypted data: encapsulatedSecret || iv || encryptedTunnelKey
|
|
const ENCAPSULATED_SECRET_LENGTH = 1088;
|
|
const IV_LENGTH = 12;
|
|
|
|
if (encryptedKeyBytes.length < ENCAPSULATED_SECRET_LENGTH + IV_LENGTH) {
|
|
throw new Error(
|
|
`encryptedTunnelKey too short: ${encryptedKeyBytes.length} bytes, expected at least ${ENCAPSULATED_SECRET_LENGTH + IV_LENGTH}`,
|
|
);
|
|
}
|
|
|
|
const encapsulatedSecret = encryptedKeyBytes.slice(0, ENCAPSULATED_SECRET_LENGTH);
|
|
const iv = encryptedKeyBytes.slice(
|
|
ENCAPSULATED_SECRET_LENGTH,
|
|
ENCAPSULATED_SECRET_LENGTH + IV_LENGTH,
|
|
);
|
|
const encryptedTunnel = encryptedKeyBytes.slice(ENCAPSULATED_SECRET_LENGTH + IV_LENGTH);
|
|
|
|
// 1. Decapsulate to get shared secret
|
|
const sharedSecret = KyberCrypto.decapsulate(privateKeyBytes, encapsulatedSecret);
|
|
|
|
// 2. Use first 32 bytes as AES key
|
|
const aesKeyBytes = sharedSecret.slice(0, 32);
|
|
|
|
// 3. Import as CryptoKey
|
|
const aesKey = await crypto.subtle.importKey("raw", aesKeyBytes, { name: "AES-GCM" }, false, [
|
|
"decrypt",
|
|
]);
|
|
|
|
// 4. Decrypt the tunnel key
|
|
const decryptedTunnelKey = await crypto.subtle.decrypt(
|
|
{ name: "AES-GCM", iv },
|
|
aesKey,
|
|
encryptedTunnel,
|
|
);
|
|
|
|
// 5. Import tunnel key as CryptoKey
|
|
return await crypto.subtle.importKey(
|
|
"raw",
|
|
new Uint8Array(decryptedTunnelKey),
|
|
{ name: "AES-GCM" },
|
|
true,
|
|
["encrypt", "decrypt"],
|
|
);
|
|
}
|
|
|
|
async decryptTunnelKeyByStaff(staffKeyShare: string): Promise<CryptoKey> {
|
|
if (!this.staffAuthenticated || !this.staffKeyPair) {
|
|
throw new Error("Staff not authenticated");
|
|
}
|
|
|
|
if (this.keyExpiry && Date.now() > this.keyExpiry) {
|
|
throw new Error("Staff session expired - please authenticate again");
|
|
}
|
|
|
|
try {
|
|
// Parse the staffKeyShare which now contains: encapsulatedSecret || iv || encryptedTunnelKey
|
|
const staffKeyShareBytes = this.hexToUint8Array(staffKeyShare);
|
|
|
|
// ML-KEM-768 encapsulated secret is 1088 bytes
|
|
const ENCAPSULATED_SECRET_LENGTH = 1088;
|
|
const IV_LENGTH = 12;
|
|
|
|
if (staffKeyShareBytes.length < ENCAPSULATED_SECRET_LENGTH + IV_LENGTH) {
|
|
throw new Error(
|
|
`staffKeyShare too short: ${staffKeyShareBytes.length} bytes, expected at least ${ENCAPSULATED_SECRET_LENGTH + IV_LENGTH}`,
|
|
);
|
|
}
|
|
|
|
const encapsulatedSecret = staffKeyShareBytes.slice(0, ENCAPSULATED_SECRET_LENGTH);
|
|
const iv = staffKeyShareBytes.slice(
|
|
ENCAPSULATED_SECRET_LENGTH,
|
|
ENCAPSULATED_SECRET_LENGTH + IV_LENGTH,
|
|
);
|
|
const encryptedTunnelKey = staffKeyShareBytes.slice(ENCAPSULATED_SECRET_LENGTH + IV_LENGTH);
|
|
|
|
// 1. Decapsulate to get shared secret
|
|
const sharedSecret = KyberCrypto.decapsulate(
|
|
this.staffKeyPair.privateKey,
|
|
encapsulatedSecret,
|
|
);
|
|
|
|
// 2. Use first 32 bytes of shared secret as AES key
|
|
const aesKeyBytes = sharedSecret.slice(0, 32);
|
|
|
|
// Import as CryptoKey for Web Crypto API
|
|
const aesKey = await crypto.subtle.importKey("raw", aesKeyBytes, { name: "AES-GCM" }, false, [
|
|
"decrypt",
|
|
]);
|
|
|
|
// 3. Decrypt the tunnel key
|
|
const decryptedTunnelKey = await crypto.subtle.decrypt(
|
|
{ name: "AES-GCM", iv },
|
|
aesKey,
|
|
encryptedTunnelKey,
|
|
);
|
|
|
|
// 4. Import and return tunnel key as CryptoKey
|
|
return await crypto.subtle.importKey(
|
|
"raw",
|
|
new Uint8Array(decryptedTunnelKey),
|
|
{ name: "AES-GCM" },
|
|
true,
|
|
["encrypt", "decrypt"],
|
|
);
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Decryption failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Helper methods for completing the API
|
|
private async getPrivateKeyShare(): Promise<string> {
|
|
if (!this.serverPrivateKeyShare) throw new Error("Server private key share not available");
|
|
return this.serverPrivateKeyShare;
|
|
}
|
|
|
|
private async getStaffKeyShares(
|
|
tenantId: string,
|
|
decryptedTunnelKey?: CryptoKey,
|
|
): Promise<StaffKeyShares> {
|
|
// Fetch and encrypt tunnel key for all staff members
|
|
const staffPublicKeys = await this.fetchStaffPublicKeys(tenantId);
|
|
return await this.encryptTunnelKeyForStaff(staffPublicKeys, decryptedTunnelKey);
|
|
}
|
|
|
|
private async getClientKeyShare(): Promise<string> {
|
|
// Return the client's encrypted tunnel key
|
|
return await this.encryptTunnelKeyForClient();
|
|
}
|
|
|
|
// Encoding/decoding utilities
|
|
private hexToUint8Array(hex: string): Uint8Array {
|
|
return new Uint8Array(hex.match(/.{2}/g)!.map((byte) => parseInt(byte, 16)));
|
|
}
|
|
|
|
private uint8ArrayToHex(array: Uint8Array): string {
|
|
return Array.from(array)
|
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
.join("");
|
|
}
|
|
|
|
private base64ToUint8Array(base64: string): Uint8Array {
|
|
return new Uint8Array(Array.from(atob(base64)).map((c) => c.charCodeAt(0)));
|
|
}
|
|
}
|