Engineering & Beveiliging11 mei 20269 min lezen

Hoe Boekify uw financiële data beschermt: veldsgewijze versleuteling en de CFO AI-anonimisatielaag

Boekify is een financieel dashboard voor privé en zakelijk — een product dat alleen vertrouwen verdient als de techniek de belofte op de marketingpagina waarmaakt. Deze post is een gedetailleerde doorloop van twee pijlers van die techniek: de veldsgewijze versleuteling van iedere identificerende databasekolom, en de anonimisatielaag van CFO AI waarmee u met een AI-CFO kunt chatten zonder dat uw naam, adres, IBAN of BSN de applicatie verlaat.

Alles in deze post is geïmplementeerd in de open broncode van het product (backend/core/crypto.py, backend/core/keys.py, backend/core/encrypted_types.py en backend/services/cfo_ai.py). De bijpassende UI — het CFO-dashboard met de indicator “Anonieme sessie actief” rechtsboven en een chatpaneel met label “Anoniem · versleuteld” — is gedefinieerd in docs/PRDs/boekify_CFO_Dashboard.jsx. Hieronder leest u wat die labels in de praktijk betekenen.

1. Dreigingsmodel in één alinea

We gaan ervan uit dat applicatiecode, database en LLM-provider drie aparte vertrouwenszones zijn. Een lees-actie op het Postgres-volume mag geen leesbare PII opleveren. Een onderschept verzoek aan de LLM mag geen identificatoren bevatten die naar een echt persoon of bedrijf leiden. Een gelekte back-up mag geen de-anonimisatie mogelijk maken. De mechanismen hieronder volgen uit deze drie eisen.

2. Versleuteling at rest: twee-laags envelope keys

2.1 Eén master KEK, veel DEKs

We gebruiken envelope-encryptie met twee lagen sleutels:

  • Master Key-Encryption Key (KEK). Eén 32-byte-sleutel, geladen uit ENCRYPTION_MASTER_KEY bij processtart. Hij verlaat het geheugen niet en wordt alleen gebruikt om per-eigenaar sleutels te wrappen en om via HKDF-SHA256 hulpsleutels af te leiden. De subkey-afleiding krijgt expliciete info-labels (boekify/dek_wrap/v1, boekify/blind_index/v1), zodat compromittering van één doel nooit ciphertext kan vervalsen voor een ander doel.
  • Data Encryption Key (DEK) per eigenaar. Iedere User en iedere Company heeft een eigen 32-byte DEK, gegenereerd met secrets.token_bytes bij de eerste schrijf-actie. De DEK wordt gewrapped onder de master KEK met AES-256-GCM (wrap_dek) en opgeslagen in de tabellen user_keys / company_keys als zelfbeschrijvend blob: version(1) || nonce(12) || ciphertext+tag.

Bij ieder request unwrapt een FastAPI-dependency de DEK één keer en stuurt hem naar een ContextVar; SQLAlchemy leest hem daar terug bij bind- en result-processing. De unwrapte DEK wordt gecached op Session.info voor de levensduur van het request, zodat round-trips naar de database de unwrap niet bij iedere kolom hoeven te herhalen.

2.2 AES-256-GCM met AAD-binding

Elk versleuteld veld is beveiligd met AES-256-GCM en een verse 12-byte nonce. De ciphertext is gebonden aan zijn herkomstkolom doordat we tabel- en kolomnaam meegeven als Additional Authenticated Data (AAD). Een rij die uit users.email_enc wordt gehaald en in users.phone_enc wordt geplakt, faalt op decryptie — de tag-check weigert context-verschuiving. Iedere ciphertext krijgt bovendien een 1-byte versieprefix, zodat we een toekomstige algoritmewissel kunnen uitrollen zonder oude rijen te hoeven herschrijven.

2.3 Versleutelde SQLAlchemy-kolomtypes

De versleuteling blijft buiten de business-code. ORM-modellen gebruiken vier custom kolomtypes uit core.encrypted_types:

  • EncryptedString, EncryptedDate, EncryptedNumeric, EncryptedInteger — opgeslagen als BYTEA. Een bind-hook encryptt bij schrijven, een result-hook decryptt bij lezen; beide lezen de DEK uit de request-context.
  • BlindIndexString — bewaart een deterministische HMAC-SHA256 hex-digest (64 tekens) van de genormaliseerde waarde. Het is de zustertabelkolom die we gebruiken wanneer een EncryptedString een geïndexeerde equality-lookup nodig heeft (bijvoorbeeld “zoek de bankrekening met deze IBAN”). De HMAC-sleutel komt via HKDF uit de master KEK, dus roteren van de KEK roteert ieder blind-index automatisch.

Concreet zijn onder andere versleuteld: e-mail, telefoon, adres, geboortedatum, geboortedatum partner, geboortedata kinderen, eigen IBANs; en aan de zakelijke kant: KvK, btw-nummer, RSIN, factuur- en klantgegevens en hoofd-IBAN.

2.4 Wachtwoorden en tokens

Wachtwoorden worden nooit versleuteld — ze worden gehasht met bcrypt (core/security.py::get_password_hash), met een salt per wachtwoord; alleen de hash wordt bewaard. Sessietokens zijn óf legacy app-JWTs (HMAC-SHA256 met SECRET_KEY) óf Supabase access tokens, die we verifiëren via het legacy HS256-secret óf — na de Supabase-sleutelsmigratie — via de JWKS-endpoint op /auth/v1/.well-known/jwks.json met ES256 of RS256. Iedere versleutelde route eist een geldig token vóór de DEK wordt unwrapped.

2.5 Sleutelrotatie en AVG-wissen

Twee operationele eigenschappen volgen uit het ontwerp:

  • Master-key-rotatie raakt alleen de tabellen user_keys / company_keys aan — we unwrappen DEKs onder de oude KEK, wrappen ze opnieuw onder de nieuwe, en bouwen de blind-indexen opnieuw op. Versleutelde PII-kolommen worden niet opnieuw versleuteld. Dat is precies wat het rotatiescript in backend/scripts/rotate_master_key.py doet.
  • Recht om vergeten te worden is daardoor snel: het verwijderen van de DEK-rij van een eigenaar maakt iedere ciphertext van die eigenaar permanent onleesbaar. Een bulk-her- encryptie van PII-kolommen is niet nodig.

3. De anonimisatielaag van CFO AI

Het dashboardprototype in docs/PRDs/boekify_CFO_Dashboard.jsx toont twee beveiligingsclaims direct in de UI: een tab-header met “Anonieme sessie actief” en een chatpaneel met label “Anoniem · versleuteld”. Dat zijn geen decoratie — ze zijn de zichtbare kant van vier concrete backend-controls in backend/services/cfo_ai.py.

3.1 Strikte context-allowlist

Voordat een prompt naar de LLM gaat, wordt de context opnieuw opgebouwd vanuit een hard-coded allowlist. De privé-allowlist is precies vijf velden — annual_income, monthly_income, monthly_expenses, unknown_amount, jaarruimte_total. De zakelijke allowlist is even smal — company_type, profit_ytd, btw_to_pay, overdue_amount, btw_coverage_pct, plus grove aggregaten als sales_ytd, fiscale kalendercontext en bedrijfsleeftijdbucket. Alles wat niet op die lijst staat — namen, adressen, IBANs, e-mails van klanten, omschrijvingen van transacties, factuurnummers — komt niet in de prompt, omdat de allowlist een positieve filter is: de nieuwe dict wordt opgebouwd uit {key: base[key] for key in allowlist if key in base}, niet uit de originele payload.

3.2 Pseudonieme sessie, nooit een user-id

Elke CFO AI-sessie is gekoppeld aan een vers gegenereerde UUID (uuid.uuid4()) die als session_uuid aan de context wordt gehangen. Interne identifiers — user-id, company-id, e-mailadres — gaan niet naar de LLM en worden niet in de body van het audit-log geschreven. De sessie-UUID is de sleutel waarop het audit-trail en het chatpaneel correleren — dat is waarom de UI de sessie eerlijk anoniem kan noemen.

3.3 Defensieve PII-detector

Zelfs met een positieve allowlist draait er een tweede check vóór ieder uitgaand request. contains_pii_data()loopt recursief door de payload en flagt zowel structurele markers (sleutels als email, iban, bsn, address, phone, …) als waarde-markers via reguliere expressies: een NL-achtig IBAN-patroon ([A-Z]{2}\\d{2}[A-Z0-9]{4}\\d{10}), een 9-cijferige BSN-achtige token en een e-mailpatroon. Wanneer CFO_AI_BLOCK_PII aanstaat en PII wordt gedetecteerd, wordt het verzoek geblokkeerd voordat het het proces verlaat; in het audit-log verschijnt status="blocked", reason="pii_detected".

3.4 Provider-gate en local-only modus

Met CFO_AI_REQUIRE_LOCAL_PROVIDER aan is de enige toegestane provider een lokale Ollama-runtime. Een poging om een cloudprovider te bellen terwijl deze toggle aanstaat, wordt afgekapt met reason="provider_not_local" — zo kan een operator het volledige LLM-pad afdwingen om de host nooit te verlaten. Een circuit breaker (_CFO_CB_*) klapt na een glijdend venster van transportfouten en pauzeert uitgaande aanroepen tot de cooldown verstreken is, zodat een misdragende provider geen denial of service of retry-storm wordt.

3.5 Audit zonder exfiltratie

Ieder CFO AI-request krijgt een audit-record via build_cfo_ai_audit_record(). Daar staat alleen in: timestamp, mode (personal / business), provider, status (success / blocked / fallback), reden, de sessie-UUID, de actieve control-flags en de namen van de meegestuurde contextsleutels — niet de waarden. Daardoor is het trail bruikbaar voor incident-respons zonder een tweede kopie te worden van de data die we juist proberen te beschermen. Het record komt ook terecht in een tamper-evident on-disk log voor het health-dashboard op /cfo-ai/health.

4. End-to-end architectuur

Beide pijlers op één plaatje:

              ┌─────────────────────────────────────────────────────────────┐
              │              Boekify  ·  CFO AI request                     │
              └────────────────────────────┬────────────────────────────────┘
                                           │ HTTPS (TLS 1.2+)
                                           ▼
               ┌──────────────────────────────────────────────────────────┐
               │  FastAPI route   (/analytics/cfo/personal/chat …)        │
               │   · Auth: Supabase JWT (HS256 or ES/RS via JWKS)         │
               │   · Loads request-scoped DEK into ContextVar             │
               └────────────────────────────┬─────────────────────────────┘
                                            │
              ┌─────────────────────────────┴──────────────────────────────┐
              ▼                                                            ▼
 ┌────────────────────────┐                              ┌───────────────────────────┐
 │  Encrypted Postgres    │                              │  CFO AI anonymisation     │
 │  ── EncryptedString    │                              │  ── allowlist context     │
 │  ── EncryptedDate      │                              │  ── contains_pii_data()   │
 │  ── EncryptedNumeric   │                              │  ── session UUID (no id)  │
 │  ── BlindIndexString   │                              │  ── audit record (no PII) │
 └─────────┬──────────────┘                              └────────────┬──────────────┘
           │ AES-256-GCM + AAD                                         │
           │ (DEK per user / per company)                              │
           ▼                                                            ▼
 ┌────────────────────────┐                              ┌───────────────────────────┐
 │  user_keys / company_  │                              │  Provider gate            │
 │  keys (wrapped DEKs)   │                              │  · local-only? (Ollama)   │
 │  wrapped by master KEK │                              │  · circuit breaker        │
 │  via HKDF-SHA256       │                              │  · timeout + retry        │
 └─────────┬──────────────┘                              └────────────┬──────────────┘
           │                                                          │
           ▼                                                          ▼
 ┌────────────────────────┐                              ┌───────────────────────────┐
 │  ENCRYPTION_MASTER_KEY │                              │  LLM provider (OpenAI /   │
 │  (env, 32 bytes, KEK)  │                              │  Mistral / local Ollama)  │
 └────────────────────────┘                              └───────────────────────────┘
Boekify CFO AI — requestpad met veldsgewijze versleuteling (links) en anonimisatie-gate (rechts).

De linkerhelft is het opslagpad: iedere PII-kolom is versleuteld onder een DEK per eigenaar, die zelf onder de master KEK is gewrapped. De rechterhelft is het AI-pad: zelfs als een geauthenticeerde gebruiker een CFO-gesprek start, verlaat alleen de pseudonieme, allowlisted context het proces — en pas nadat de PII-detector én de provider-gate beide groen licht hebben gegeven.

5. Wat dat in de praktijk betekent

  • Alleen de database is niet genoeg. Het lezen van versleutelde PII-kolommen uit Postgres levert ciphertext op die alleen werkbaar is met een DEK uit de envelope-keys.
  • De LLM-provider ziet aggregaten, geen mensen. Namen, IBANs, adressen en BSN-achtige tokens bereiken de prompt niet; de provider ziet alleen grove financiële getallen en een sessie-UUID.
  • Operationele macht is geconcentreerd. Eén master KEK roteert alles; één DEK-rij verwijdert alles voor één eigenaar. Beide acties zijn auditbaar en alleen via de formele scripts in backend/scripts/ bereikbaar.
  • Anonimiteit is een afspraak, geen marketingzin. De UI-badges uit de CFO-dashboard PRD (“Anonieme sessie actief”, “Anoniem · versleuteld”) verwijzen direct naar anonymize_cfo_context(), contains_pii_data(), de local-provider-gate en het PII-loze audit-record.

Wilt u verder lezen? Het beleidsdocument op /privacy behandelt bewaartermijnen en uw AVG-rechten; de broncode blijft de bron van waarheid voor alles wat hier staat en is de eerste plek om te kijken zodra er iets is veranderd.