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_KEYbij 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 explicieteinfo-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
Useren iedereCompanyheeft een eigen 32-byte DEK, gegenereerd metsecrets.token_bytesbij de eerste schrijf-actie. De DEK wordt gewrapped onder de master KEK met AES-256-GCM (wrap_dek) en opgeslagen in de tabellenuser_keys/company_keysals 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 alsBYTEA. 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 eenEncryptedStringeen 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_keysaan — 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 inbackend/scripts/rotate_master_key.pydoet. - 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) │
└────────────────────────┘ └───────────────────────────┘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.