Criptare la nivel de câmp cu AES-256-GCM și chei derivate per tenant
Cum protejezi câmpurile sensibile într-un SaaS multi-tenant — derivare de chei, nonce, date autentificate suplimentar și partea grea: căutarea și rotația.
Cum protejezi câmpurile sensibile într-un SaaS multi-tenant — derivare de chei, nonce, date autentificate suplimentar și partea grea: căutarea și rotația.
Criptarea „at rest“ oferită de baza de date protejează împotriva unui singur scenariu: cineva pleacă cu discul. Nu te ajută dacă atacatorul are acces la baza de date prin aplicație sau cu un cont valid — pentru că atunci motorul decriptează pentru el, politicos.
Criptarea la nivel de câmp e răspunsul pentru datele care chiar contează: câteva coloane, criptate în aplicație, cu chei pe care baza de date nu le vede niciodată.
Nu pe tot. Criptarea unui câmp îl scoate practic din motorul de căutare al bazei: nu mai poți face LIKE, nu mai poți ordona, nu mai poți indexa util. Deci alegi deliberat:
Regula practică: dacă vrei să cauți după el des, probabil nu vrei să-l criptezi la nivel de câmp — sau ai nevoie de un index orb, despre care mai jos.
Pentru că e un cifru autentificat (AEAD): îți dă confidențialitate și garanția că textul cifrat nu a fost modificat. Cu un mod neautentificat, cum e CBC simplu, un atacator poate altera datele fără să știi — iar tu decriptezi „cu succes“ gunoi.
GCM produce, pe lângă textul cifrat, o etichetă de autentificare. Dacă cineva schimbă un singur bit, decriptarea eșuează. Asta e exact comportamentul pe care îl vrei.
Formatul pe care îl stochez, într-o singură coloană binară:
[1B id algoritm][1B versiune cheie][12B nonce][text cifrat][16B etichetă]
Două detalii care se plătesc mai târziu: id-ul algoritmului îți permite să migrezi la alt cifru fără o zi de oprire generală, iar versiunea cheii face rotația posibilă în primul rând. Ambele intră și în AAD (mai jos), deci niciunul nu poate fi falsificat.
O singură cheie pentru toți clienții înseamnă că o compromitere le expune pe toate. Soluția: o cheie master păstrată într-un seif (Azure Key Vault sau echivalent) și, din ea, o cheie derivată pentru fiecare tenant cu HKDF-SHA256:
// info leagă cheia derivată de tenant și de scopul ei
var key = HKDF.DeriveKey(
HashAlgorithmName.SHA256,
ikm: masterKey,
outputLength: 32,
salt: tenantSalt,
info: Encoding.UTF8.GetBytes($"field-encryption:v1:{tenantId}"));
Avantajele: nu stochezi N chei, ci una singură; poți reconstrui cheia oricărui tenant determinist; iar compromiterea cheii derivate a unui client nu oferă nimic despre ceilalți.
Cheia master nu trebuie să părăsească niciodată seiful mai mult decât e strict necesar, iar cheile derivate trăiesc doar în memorie.
Ăsta e punctul unde greșelile sunt fatale, nu doar neplăcute.
Un nonce nu trebuie folosit niciodată de două ori cu aceeași cheie. Cu GCM, refolosirea nu doar slăbește criptarea — permite recuperarea informației necesare pentru a falsifica etichete de autentificare. E o cădere totală, nu o degradare.
Reguli practice:
AesGcm din .NET o acceptă;Un detaliu subtil și foarte util. GCM permite legarea textului cifrat de un context care nu e criptat, dar e autentificat.
Dacă pui în AAD identificatorul tenantului, numele câmpului și cheia rândului, atunci un text cifrat nu mai poate fi mutat din rândul lui în altul, sau de la un client la altul. Decriptarea va eșua, pentru că nu se mai potrivește contextul.
Fără AAD, cineva cu acces de scriere la baza de date poate copia un câmp criptat dintr-un rând în altul și obține o schimbare validă. Cu AAD, nu.
Trebuie însă să știi unde se oprește: AAD îți dă legare de context, nu integritate temporală. Cineva cu acces de scriere poate încă să restaureze un text cifrat mai vechi al aceleiași celule — același tenant, același câmp, același rând, deci AAD se potrivește perfect — sau pur și simplu să golească coloana. Readucerea unui status sau a unui sold la o valoare validă anterioară e adesea exact atacul care contează. Apărarea împotriva lui cere versionare de rând sau un audit trail, care e un mecanism separat.
O capcană practică: dacă cheia rândului e generată de baza de date, nu o ai la momentul criptării. Ori generezi cheile în aplicație (UUIDv7 păstrează localitatea în index), ori scrii în două faze.
Nu poți — nu direct. Ai trei opțiuni, în ordinea preferinței:
| Abordare | Ce permite | Ce scurge |
|---|---|---|
| Nu cauta | Citire după cheia primară | Nimic |
| Index orb (HMAC determinist al valorii normalizate) | Căutare pe egalitate exactă | Faptul că două rânduri au aceeași valoare |
| Criptare deterministă | Egalitate + grupare | Mai mult decât indexul orb; de evitat |
Indexul orb e compromisul uzual: păstrezi câmpul criptat cu GCM pentru citire și, separat, o coloană cu HMAC(cheie_index, valoare_normalizată) pe care o indexezi. Cauți după HMAC, nu după valoare.
Derivă cheia indexului per tenant, exact ca pe cea de criptare — info: "blind-index:v1:{tenantId}:{numeCâmp}". Ăsta e detaliul cel mai ușor de greșit și cel mai scump de greșit: cu o singură cheie globală de index, același CNP produce același hash în datele fiecărui client, deci oricine ajunge la baza de date poate corela o persoană în toată baza ta de clienți. Exact legătura pe care cheile de criptare per tenant există ca s-o prevină. Costul e că devine imposibilă căutarea pe egalitate între tenanți — ceea ce, într-un produs multi-tenant, e rezultatul corect.
Încă două lucruri de acceptat conștient:
Întâi fii precis cu ce rotești — cele două cazuri nu sunt același lucru:
| Ce rotești | Cum | Ce îți aduce de fapt |
|---|---|---|
Cheia derivată (crești info la ...:v2:...) |
Re-derivi din același master | Un buget de nonce proaspăt, o ruptură curată după scurgerea unei chei derivate |
| Cheia master (cheie nouă în seif) | Re-derivi tot din noul master | Recuperare după compromiterea master-ului — singura rotație care ajută acolo |
Creșterea versiunii din info re-derivă din același master. E genuin util — resetează contorul de 2^32 și izolează o cheie de tenant scursă — dar dacă scapă master-ul însuși, cad cu el toate versiunile derivate din el. Proiectează pentru ambele de la început: ține un identificator de generație a master-ului lângă versiunea cheii și asigură-te că rezolvarea de chei poate lucra cu două master-e simultan în timpul tranziției.
Mecanica, în ambele cazuri:
Rotația costă atunci performanță, nu disponibilitate — cu condiția să fi pus versiunea acolo din prima zi. Atenție în special la pasul 4: o trecere în fundal e ea însăși un lot mare de criptări, deci se scade din bugetul de nonce al cheii noi.
Dacă lucrezi la un produs unde câteva câmpuri chiar trebuie protejate și vrei o verificare pe schema de chei înainte să intre în producție, e o discuție pe care o port cu plăcere.
Dacă lucrezi la ceva din zona asta, hai să vorbim 30 de minute.
Programează o discuție