@generazioneai/crypto
v0.9.0
Published
Cifratura a busta dei segreti nei database (ADR-012): formato unico gx2 con AAD obbligatoria, DEK per servizio e azienda cifrate da una KEK nel KMS (Infisical KMS o chiave locale), lettura dei formati di oggi; KEK di emergenza fuori linea (secondo involuc
Readme
@generazioneai/crypto
Cifratura a busta dei segreti salvati nei database della piattaforma (ADR-012, spec «Fondamenta enterprise» §4, tappa K1).
Un solo formato, gx2, con dati autenticati (AAD) obbligatori; DEK per servizio e azienda, cifrate da una KEK che non esce
dal KMS; lettura dei formati di oggi finché la migrazione (K2-K5) non li ha riscritti.
Installazione (npmjs.org, pubblico dalla 0.2.0 su npm; prima solo su GitHub Packages come @generazioneai-srl/crypto):
npm i @generazioneai/cryptoFormato
gx2.<dekId>.<iv>.<cifrato+tag>- AES-256-GCM, IV casuale di 12 byte, tag di 16 byte in coda al cifrato;
ivecifrato+tagin base64url senza=. dekId: id della riga del registroencryption_keysdel servizio.- AAD, costruita dalla libreria: i campi
gx2,dekId, servizio, tabella, colonna, id della riga, azienda (o null per la piattaforma), ognuno[1 byte: 0 null, 1 testo][lunghezza u32 big-endian][UTF-8]. Un valore copiato su un'altra riga, colonna, tabella, azienda o servizio non si decifra. Un contesto incompleto (anche solojuridicalIddimenticato) è un errore: per i dati della piattaforma si scrivejuridicalId: null. - Vettori di prova in
vettori/gx2.json(deterministici) evettori/legacy.json(formati vecchi, generati con il codice vero dei servizi): li usa anche ilsecretboxv3 di Go (Sentinel).
Uso
import { EnvelopeCrypto, InfisicalKmsKekProvider, LocalKekProvider, createPrismaDekStore, notifyDataKeyV1, parseKey32 } from '@generazioneai/crypto';
const kek = process.env.KMS_KEY_ID
? new InfisicalKmsKekProvider({
siteUrl: process.env.INFISICAL_SITE_URL!, // nel cluster: http://infisical-infisical-standalone-infisical.infisical.svc.cluster.local:8080
keyId: process.env.KMS_KEY_ID,
clientId: process.env.KMS_CLIENT_ID!,
clientSecret: process.env.KMS_CLIENT_SECRET!,
})
: new LocalKekProvider({ keys: parseKey32('DEV_KEK', process.env.DEV_KEK)! }); // sviluppo e test; rifiutato in produzione
const box = new EnvelopeCrypto({
service: 'notify',
kek,
store: createPrismaDekStore(prisma.encryptionKey), // dentro runUnscoped('crypto-dek') con authz/RLS attive
legacy: [notifyDataKeyV1(parseKey32('NOTIFY_DATA_KEY', process.env.NOTIFY_DATA_KEY)!)], // KEK di transizione
});
const ctx = { table: 'messages', column: 'payloadEnc', rowId: message.id, juridicalId: message.juridicalId };
const enc = await box.encrypt(ctx, JSON.stringify(payload)); // sempre gx2
const json = await box.decryptString(ctx, stored, { aad: message.id }); // gx2, o il blob v1 di oggi con la sua AAD
if (needsRewrite(stored)) { /* il job di migrazione lo riscriverà */ }KEK e KMS
KekProvider è l'interfaccia (wrap/unwrap della DEK, kekId, versione):
InfisicalKmsKekProvider: KMS dell'Infisical self-hosted (login della machine identity con Universal Auth, token in cache;POST /api/v1/kms/keys/<keyId>/encrypt|decrypt). Il KMS non accetta AAD: l'involucro porta l'impronta della riga del registro e la controlla alla decifratura.- Login della machine identity (0.4.0):
auth: 'universal'(default,clientId+clientSecret) oauth: 'kubernetes'(identityId+ il token proiettato del service account del pod, riletto a ogni login daserviceAccountTokenPath, default/var/run/secrets/infisical-kms/token; Infisical lo verifica con la TokenReview dell'API server). Conkubernetesnel Secret del servizio non c'è nessuna credenziale del KMS. LocalKekProvider: chiave da env, per sviluppo e test (o KEK di transizione conallowInProduction).
Più KEK e rewrap (0.2.0)
Promessa: si cambia provider di KMS, o si torna a una chiave locale, senza toccare un valore cifrato.
KekRing(corrente, [altre]): cifra le DEK nuove con la corrente, apre ogni DEK con la KEK scritta nella sua riga (kekId). È ilkekda passare aEnvelopeCryptodurante un cambio.rewrapDeks({ service, store, from, to, apply, force }): ricifra tutte le DEK del registro sottoto. A secco senzaapply; si può rilanciare (quelle già sottotoalla sua versione si saltano); rilegge il nuovo involucro prima di scrivere; scrittura condizionata (replaceWrapped: una riga cambiata nel frattempo si lascia e si conta inchanged).forcericifra anche quelle già sottoto: dopo una rotazione della chiave dentro Infisical. Stessa semantica disecretbox.Rewrap(Go). Le DEK non cambiano, quindigx2.<dekId>.…resta valido.kekRingFromEnv({ prefix: 'NOTIFY', production }): le KEK di un servizio dalle variabili, uguali dappertutto:INFISICAL_SITE_URL,KMS_KEY_ID,KMS_CLIENT_ID,KMS_CLIENT_SECRET(KMS_ORGANIZATION_SLUGfacoltativa) per il KMS; conKMS_AUTH=kubernetes(0.4.0) al posto di client id e secretKMS_IDENTITY_IDe, facoltativa,KMS_K8S_TOKEN_PATH;<P>_KEK_LOCAL_KEYS(base64 di 32 byte, versioni separate da virgole: la posizione è la versione),<P>_KEK_LOCAL_NAME,<P>_KEK_LOCAL_IN_PRODUCTION;<P>_KEK=infisical|localsceglie quale cifra (di serie il KMS, se c'è).createPrismaDekStoreimplementa anchelistAllereplaceWrapped(servonofindManyeupdateManydel delegate).KekProvider.kekVersion(facoltativo): la versione con cuiwrapcifra;LocalKekProviderla più alta, Infisical 1.
Esempio, dal KMS di nuovo a una chiave locale: NOTIFY_KEK=local con NOTIFY_KEK_LOCAL_KEYS e il KMS ancora configurato,
poi rewrapDeks (a secco, poi apply), poi via le variabili KMS_*.
Rotazione di una azienda (0.3.0)
Sospetto di compromissione della DEK di un'azienda (decisione 28): box.rotateTenant(juridicalId, { registry, db, batchSize })
ritira la DEK attiva, ne crea una nuova e ricifra a lotti i valori di quell'azienda. I valori restano gli stessi in chiaro;
encrypt, decrypt e rewrapDeks non cambiano.
import { defineEncryptedFields, createSqlFieldsDb } from '@generazioneai/crypto';
// una volta, accanto ai segreti del servizio
export const ENCRYPTED_FIELDS = defineEncryptedFields('notify', [
{ table: 'profile_channels', column: 'secretsEnc', idColumn: 'id', tenantColumn: 'juridicalId' },
]);
// nel comando di rotazione (gateway: Pannello «Chiavi», con conferma PRODUZIONE; i dati della piattaforma non ruotano qui)
const report = await runAsTenantSystem(juridicalId, 'crypto-rotate-tenant', () =>
box.rotateTenant(juridicalId, { registry: ENCRYPTED_FIELDS, db: createSqlFieldsDb(prisma), batchSize: 200 }),
);idColumnè ilrowIddell'AAD etenantColumniljuridicalId: devono essere quelli con cui il servizio cifra.createSqlFieldsDb(client, { schema? })usa$queryRawUnsafe/$executeRawUnsafecon identificatori fra virgolette e valori come parametri; si può passare un proprioEncryptedFieldsDb(readBatch,replaceValue). Con l'authz o la RLS attive il giro va inrunAsTenantSystem(authz 0.7): vede solo quell'azienda.- Un valore si ricifra se non sta sotto la DEK attiva; la scrittura è condizionata (
replaceValuevuole ancora il valore letto): una riga cambiata da altri si conta inchangede si lascia. La DEK vecchia resta conretiredAte continua ad aprire i valori finchéremainingnon è 0; poi si può cancellare dal registro. dryRun: truenon scrive niente e non ritira (dice quante righe sono sotto una DEK diversa dall'attiva).resume: truenon ritira la DEK attiva e ricifra solo i ritardatari: serve dopo un giro interrotto e, passati 15 minuti, per i valori scritti da repliche con la cache della DEK vecchia.- Il registro delle DEK deve saper ritirare (
retireActive):InMemoryDekStoreecreatePrismaDekStore(serveupdateMany). - I valori non gx2 (formati vecchi) si saltano (
skippedLegacy): li riscrive il job di migrazione del servizio. - Il rapporto (
RotateTenantReport) non contiene mai valori né chiavi;failedha una riga per riga con tabella, colonna, id e motivo.
KEK di emergenza fuori linea (0.5.0)
Ogni DEK (e ogni chiave HMAC del registro, purpose = 'hmac:*') ha anche un secondo involucro, sigillato con una chiave
pubblica X25519 la cui privata non sta né nel cluster né in Infisical: se Infisical si perde del tutto, il dump di
encryption_keys e la privata restituiscono tutte le DEK.
const { ring } = kekRingFromEnv({ prefix: 'NOTIFY', production: true, allowedEmergencyKekIds: ['emergency:…'] });
// NOTIFY_KEK_EMERGENCY=on + KEK_EMERGENCY_PUBLIC_KEY=gxepub1:… → ogni DEK nuova ha emergencyWrapped/emergencyKekId
await escrowDeks({ service: 'notify', store, kek: ring!, emergency: ring!.emergency!, apply: true }); // le DEK di prima- Colonne del registro (facoltative, migrazione additiva prima di
on):emergencyWrapped Bytes?,emergencyKekId String?. EmergencyKeksigilla e basta: nessun servizio può aprire un involucro di emergenza.EmergencyRecoveryKey(la privata) si usa solo fuori linea ed è rifiutata dentro un pod.- Recupero:
gx2-emergenza controlla|apri|riavvolgi|pubblica --chiave <file> --dump <csv> --servizio <svc>; prova completa su un Postgres usa e getta conbash scripts/prova-recupero-pg.sh. - Formato
gxe1, file della privatagxe-priv-1, procedura e prova annuale: vault, «Sicurezza/Runbook - KEK di emergenza».
Segnali d'uso del KMS per Sentinel (0.6.0)
0.9 (decisione del 2026-11-10, via gli interruttori): accesi se la configurazione c'è (KMS_SIGNALS_SERVICE o DENY_SIGNALS_SERVICE
valido e un NATS), altrimenti spenti con una riga nel log; KMS_SIGNALS=off li spegne solo in sviluppo locale e nei test. Accesi, ogni EnvelopeCrypto conta, per DEK, le decifrature riuscite (decrypts), quelle col tag
sbagliato (decryptErrors), i valori di un altro ambito (wrongScope), le aperture di DEK col KMS (unwraps, unwrapErrors) e le
DEK nuove (created), e una volta per finestra (60 s) pubblica un messaggio su sec.kms.<servizio>:
{ v:1, id, service, subject, from, to, windowMs, totals, droppedEntries, entries:[{ dek, scope, tenant?, purpose, …contatori }] }.
Solo id di DEK e di aziende (UUID): niente valori, testi, righe o persone. La registrazione è una somma in una mappa (spenta: un
confronto); il publish è core, su una connessione NATS sua (@nats-io/transport-node o nats, se installati), e con NATS giù si
butta e si conta. Tetti: KMS_SIGNALS_MAX_KEYS (2000 DEK in mappa), KMS_SIGNALS_MAX_ENTRIES (500 voci per messaggio, le più attive).
| Variabile | Di serie |
|---|---|
| KMS_SIGNALS | acceso se configurato (off solo in sviluppo locale) |
| KMS_SIGNALS_SERVICE | DENY_SIGNALS_SERVICE: l'utente NATS, che deve poter pubblicare su sec.kms.<servizio> |
| KMS_SIGNALS_WINDOW_MS, KMS_SIGNALS_MAX_KEYS, KMS_SIGNALS_MAX_ENTRIES | 60000, 2000, 500 |
| KMS_SIGNALS_NATS_URL | NATS_URL (con NATS_NKEY_SEED) |
Firma (0.8.0; 0.9: sempre, EVENTS_SIGNATURE=off solo in sviluppo locale): ogni messaggio porta Gen-Sig (token di @generazioneai/authz
≥ 0.7.3, chiave della firma interna del servizio, vedi «Gen-Sig dei segnali» nel README di authz); la verifica la fa Sentinel.
new EnvelopeCrypto({ …, usage }) accetta un KmsUsageSink proprio; usage: null non registra mai. Sentinel ne ricava la regola
«decifrature anomale» rispetto alla norma del servizio.
Metriche OpenTelemetry del KMS (0.8.0)
Sempre accese ma senza costo se manca l'API: @opentelemetry/api è un peer facoltativo (nessuna dipendenza nuova). Con l'API
installata e un SDK registrato (@generazioneai/telemetry) ogni EnvelopeCrypto scrive:
| Metrica | Attributi |
|---|---|
| kms_operations_total | op (wrap = DEK nuova, unwrap = DEK aperta), outcome (ok, unavailable = KMS non raggiungibile, error), purpose |
| kms_operation_duration_seconds | op, outcome (istogramma, comprese le riprove del provider) |
| kms_decrypt_total | outcome (ok, error = tag che non torna, wrong_scope = DEK di un'altra azienda o scopo), purpose |
| kms_stale_served_total | purpose (DEK scaduta usata col KMS giù, staleIfKmsDownMs) |
Il servizio lo dice la risorsa (service_name). Mai id di DEK o aziende: quelli vanno solo nei segnali per Sentinel qui sopra.
setKmsMeter(meter) per i test (null = nessuna metrica). Regola kms-errori di vmalert nel Runbook - Osservabilità.
Indice cieco dei dati personali (0.7.0)
La ricerca per uguaglianza su un dato personale cifrato (codice fiscale, username, numero di documento) si fa su un indice cieco:
import { blindIndex, deriveIndexKey, fieldIndexInfo, PII_INDEX_PURPOSE } from '@generazioneai/crypto';
// dataKey: 32 byte del registro delle DEK (purpose = PII_INDEX_PURPOSE), aperti col KMS una volta per processo.
const k = deriveIndexKey(dataKey, { salt: 'genai-hr', info: fieldIndexInfo('Candidate', 'fiscalCode') });
blindIndex(' rss mra 80a01 h501u ', k); // 'b1.' + HMAC-SHA256 di 'RSSMRA80A01H501U' (base64url)normIdentifier (NFKC, senza spazi anche Unicode, maiuscolo; gli omocodici non si riconducono), sha256Index (s1., impronta senza
chiave solo per segreti casuali, es. token di 256 bit), isPersonFiscalCode. Sale e info sono quelli del servizio: ID genai-id /
pii-identifier (una sottochiave per username e CF), HR genai-hr / pii-index:<Modello>.<campo> (una per campo). Vettori in
vettori/blind-index.json, presi dalle implementazioni dei servizi: chi passa alla libreria ritrova gli indici già scritti.
DEK, cache e guasti
- Una DEK per servizio, azienda e scopo (
secrets), più unaplatform; creata alla prima cifratura, una sola anche con molte richieste insieme o due repliche (conflitto → si rilegge la riga attiva). Il registro vuole l'indice unico parziale scritto insrc/dek-store.ts. - DEK in chiaro in memoria 15 minuti, al più 1.000; mai nei log. KMS giù: le DEK in cache funzionano (e una scaduta da
meno di
staleIfKmsDownMs, 4 ore, si usa con un avviso); una DEK nuova fallisce conDekUnavailableError. La prontezza dei pod non deve dipendere dal KMS.stats()per le metriche.
Formati vecchi letti
| formato | servizio, codice di oggi | chiave | AAD da passare in hint |
|---|---|---|---|
| id-fe1 (fe1.idk1.…) | ID shared/crypto/field-crypto.ts (TOTP, IBAN) | ID_DATA_KEY (HKDF field:<scopo>) | purpose + aad = <scopo>\|<contesto> |
| id-sso-v1 (v1.…) | ID shared/sso/sso-crypto.ts | ID_DATA_KEY (HKDF sso-secret) | <configId>\|<campo> |
| notify-v1 (blob [1][iv][tag][…], byte o base64) | Notify utilities/crypto/aes-gcm.ts | NOTIFY_DATA_KEY | quella di oggi (id del messaggio, notify-profile-secret:v1\|…) |
| sign-v1 | Sign utilities/crypto/aes-gcm.ts | SIGN_DATA_KEY | account e provider |
| ops-v1 (v1.…) | Ops integrations/core/secrets.ts | OPS_SECRETS_KEY | nessuna |
| train-xapi-v1 (v1:…) | Train xapi/utils/crypto.util.ts | XAPI_SECRETS_KEY | nessuna |
Non ancora: le segnalazioni di HR (WB_ENCRYPTION_KEY, HKDF per azienda, testi e file WB1) e Sentinel (Go), nelle tappe K3 e K4.
