PII-verschluesselte Felder (`piiEncrypted`)
Konzept
Personenbezogene Daten (DSGVO) auf Feld-Ebene verschluesseln, die der
berechtigte User legitim sehen darf (eigene IBAN, Passnummer, Passwort-Hash).
Ein Flag piiEncrypted: true auf Text-Feldern — nicht Secrets, die nutzen
ein eigenes Storage (features/core-secrets.md).
Vorher
encrypted: true— umbenannt weil zu generisch (kollidierte semantisch mit Secrets). Der Begriff PII (personal data, personenbezogene Daten) ist DSGVO-Sprache und diskriminiert klar vom Secret-Case.
Abgrenzung zu Secrets
piiEncrypted: true | r.secret() | |
|---|---|---|
| Wann | User/Admin darf den Wert sehen | Nur Server-Code nutzt ihn |
| Storage | Feld auf regulaerer Entity | Eigene tenant_secret-Tabelle |
| Response | Klartext an Read-Berechtigte, sonst maskiert | NIE Klartext ueber HTTP — nur redact-Preview |
| Use-Case | IBAN, Passnummer, Telefonnummer, Notizen mit PII | SMTP-Password, API-Keys, Webhook-Signing-Keys |
Die Krypto ist die gleiche (Envelope Encryption, Master-Key-Provider). Der Unterschied liegt im Zugriffs-Modell.
Entity-Felder
r.entity("tenant", { fields: { name: { type: "text", required: true }, iban: { type: "text", piiEncrypted: true }, passnr: { type: "text", piiEncrypted: true }, },});Config-Keys
r.config({ keys: { billingAddress: { type: "text", piiEncrypted: true, access: { write: ["TenantAdmin"], read: ["TenantAdmin"] }, }, },});Typ-Restriktion (Boot-Validierung)
piiEncrypted: true ist nur auf type: "text" erlaubt. Boot schlaegt fehl bei anderer Kombination mit klarer Fehlermeldung.
Warum: wenn Storage-Column ein Envelope (jsonb) ist, passt der deklarierte Typ “number” nicht mehr zur Realitaet — Sort, Filter, Index geht nicht, der Typ luegt. Wer eine verschluesselte Zahl braucht, konvertiert selbst zu String. (Spaeter ggf. type: "json" zusaetzlich freigegeben, wenn Use-Case auftaucht.)
Storage
Eine jsonb-Spalte pro Feld mit dem kompletten Envelope:
{ ciphertext, iv, authTag, encryptedDek, kekVersion }Table-Builder erkennt piiEncrypted: true und emittet jsonb statt text. Migration kann das automatisch.
Verhalten
| Operation | Verhalten |
|---|---|
| Schreiben | Framework verschluesselt mit Envelope-Encryption, Value laendet als jsonb in der DB |
| Lesen (berechtigt) | Framework entschluesselt nach DB-Read, liefert Klartext in der Response |
| Lesen (unberechtigt) | Feld wird als "••••••" maskiert, kein Decrypt |
| Search/Sort | Verboten — Boot-Validierung verhindert die Kombination mit searchable/sortable |
| Audit | Logt “Feld geaendert”, nie den Wert |
| Update ohne Change | Wenn das Klartext-Feld im Request unveraendert ist (Framework-Diff), wird nicht neu verschluesselt — derselbe Ciphertext bleibt stehen |
Master-Key & Rotation
Nutzt den MasterKeyProvider aus core-secrets:
- Default:
EnvMasterKeyProvider(liestKUMIKO_SECRETS_MASTER_KEY) - Optional: Vault/AWS-KMS/GCP-KMS via eigenes Paket
- Envelope Encryption — jeder Wert hat eigenen DEK, der KEK wrappt nur die DEKs. Rotation = KEK-rewrap aller DEKs, nicht Re-Encrypt aller Ciphertexts.
Rotation-Job (siehe core-secrets) scannt die Registry: fuer jedes Entity/Feld mit piiEncrypted werden alle Rows mit veralteter kekVersion migriert. Online-fahig — waehrend Rotation funktionieren alte und neue KEK-Version gleichzeitig.
Was NICHT piiEncrypted ist
- Passwort-Hashes:
bcrypt/argon2-Hashes sind Einweg und brauchen keine Encryption. Separater Feld-Typtype: "passwordHash". - Secrets (API-Keys, SMTP-Passwords, Webhook-Secrets): nutzen
r.secret()mit eigener Storage und Access-Model. - Opake-Tokens (Session-IDs etc.): selbst nicht sensibel, Verschluesselung nicht noetig.
Tests
Siehe core-secrets — Envelope + Rotation-Tests decken beide Anwendungen ab. Zusaetzlich pro piiEncrypted-Feld:
- Round-trip: Write + Read liefert Klartext fuer Berechtigte
- Maskierung: Unberechtigter sieht
•••••• - Boot-Validierung:
piiEncrypted: trueauftype: "number"→ Boot-Fehler - Boot-Validierung:
piiEncrypted: true+searchable: true→ Boot-Fehler