mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-09 18:57:39 +02:00
641 lines
19 KiB
Markdown
641 lines
19 KiB
Markdown
# 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:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
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()`):
|
|
|
|
```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
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// 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:**
|
|
|
|
```json
|
|
{
|
|
"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:**
|
|
|
|
```json
|
|
{
|
|
"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:**
|
|
|
|
```json
|
|
{
|
|
"staffPublicKeys": [
|
|
{
|
|
"userId": "staff-user-id",
|
|
"publicKey": "base64-encoded-kyber-public-key"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
## Browser Implementation
|
|
|
|
### Initialize Crypto System
|
|
|
|
```javascript
|
|
import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto";
|
|
|
|
const crypto = new UnifiedAppointmentCrypto();
|
|
```
|
|
|
|
### Staff Authentication
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
await cryptoWorker.logout();
|
|
console.log("All cryptographic data cleared");
|
|
```
|
|
|
|
## Advanced Usage
|
|
|
|
### Custom Event Handlers
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
```javascript
|
|
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**:
|
|
|
|
```javascript
|
|
await cryptoWorker.authenticate(staffId);
|
|
```
|
|
|
|
2. **Handle session expiration gracefully**:
|
|
|
|
```javascript
|
|
cryptoWorker.onSessionExpired = () => redirectToLogin();
|
|
```
|
|
|
|
3. **Clear data on logout**:
|
|
|
|
```javascript
|
|
await cryptoWorker.logout();
|
|
```
|
|
|
|
4. **Terminate worker on page unload**:
|
|
```javascript
|
|
window.addEventListener("beforeunload", () => {
|
|
cryptoWorker.terminate();
|
|
});
|
|
```
|
|
|
|
## Integration with Appointment System
|
|
|
|
### Fetching Staff Appointments
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
```javascript
|
|
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.
|