Files
appointment-booking-software/docs/staff-crypto-worker.md
T

19 KiB

Staff Crypto Management Documentation

Overview

The Staff Crypto system implements browser-based end-to-end encryption for staff members accessing patient appointment data. All cryptographic operations happen in the browser using WebAuthn-backed key derivation with split-key architecture for maximum security.

Architecture

Browser (Staff App) ↔ StaffCryptoService API ↔ Tenant Database
       ↓
   WebAuthn Passkeys ↔ Hardware Keys
  • Browser: Generates Kyber keys, performs encryption/decryption
  • StaffCryptoService: Stores/retrieves key shards and public keys
  • WebAuthn: Hardware-backed authentication for deterministic key derivation
  • Split-Key Storage: Private keys split between database and passkey-derived shards

Key Features

  • 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 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 using the WebAuthn PRF extension:

import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto";
import { generatePasskey, getPRFOutputAfterRegistration } from "$lib/utils/passkey";

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 " +
        "(https://open-reception.org/getting-started/#passkeys)",
    );
  }

  // 2. Generate Kyber keypair in browser
  const keyPair = KyberCrypto.generateKeyPair();

  // 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.derivePasskeyBasedShardWithPRF(
    prfOutput, // 32-byte secret from PRF extension
    userId,
  );

  // 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];
  }

  // 6. Store via StaffCryptoService API
  await fetch(`/api/tenants/${tenantId}/staff/${userId}/crypto`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      passkeyId: credential.id,
      publicKey: bufferToBase64(keyPair.publicKey),
      privateKeyShare: bufferToBase64(dbShard),
    }),
  });

  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 using the WebAuthn PRF extension:

async function authenticateStaff(userId, tenantId) {
  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()):

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
        },
      },
    },
  });

  // 3. Extract PRF output from WebAuthn response
  const extensionResults = credential.getClientExtensionResults();
  const prfOutput = extensionResults.prf?.results?.first;

  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,
  );

  // 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];
  }

  // 7. Store reconstructed keypair for session
  this.staffKeyPair = {
    publicKey: base64ToBuffer(publicKey),
    privateKey: privateKey,
  };
}

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:

Store Staff Keypair

POST /api/tenants/{tenantId}/staff/{userId}/crypto

Body:

{
  "passkeyId": "credential-id-from-webauthn",
  "publicKey": "base64-encoded-kyber-public-key",
  "privateKeyShare": "base64-encoded-database-shard"
}

Get Staff Key Shard

GET /api/tenants/{tenantId}/staff/{userId}/key-shard

Response:

{
  "publicKey": "base64-encoded-kyber-public-key",
  "privateKeyShare": "base64-encoded-database-shard",
  "passkeyId": "associated-passkey-id"
}

Get All Staff Public Keys

GET /api/tenants/{tenantId}/appointments/staff-public-keys

Response:

{
  "staffPublicKeys": [
    {
      "userId": "staff-user-id",
      "publicKey": "base64-encoded-kyber-public-key"
    }
  ]
}

Browser Implementation

Initialize Crypto System

import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto";

const crypto = new UnifiedAppointmentCrypto();

Staff Authentication

async function authenticateStaff(staffId, tenantId) {
  try {
    await crypto.authenticateStaff(staffId, tenantId);
    console.log("Staff authenticated successfully");

    // Crypto system is now ready for encryption/decryption
    return crypto;
  } catch (error) {
    console.error("Authentication failed:", error);
    throw error;
  }
}

Decrypt Appointment Data

// Appointment data from API
const appointmentData = {
  encryptedData: "base64-encoded-encrypted-data",
  staffKeyShare: "base64-encoded-encrypted-key",
};

try {
  const decrypted = await cryptoWorker.decryptAppointment(appointmentData);

  // decrypted contains:
  // {
  //   title: 'Appointment Title',
  //   description: 'Appointment Description',
  //   clientEmail: 'patient@example.com',
  //   decryptedAt: '2023-12-07T10:30:00.000Z'
  // }

  console.log("Patient:", decrypted.clientEmail);
  console.log("Appointment:", decrypted.title);
} catch (error) {
  if (error.message.includes("Not authenticated")) {
    // Need to authenticate first
    await cryptoWorker.authenticate(staffId);
    // Retry decryption
  }
}

Check Worker Status

const status = await cryptoWorker.getStatus();

console.log("Authenticated:", status.authenticated);
console.log("Staff ID:", status.staffId);
console.log("Session expires at:", new Date(status.expiresAt));
console.log("Time remaining (ms):", status.timeRemaining);

Logout

await cryptoWorker.logout();
console.log("All cryptographic data cleared");

Advanced Usage

Custom Event Handlers

// Handle session expiration
cryptoWorker.onSessionExpired = (data) => {
  console.log(`Session expired for staff ${data.staffId}`);
  // Show re-authentication dialog
  showReAuthDialog();
};

// Handle authentication requirement
cryptoWorker.onAuthenticationRequired = () => {
  console.log("Authentication required");
  // Redirect to login or show auth dialog
  redirectToLogin();
};

Batch Processing Multiple Appointments

async function decryptMultipleAppointments(appointments) {
  const decryptedAppointments = [];

  for (const appointment of appointments) {
    try {
      const decrypted = await cryptoWorker.decryptAppointment({
        encryptedData: appointment.encryptedData,
        staffKeyShare: appointment.staffKeyShare,
      });

      decryptedAppointments.push({
        ...appointment,
        decryptedData: decrypted,
      });
    } catch (error) {
      console.error(`Failed to decrypt appointment ${appointment.id}:`, error);
      // Handle individual failures gracefully
    }
  }

  return decryptedAppointments;
}

Session Management

// Check if authentication is needed before operations
async function ensureAuthenticated(staffId) {
  const status = await cryptoWorker.getStatus();

  if (!status.authenticated || status.timeRemaining < 60000) {
    // Less than 1 minute
    console.log("Re-authenticating...");
    await cryptoWorker.authenticate(staffId);
  }
}

// Use before sensitive operations
await ensureAuthenticated("staff-id-uuid");
const decrypted = await cryptoWorker.decryptAppointment(appointmentData);

Error Handling

Common Errors

Error Message Cause Solution
Worker not initialized Worker not initialized Call cryptoWorker.initialize()
Not authenticated - please authenticate first No active session Call cryptoWorker.authenticate(staffId)
Session expired - please authenticate again 10-minute timeout reached Re-authenticate with WebAuthn
Failed to decrypt appointment data Corrupted or invalid data Check API response format
Authentication failed WebAuthn failure Check hardware key or browser support

Error Handling Pattern

async function safeDecryptAppointment(appointmentData, staffId) {
  try {
    return await cryptoWorker.decryptAppointment(appointmentData);
  } catch (error) {
    if (error.message.includes("Not authenticated") || error.message.includes("expired")) {
      // Try to re-authenticate
      try {
        await cryptoWorker.authenticate(staffId);
        return await cryptoWorker.decryptAppointment(appointmentData);
      } catch (authError) {
        throw new Error("Re-authentication failed: " + authError.message);
      }
    }

    // Re-throw other errors
    throw error;
  }
}

Security Considerations

What the Worker Protects Against

  • XSS Attacks: Private keys are isolated from DOM and main thread
  • Memory Dumps: Keys are automatically cleared after timeout
  • Debugging: Private keys cannot be inspected via browser dev tools
  • Extensions: Malicious browser extensions cannot access worker memory

What You Still Need to Protect

  • CSRF: Implement proper CSRF protection on your APIs
  • Network Security: Use HTTPS for all communications
  • Authentication: Ensure proper staff authentication before worker access
  • Authorization: Verify staff permissions server-side

Best Practices

  1. Always authenticate before operations:

    await cryptoWorker.authenticate(staffId);
    
  2. Handle session expiration gracefully:

    cryptoWorker.onSessionExpired = () => redirectToLogin();
    
  3. Clear data on logout:

    await cryptoWorker.logout();
    
  4. Terminate worker on page unload:

    window.addEventListener("beforeunload", () => {
      cryptoWorker.terminate();
    });
    

Integration with Appointment System

Fetching Staff Appointments

// Fetch appointments from API
const response = await fetch(`/api/tenants/${tenantId}/staff/appointments?staffId=${staffId}`);
const { data: appointments } = await response.json();

// Decrypt each appointment
for (const appointment of appointments) {
  const decrypted = await cryptoWorker.decryptAppointment({
    encryptedData: appointment.encryptedData,
    staffKeyShare: appointment.staffKeyShare,
  });

  // Display decrypted data in UI
  displayAppointment(appointment, decrypted);
}

Complete Staff Dashboard Example

import { cryptoWorker } from "./crypto-worker-client.js";

class StaffDashboard {
  constructor(staffId, tenantId) {
    this.staffId = staffId;
    this.tenantId = tenantId;
    this.appointments = [];
  }

  async initialize() {
    // Initialize crypto worker
    await cryptoWorker.initialize();

    // Set up event handlers
    cryptoWorker.onSessionExpired = () => this.handleSessionExpired();
    cryptoWorker.onAuthenticationRequired = () => this.handleAuthRequired();

    // Authenticate staff
    await this.authenticate();

    // Load appointments
    await this.loadAppointments();
  }

  async authenticate() {
    try {
      const result = await cryptoWorker.authenticate(this.staffId);
      console.log("Authenticated until:", new Date(result.expiresAt));
      return result;
    } catch (error) {
      throw new Error("Authentication failed: " + error.message);
    }
  }

  async loadAppointments() {
    try {
      // Fetch encrypted appointments
      const response = await fetch(
        `/api/tenants/${this.tenantId}/staff/appointments?staffId=${this.staffId}`,
      );
      const { data: encryptedAppointments } = await response.json();

      // Decrypt appointments
      this.appointments = [];
      for (const appointment of encryptedAppointments) {
        try {
          const decrypted = await cryptoWorker.decryptAppointment({
            encryptedData: appointment.encryptedData,
            staffKeyShare: appointment.staffKeyShare,
          });

          this.appointments.push({
            ...appointment,
            decrypted,
          });
        } catch (error) {
          console.error(`Failed to decrypt appointment ${appointment.id}:`, error);
        }
      }

      this.renderAppointments();
    } catch (error) {
      console.error("Failed to load appointments:", error);
    }
  }

  renderAppointments() {
    const container = document.getElementById("appointments");
    container.innerHTML = "";

    this.appointments.forEach((appointment) => {
      const div = document.createElement("div");
      div.className = "appointment-card";
      div.innerHTML = `
                <h3>${appointment.decrypted.title}</h3>
                <p>Date: ${appointment.appointmentDate}</p>
                <p>Patient: ${appointment.decrypted.clientEmail}</p>
                <p>Description: ${appointment.decrypted.description || "No description"}</p>
                <p>Status: ${appointment.status}</p>
            `;
      container.appendChild(div);
    });
  }

  handleSessionExpired() {
    alert("Your session has expired. Please log in again.");
    window.location.href = "/staff/login";
  }

  handleAuthRequired() {
    this.authenticate().catch((error) => {
      console.error("Re-authentication failed:", error);
      this.handleSessionExpired();
    });
  }

  async cleanup() {
    await cryptoWorker.logout();
    cryptoWorker.terminate();
  }
}

// Usage
const dashboard = new StaffDashboard("staff-id-uuid", "tenant-id-uuid");
dashboard.initialize().catch(console.error);

// Cleanup on page unload
window.addEventListener("beforeunload", () => {
  dashboard.cleanup();
});

Browser Compatibility

  • Chrome/Edge: Full support
  • Firefox: Full support
  • Safari: Full support (iOS 16.4+)
  • WebAuthn: Required for hardware key authentication

Troubleshooting

Worker Not Loading

  • Check that crypto-worker.js is accessible at the correct path
  • Verify CORS settings allow worker loading
  • Check browser console for loading errors

Authentication Failures

  • Ensure WebAuthn is supported in the browser
  • Check that hardware keys are properly registered
  • Verify staff credentials are valid

Decryption Failures

  • Ensure appointment data format matches expected structure
  • Check that staff has access to the specific appointment
  • Verify encryption/decryption key compatibility

Performance Issues

  • Consider implementing appointment pagination
  • Use batch processing for multiple appointments
  • Monitor worker memory usage

API Integration

The crypto worker is designed to work with the Open Reception appointment API:

  • GET /api/tenants/{tenantId}/staff/appointments - Get all staff appointments
  • POST /api/tenants/{tenantId}/staff/appointments - Get specific appointment

See the main API documentation for complete endpoint specifications.