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
-
Always authenticate before operations:
await cryptoWorker.authenticate(staffId); -
Handle session expiration gracefully:
cryptoWorker.onSessionExpired = () => redirectToLogin(); -
Clear data on logout:
await cryptoWorker.logout(); -
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.jsis 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 appointmentsPOST /api/tenants/{tenantId}/staff/appointments- Get specific appointment
See the main API documentation for complete endpoint specifications.