Client-Side Encryption for Healthcare Apps
Source: Dev.to
Introduction
I’ve had my health data used against me in court—actual lawyers, an actual judge, and my own pain‑flare journal entries reframed as evidence of instability. That experience drives every security decision below: 150 000 PBKDF2 iterations, AES‑256‑GCM, and a key that never leaves the device. This isn’t a tutorial; it’s the architecture that keeps health data out of discovery motions, custody disputes, and insurance‑fraud investigations.
Threat Model
-
Traditional model:
User → Server → Database.- Server decrypts to process.
- Data traverses corporate infrastructure, is accessible to employees, and can be subpoenaed.
- Breaches, business‑model monetisation, and legal requests expose the data.
-
Zero‑knowledge / local‑first model:
- The server only ever sees ciphertext (or nothing at all).
- Data never leaves the user’s device, protecting against theft, malware, shared computers, and forensic analysis.
- Even if hardware is seized, the attacker gets only noise.
Using the Web Crypto API
The Web Crypto API is built into modern browsers, hardware‑accelerated, and has no external supply‑chain dependencies.
const cryptoAPI = globalThis.crypto ?? window.crypto;
const subtle = cryptoAPI.subtle;
Generating Keys
// AES‑GCM encryption key (256‑bit)
async function generateEncryptionKey(): Promise {
return subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
}
// HMAC‑SHA‑256 key for integrity
async function generateHMACKey(): Promise {
return subtle.generateKey(
{ name: 'HMAC', hash: 'SHA-256' },
true,
['sign', 'verify']
);
}
Encryption
interface EncryptedPayload {
ciphertext: string;
iv: string;
hmac: string;
algorithm: string;
version: string;
timestamp: string;
}
/**
* Encrypts arbitrary data with AES‑GCM and signs the ciphertext with HMAC.
*/
async function encrypt(
data: T,
encryptionKey: CryptoKey,
hmacKey: CryptoKey
): Promise {
const plaintext = JSON.stringify(data);
const plaintextBytes = new TextEncoder().encode(plaintext);
// Fresh IV for every encryption
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertextBuffer = await subtle.encrypt(
{ name: 'AES-GCM', iv },
encryptionKey,
plaintextBytes
);
const hmacSignature = await subtle.sign('HMAC', hmacKey, ciphertextBuffer);
return {
ciphertext: arrayBufferToBase64(ciphertextBuffer),
iv: arrayBufferToBase64(iv.buffer),
hmac: arrayBufferToBase64(hmacSignature),
algorithm: 'AES-256-GCM',
version: '2.0.0',
timestamp: new Date().toISOString(),
};
}
Why HMAC?
Even though AES‑GCM provides authentication, an additional HMAC layer offers “belt‑and‑suspenders” protection. Verification occurs before decryption, allowing fast rejection of tampered payloads.
Decryption
/**
* Decrypts a payload after verifying its HMAC.
*/
async function decrypt(
payload: EncryptedPayload,
encryptionKey: CryptoKey,
hmacKey: CryptoKey
): Promise {
const ciphertextBuffer = base64ToArrayBuffer(payload.ciphertext);
const iv = base64ToArrayBuffer(payload.iv);
const expectedHmac = base64ToArrayBuffer(payload.hmac);
const isValid = await subtle.verify(
'HMAC',
hmacKey,
expectedHmac,
ciphertextBuffer
);
if (!isValid) {
throw new Error('Integrity check failed');
}
const plaintextBuffer = await subtle.decrypt(
{ name: 'AES-GCM', iv },
encryptionKey,
ciphertextBuffer
);
return JSON.parse(new TextDecoder().decode(plaintextBuffer));
}
Storage Considerations
| Option | Drawbacks |
|---|---|
localStorage | Vulnerable to XSS |
sessionStorage | Lost on tab close |
IndexedDB | Still XSS‑prone |
| Password‑derived key (user types each time) | Poor UX |
extractable: false (non‑exportable keys) | Cannot back up or migrate |
Layered Keys
- Master key wraps per‑entry data keys.
- Password‑derived key wraps backups, enabling secure migration.
/**
* Wraps a CryptoKey for storage (e.g., in IndexedDB).
*/
async function wrapKeyForStorage(
keyToWrap: CryptoKey,
wrappingKey: CryptoKey
): Promise {
const iv = crypto.getRandomValues(new Uint8Array(12));
const wrapped = await subtle.wrapKey(
'raw',
keyToWrap,
wrappingKey,
{ name: 'AES-GCM', iv }
);
return JSON.stringify({
wrapped: arrayBufferToBase64(wrapped),
iv: arrayBufferToBase64(iv.buffer),
});
}
Password‑Derived Keys
/**
* Derives an AES‑GCM key from a password using PBKDF2.
* Default: 150 000 iterations (hard‑coded for legal‑risk scenarios).
*/
async function deriveKeyFromPassword(
password: string,
salt: Uint8Array,
iterations: number = 150_000
): Promise {
const passwordBuffer = new TextEncoder().encode(password);
const baseKey = await subtle.importKey(
'raw',
passwordBuffer,
'PBKDF2',
false,
['deriveBits', 'deriveKey']
);
return subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations,
hash: 'SHA-256',
},
baseKey,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
}
- Salt is stored alongside the backup (not secret).
- Iteration count is also stored, allowing future increases without breaking existing backups.
Key Rotation
Regular rotation isn’t just a best practice; it’s a response to device loss, compromise, or legal pressure.
async function rotateKey(keyId: string): Promise {
const newBundle = await generateKeyBundle(); // new enc + HMAC keys
const allEntries = await loadAllEncryptedEntries(); // fetch all stored payloads
const oldKey = await getKey(keyId); // retrieve old bundle
for (const entry of allEntries) {
const plaintext = await decrypt(entry.data, oldKey.enc, oldKey.hmac);
const newCiphertext = await encrypt(plaintext, newBundle.encryptionKey, newBundle.hmacKey);
await updateEntry(entry.id, newCiphertext);
}
await archiveKey(keyId, oldKey); // keep for recovery if needed
await storeKey(keyId, newBundle);
await logSecurityEvent({
type: 'key_rotation',
keyId,
timestamp: new Date(),
});
}
Auditing
All cryptographic operations are logged locally; nothing is sent to a server.
interface SecurityEvent {
type: string;
keyId?: string;
timestamp: Date;
[prop: string]: any;
}
/**
* Stores a bounded audit log in localStorage.
*/
function logSecurityEvent(event: SecurityEvent): void {
const auditLog = JSON.parse(localStorage.getItem('security_audit') ?? '[]');
auditLog.push({ ...event, timestamp: event.timestamp.toISOString() });
// Keep the most recent 1 000 entries
if (auditLog.length > 1000) {
auditLog.splice(0, auditLog.length - 1000);
}
localStorage.setItem('security_audit', JSON.stringify(auditLog));
}
These logs can be presented to users or auditors without involving any backend.
Implementation Notes
- Private‑browsing:
localStoragemay be unavailable; fall back to in‑memory storage and warn the user that data won’t persist. - Testing: Web Crypto behaves differently under Vitest; mock the API or use
@peculiar/webcrypto. - Base64 handling: Node.js and browsers differ. Use a helper that works in both environments.
function arrayBufferToBase64(buffer: ArrayBuffer): string {
if (typeof Buffer !== 'undefined') {
// Node.js environment
return Buffer.from(buffer).toString('base64');
}
// Browser environment
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function base64ToArrayBuffer(base64: string): ArrayBuffer {
if (typeof Buffer !== 'undefined') {
// Node.js
return Buffer.from(base64, 'base64').buffer;
}
const binary = atob(base64);
const len = binary.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
Conclusion
By keeping encryption entirely on the client, using strong primitives (AES‑256‑GCM, HMAC‑SHA‑256, PBKDF2 with 150 k iterations), and logging everything locally, you can protect health data from subpoenas, breaches, and any party that might weaponise a patient’s personal narrative. This architecture is built for people whose data could be used against them—disability claimants, chronic‑pain patients, and anyone the system has already decided to disbelieve.