Encryptie Implementatie

Alle notities worden client-side met AES-256-GCM versleuteld. De server ziet nooit plaintext.

Algoritme

  • Symmetrisch: AES-256-GCM (Galois/Counter Mode)
  • Sleutel: 256-bit, random gegenereerd per cirkel
  • IV (Initialisatievector): 12-byte, random per notitie
  • API: Web Crypto API (native browser API)

Sleutel Management

Generatie (Bij Cirkel Aanmaken)

export async function genereerCirkelSleutel(): Promise<string> {
  const sleutel = await crypto.subtle.generateKey(
    { name: 'AES-GCM', length: 256 },
    true,  // exportable
    ['encrypt', 'decrypt']
  );

  const raw = await crypto.subtle.exportKey('raw', sleutel);
  return btoa(String.fromCharCode(...new Uint8Array(raw)));
  // Returns base64-encoded 256-bit key
}

Opslag

  • Client: In-memory tijdens sessie
  • Server: Opgeslagen in Supabase Vault (encrypted at rest)
    • Vault key = master encryption key op server
    • Cirkel-sleutel = versleuteld met master key
    • Mentor kan sleutel ophalen (met RLS)

Vrijgave (Op Opening)

-- RLS Policy: cirkel_sleutel NULL tot opening_datum voorbij
CREATE POLICY "sleutel_pas_na_opening" ON cirkels
  FOR SELECT USING (
    CASE
      WHEN opening_datum <= CURRENT_DATE THEN TRUE
      ELSE (cirkel_sleutel IS NULL)
    END
  );

Encryption Flow

Notitie Schrijven

export async function versleutelNotitie(
  tekst: string,
  cirkelSleutelB64: string
): Promise<{ versleuteld: string; iv: string }> {
  // 1. Decode sleutel van base64
  const sleutelBytes = Uint8Array.from(
    atob(cirkelSleutelB64),
    c => c.charCodeAt(0)
  );

  // 2. Import als WebCrypto key
  const sleutel = await crypto.subtle.importKey(
    'raw',
    sleutelBytes,
    { name: 'AES-GCM' },
    false,  // niet exportable
    ['encrypt']
  );

  // 3. Genereer random IV
  const iv = crypto.getRandomValues(new Uint8Array(12));

  // 4. Versleutel
  const encoder = new TextEncoder();
  const versleuteld = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    sleutel,
    encoder.encode(tekst)
  );

  // 5. Return base64-encoded ciphertext + IV
  return {
    versleuteld: btoa(String.fromCharCode(...new Uint8Array(versleuteld))),
    iv: btoa(String.fromCharCode(...iv))
  };
}

Stuur naar server:

{
  "cirkel_id": "uuid",
  "ontvanger_id": "uuid",
  "versleutelde_tekst": "base64-ciphertext",
  "iv": "base64-iv"
}

Server slaat op zonder ooit plaintext te zien.

Notitie Lezen (Na Opening)

export async function ontsleutelNotitie(
  versleuteldB64: string,
  ivB64: string,
  cirkelSleutelB64: string
): Promise<string> {
  // 1. Decode sleutel
  const sleutelBytes = Uint8Array.from(
    atob(cirkelSleutelB64),
    c => c.charCodeAt(0)
  );

  // 2. Import
  const sleutel = await crypto.subtle.importKey(
    'raw',
    sleutelBytes,
    { name: 'AES-GCM' },
    false,
    ['decrypt']
  );

  // 3. Decode IV + ciphertext
  const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0));
  const data = Uint8Array.from(atob(versleuteldB64), c => c.charCodeAt(0));

  // 4. Ontsleutel
  const ontsleuteld = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv },
    sleutel,
    data
  );

  return new TextDecoder().decode(ontsleuteld);
}

Security Properties

Wat De Server Beschermt

✅ Server kan nooit notitie-inhoud lezen ✅ Zelfs admin van Supabase kan niet decrypten ✅ Ciphertext is onbruikbaar zonder sleutel ✅ IV is uniek per notitie (voorkomt pattern analysis)

Wat De Server WEL Controleert

✅ RLS blokkeert sleutel tot opening_datum ✅ Cron job ontsleutelt op exacte datum ✅ Notities kunnen niet worden gewijzigd (immutable) ✅ Audit log van alles

Beperking

⚠️ Gebruiker MOET op opening_datum de pagina bezoeken ⚠️ Sleutel is in-memory, niet in localStorage (beter, maar minder persistentie) ⚠️ Ontsleuteling gebeurt in browser — plaintext even in RAM

Testing Encryptie

// Test encrypt → decrypt
const tekst = "Geheim bericht";
const sleutel = await genereerCirkelSleutel();
const { versleuteld, iv } = await versleutelNotitie(tekst, sleutel);
const decrypted = await ontsleutelNotitie(versleuteld, iv, sleutel);

console.assert(decrypted === tekst, "Encrypt/decrypt failed");

Vault (Server-Side)

Supabase Vault beschermt de cirkel-sleutel:

// In Edge Function (server-side only)
const { data: vault } = await supabase
  .from('cirkels')
  .select('cirkel_sleutel')
  .eq('id', cirkelId)
  .eq('opening_datum', today)
  .single();

// vault.cirkel_sleutel wordt geONTSLEUTELD door Vault
// Stuur naar client

AES-256-GCM Details

Eigenschap Waarde
Algoritme AES-256-GCM
Sleutelgrootte 256 bits (32 bytes)
IV-grootte 96 bits (12 bytes)
Authenticatie GCM (Galois/Counter Mode)
Padding Geen (GCM handelt dit af)

Waarom GCM?

  • Versleuteling + authenticatie in één
  • Detect wijzigingen aan ciphertext
  • Geen padding oracle attacks

Referenties