Toate articolele
SaaS multi-tenant · 8 min citire ·

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.

securitatecriptareAES-GCMmulti-tenant.NET

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ă.

Când merită?

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:

  • identificatori naționali, date de sănătate, date bancare;
  • câmpuri pe care le citești rar și le cauți și mai rar;
  • date pentru care ai o cerință explicită (contractuală sau de reglementare).

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.

De ce AES-256-GCM?

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.

Cum derivi o cheie per tenant?

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.

Ce e critic la nonce?

Ă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:

  • 12 octeți (96 de biți) e dimensiunea de folosit — și singura pe care AesGcm din .NET o acceptă;
  • stochează-l lângă textul cifrat — nu e secret, doar unic;
  • un contor e construcția mai puternică dacă poți garanta că nu se repetă niciodată între procese, reporniri și replici. Într-un SaaS scalat orizontal asta e sincer greu, deci un generator criptografic e alegerea pragmatică;
  • cu nonce-uri aleatoare de 96 de biți, limita sigură e un număr publicat, nu o senzație: NIST SP 800-38D §8.3 plafonează construcția aleatoare la 2^32 criptări per cheie. Numără criptările per versiune de cheie și rotește înainte să te apropii — o re-criptare în fundal pe un tabel mare, peste traficul normal, ajunge acolo mai repede decât ai crede.

Ce sunt datele autentificate suplimentar (AAD)?

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.

Cum cauți în date criptate?

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 criptareinfo: "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:

  • indexul orb dezvăluie tipare de egalitate în interiorul unui tenant. Pentru câmpuri cu puține valori distincte, asta poate fi suficient pentru a deduce conținutul; trunchierea indexului induce deliberat coliziuni și tocește analiza de frecvență, cu prețul unei treceri de decriptare la filtrarea finală;
  • normalizarea trebuie să fie canonică și înghețată (formă Unicode, uniformizarea literelor mari/mici, eliminarea spațiilor). O schimbi mai târziu și fiecare intrare existentă din index rămâne tăcut orfană.

Cum rotești cheile?

Î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:

  1. crești versiunea (derivată) sau provisionezi o nouă generație de master;
  2. scrii mereu cu versiunea curentă;
  3. la citire, rezolvi cheia din prefixul stocat — deci vechiul și noul coexistă;
  4. re-criptezi treptat, în fundal sau la prima atingere a rândului;
  5. retragi cheia veche abia după ce o interogare confirmă că niciun rând nu mai poartă versiunea ei.

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.

Greșelile care contează

  1. Nonce refolosit. Singura din listă care e catastrofală.
  2. Ignorarea bugetului de nonce — nonce-urile aleatoare sunt sigure până la 2^32 per cheie, iar o re-criptare în masă consumă bugetul mai repede decât traficul zilnic.
  3. Mod neautentificat (CBC fără MAC) — modificări nedetectabile.
  4. Cheie unică pentru toți tenanții — o compromitere le ia pe toate.
  5. Cheie globală de index orb — cheile de criptare per tenant nu mai contează, pentru că indexul corelează oricum persoanele între toți clienții.
  6. Fără versiune de cheie — te blochează la rotație.
  7. Fără AAD — textele cifrate devin mutabile între rânduri.
  8. Criptarea a tot — pierzi căutarea și câștigi foarte puțin.

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.

Proiectul din spatele articolului Workly — Pontaj & Operațiuni Interne Pontaj, concedii, documente, tickets, assets și stocuri — multi-tenant, configurabil per firmă.

Ai un proiect similar?

Dacă lucrezi la ceva din zona asta, hai să vorbim 30 de minute.

Programează o discuție
Toate articolele