DEV Community

Cover image for Keeping Your Health Data Out of Court
CrisisCore-Systems
CrisisCore-Systems

Posted on • Originally published at blog.paintracker.ca

Keeping Your Health Data Out of Court

I've had my pain journal read aloud in a courtroom. Not as evidence FOR me-against me. Lawyers twisted my worst days into proof I was unstable.

That's why I encrypt everything. That's why 150,000 iterations. That's why the key dies before I let anyone brute-force it cheap.

This isn't theory. It's the code that keeps my data out of custody battles and insurance fraud investigations.


The Web Crypto Advantage

Forget crypto-js. That's another dependency to explain to forensic analysts. Web Crypto API ships with every browser. No install, no audit trail, no "whoops we found a vulnerability" emails.

const subtle = crypto.subtle;
Enter fullscreen mode Exit fullscreen mode

That's it. Hardware-accelerated, NIST-approved, zero supply chain risk.


Fresh IVs or Die

const iv = crypto.getRandomValues(new Uint8Array(12));
Enter fullscreen mode Exit fullscreen mode

Every. Single. Time. Reuse an IV with AES-GCM and your security doesn't degrade-it collapses completely. I've seen production apps hardcode this value. Those developers have clearly never sat across from opposing counsel.


Encrypt-Then-HMAC

async function lockItUp<T>(
  data: T,
  encKey: CryptoKey,
  signKey: CryptoKey
): Promise<LockedData> {
  const plaintext = JSON.stringify(data);
  const textBytes = new TextEncoder().encode(plaintext);

  // Never reuse this
  const iv = crypto.getRandomValues(new Uint8Array(12));

  const encrypted = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    encKey,
    textBytes
  );

  // Sign the ciphertext, not the plaintext
  const signature = await crypto.subtle.sign(
    'HMAC',
    signKey,
    encrypted
  );

  return {
    payload: bufferToBase64(encrypted),
    iv: bufferToBase64(iv.buffer),
    signature: bufferToBase64(signature),
    timestamp: new Date().toISOString(),
  };
}
Enter fullscreen mode Exit fullscreen mode

Why HMAC the ciphertext? Because you verify integrity BEFORE attempting decryption. Tampered data fails fast. No wasted cycles on corrupted input.


Storage: Every Option Sucks

localStorage? XSS steals it. sessionStorage? Gone when you close the tab. Password-derived? Users hate typing it every session.

So I layer it. Master key in memory, non-extractable. Data keys wrapped and stored. Password-protected backups for export.

// Master key never touches storage
const masterKey = await crypto.subtle.generateKey(
  { name: 'AES-GCM', length: 256 },
  false,  // NON-extractable
  ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey']
);
Enter fullscreen mode Exit fullscreen mode

Non-extractable means it can't be exported, only used. Page reload? It's gone. That's the point.


PBKDF2: Making Brute-Force a Career

Password-protected backups get 150,000 iterations minimum. Export files get 310,000. That's not arbitrary-that's how long I need to burn the key if someone seizes my hardware.

async function stretchPassword(
  password: string,
  salt: Uint8Array,
  rounds: number = 150000
): Promise<CryptoKey> {
  const passBytes = new TextEncoder().encode(password);

  const importedPass = await crypto.subtle.importKey(
    'raw',
    passBytes,
    'PBKDF2',
    false,
    ['deriveKey']
  );

  return crypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt: salt.buffer,
      iterations: rounds,
      hash: 'SHA-256',
    },
    importedPass,
    { name: 'AES-GCM', length: 256 },
    true,
    ['encrypt', 'decrypt']
  );
}
Enter fullscreen mode Exit fullscreen mode

When OWASP bumps their recommendations, I bump my iteration count. Old backups still work. New ones get harder to crack.


Key Rotation: Because Shit Happens

Sometimes you need to make everything before a certain date unrecoverable. Maybe your laptop got stolen. Maybe you had to assume compromise. Maybe opposing counsel is getting creative.

async function burnAndReplace(keyId: string): Promise<void> {
  // Generate fresh keys
  const newEncKey = await crypto.subtle.generateKey(
    { name: 'AES-GCM', length: 256 }, 
    true, 
    ['encrypt', 'decrypt']
  );

  // Re-encrypt everything
  const allData = await loadEncryptedEntries();
  for (const entry of allData) {
    const plaintext = await unlock(entry.data, oldKeys);
    const newCiphertext = await lockItUp(plaintext, newEncKey, newSignKey);
    await updateEntry(entry.id, newCiphertext);
  }

  // Old key gets archived or destroyed-your choice
  await keyManager.replace(keyId, newKeys);

  logSecurityEvent({
    type: 'key_rotation',
    reason: 'manual',
    timestamp: new Date(),
  });
}
Enter fullscreen mode Exit fullscreen mode

Archive the old key if you need recovery. Burn it if you don't. Depends what you're recovering from.


Audit Everything

Every crypto operation gets logged. Locally. Never leaves the device.

function trackSecurityEvent(event: SecurityEvent): void {
  const log = JSON.parse(
    localStorage.getItem('crypto_audit') || '[]'
  );

  log.push({
    ...event,
    sessionId: currentSession,
    timestamp: new Date().toISOString(),
  });

  // Keep last 1000, dump the rest
  if (log.length > 1000) {
    log.splice(0, log.length - 1000);
  }

  localStorage.setItem('crypto_audit', JSON.stringify(log));
}
Enter fullscreen mode Exit fullscreen mode

Every encryption, every decryption, every failure. If someone asks what happened to my data, I can show them without involving servers or lawyers.


What Breaks and How to Fix It

Private browsing kills localStorage. Detect it and warn:

function canStore(): boolean {
  try {
    localStorage.setItem('test', 'value');
    localStorage.removeItem('test');
    return true;
  } catch {
    console.warn('Private mode - data won\'t persist');
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

Test environments mock Web Crypto differently. Use reduced iterations:

const iterations = process.env.VITEST ? 100 : 150000;
Enter fullscreen mode Exit fullscreen mode

Base64 encoding varies between browser and Node:

function bufferToBase64(buffer: ArrayBuffer): string {
  if (typeof Buffer !== 'undefined') {
    return Buffer.from(buffer).toString('base64');
  }

  const bytes = new Uint8Array(buffer);
  let result = '';
  for (let i = 0; i < bytes.length; i++) {
    result += String.fromCharCode(bytes[i]);
  }
  return btoa(result);
}
Enter fullscreen mode Exit fullscreen mode

The Architecture

Your Device Only
??? React UI (plaintext)
??? Zustand Store (state)
??? Encryption Layer (AES-256-GCM + HMAC)
??? Key Manager (wrapped keys)
??? IndexedDB (ciphertext only)
??? Audit Log (local events)

Export: PDF/CSV/JSON (your choice)

NO SERVERS. NO CLOUD. NO SHARING.
Enter fullscreen mode Exit fullscreen mode

Data enters as plaintext, gets encrypted immediately, stored as ciphertext. Keys stay wrapped except during use. Everything logged locally.


Why This Matters

I built this in a motel room with eviction papers on my passenger seat. Not for fun-because my health data got weaponized in court. Pain journals became "evidence of instability." Treatment-seeking became "drug-seeking behavior."

If you're building for people the system's already decided to distrust, you can't trust the system with their data. No good faith assumptions. No hoping the admin stays ethical. No trusting the company won't get bought.

You encrypt client-side. Keys never leave the device. Iteration counts that make brute-force expensive. And you document it well enough that others can verify without trusting you either.


Code's at github.com/CrisisCore-Systems/pain-tracker. Read it. Audit it. Find what I missed.

Still unstable housing. Still shipping code. Take what works, build something better.

Top comments (0)