Skip to content

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: truer.secret()
WannUser/Admin darf den Wert sehenNur Server-Code nutzt ihn
StorageFeld auf regulaerer EntityEigene tenant_secret-Tabelle
ResponseKlartext an Read-Berechtigte, sonst maskiertNIE Klartext ueber HTTP — nur redact-Preview
Use-CaseIBAN, Passnummer, Telefonnummer, Notizen mit PIISMTP-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

OperationVerhalten
SchreibenFramework 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/SortVerboten — Boot-Validierung verhindert die Kombination mit searchable/sortable
AuditLogt “Feld geaendert”, nie den Wert
Update ohne ChangeWenn 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 (liest KUMIKO_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-Typ type: "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: true auf type: "number" → Boot-Fehler
  • Boot-Validierung: piiEncrypted: true + searchable: true → Boot-Fehler