// secrete.mjs - Aere PQ Secrets: un serviciu de secrete (nume, versiuni, metadate) peste Aere PQ KMS, in care FIECARE acces e autorizat // de o identitate AERE PREZENTATA (zero trust fata de retea si fata de apelant: nicio incredere in adresa, in token sau in gazda // apelantului, numai in dovada prezentata) si inregistrat ca plic AIP-23 in jurnalul sidecar-ului (AERE Verification Layer). // Roadmap pista B, punctul B8 (linia Shield, partea DA a hotararii 4 a fondatorului), 2026-10-18. Numai Node 24, fara dependinte. // // CE FACE: // - fiecare versiune a unui secret e cifrata cu o cheie de date proprie (AES-256-GCM), iar cheia de date e invelita de KMS in plicul // lui hibrid (X25519 + ML-KEM-768); pe disc stau numai forma sigilata si plicul KMS; fisierul secretului poarta un HMAC sub o cheie // a serviciului, ea insasi invelita de KMS (un grant adaugat pe disc cu mana e refuzat); // - fiecare cerere poarta o PREZENTARE a unei credentiale AERE (tools/aere-identity), facuta pentru ACEST serviciu (audienta = numele // serviciului), cu un NONCE emis de serviciu si consumat la prima vedere, intr-un termen (prospetimea verificatorului de identitate), // semnata de cheia detinatorului sau a unui delegat al lui, si legata de CEREREA insasi (`request`, digestul operatiei, al secretului // si al corpului, requestHash mai jos): o prezentare facuta pentru "get db" nu deschide "revoke" pe db, nici "get" pe alt secret; // verificarea e cea de productie, verifyPresentation, iar politica fiecarui grant e judecata de checkCompliance; // - un grant spune ce operatii permite (put, get, metadata, rotate, revoke-version, grant, revoke), daca si versiunile VECHI se pot // citi (`versions: 'any'`), caror detinatori (`holders`) si daca si delegatilor lor (`delegates`); politica de ADMIN permite tot; un // grant facut de un ne-admin e o ATENUARE a grantului prin care a intrat (regula grant-scope); // - fiecare refuz isi numeste regula (REGULI, cu /*PAZA:*/ pe conditia buna); un refuz de AUTORIZARE e acelasi raspuns, octet cu // octet, pentru un secret care nu exista, unul fara grant potrivit si unul cu grant si regula picata; detaliul sta numai in jurnal; // - fiecare operatie, reusita sau refuzata, e o intrare `secret-access` (tools/proof-kinds) in jurnalul sidecar-ului; pentru o operatie // care MUTA, randul de audit se scrie si se fsync-uieste INAINTE de schimbare, iar o schimbare care nu se poate pune pe disc dupa el // primeste al doilea rand, `failed`: la o cadere ramane o intrare fara schimbare, niciodata o schimbare fara intrare. // // CE NU FACE (spus si in README): nu apara impotriva gazdei pe care ruleaza (procesul tine in memorie cheile de date cat le foloseste, // si, cu KMS-ul in proces, chiar cheia radacina a KMS-ului); fara HSM radacina KMS-ului e un fisier sigilat de un secret din mediu; nu // detecteaza o intoarcere a dosarului de date la o stare veche valid-MAC-ata; limiteaza numai nonce-urile in zbor pe adresa; un singur // proces pe un dosar de date (lacatul service.lock o cere); nu atesta runtime-ul serviciului insusi (asta e Proof of runtime). // // Mesajele de eroare sunt texte fixe plus nume de secrete, numere de versiune si id-uri de grant: niciodata o valoare, un nonce, material // de cheie, sau o intrare a cererii pe care nu a verificat-o nimeni. import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import http from 'node:http'; import https from 'node:https'; import { fileURLToPath } from 'node:url'; import { KmsError, canonicalJson } from '../pq-kms/kms.mjs'; // Componentele surori, in forma publicata (alaturi: ../identity, ../proof-kinds, ../verify-layer) sau in depozitul de dezvoltare // (tools/aere-identity, tools/proof-kinds, tools/aere-verify-layer). Un rand cu candidati, cum face control-plane/raport-conformitate.mjs. const U_ID = [new URL('../identity/identity.mjs', import.meta.url), new URL('../../tools/aere-identity/identity.mjs', import.meta.url)].find((u) => fs.existsSync(fileURLToPath(u))); const U_CF = [new URL('../identity/conformitate.mjs', import.meta.url), new URL('../../tools/aere-identity/conformitate.mjs', import.meta.url)].find((u) => fs.existsSync(fileURLToPath(u))); const U_PK = [new URL('../proof-kinds/proof-kinds.mjs', import.meta.url), new URL('../../tools/proof-kinds/proof-kinds.mjs', import.meta.url)].find((u) => fs.existsSync(fileURLToPath(u))); const U_SC = [new URL('../verify-layer/sidecar.mjs', import.meta.url), new URL('../../tools/aere-verify-layer/sidecar.mjs', import.meta.url)].find((u) => fs.existsSync(fileURLToPath(u))); for (const [u, ce] of [[U_ID, 'identity/identity.mjs'], [U_CF, 'identity/conformitate.mjs'], [U_PK, 'proof-kinds/proof-kinds.mjs'], [U_SC, 'verify-layer/sidecar.mjs']]) { if (!u) throw new Error(`pq-secrets: ${ce} was not found beside this component (as published) nor in the development tree`); } const ID = await import(U_ID.href); const CF = await import(U_CF.href); const PK = await import(U_PK.href); const SC = await import(U_SC.href); /** modulele surori rezolvate aici, ca probele sa nu le rezolve a doua oara (doua rezolvari diverg); `cai` = fisierele lor */ export const MODULE = Object.freeze({ identity: ID, conformitate: CF, proofKinds: PK, sidecar: SC, cai: Object.freeze({ identity: fileURLToPath(U_ID), conformitate: fileURLToPath(U_CF), proofKinds: fileURLToPath(U_PK), sidecar: fileURLToPath(U_SC) }) }); export const VERSION = 1; export const KIND = 'secret-access'; export const FORMAT_OBIECT = 'aeresecrets-secret/1'; // formatul fisierului unui secret export const FORMAT_SERVICE = 'aeresecrets-service/1'; /** operatiile pe care le poate numi un grant (list nu e pe secret: vede numele secretelor cu un grant al detinatorului) */ export const OPERATII_GRANT = Object.freeze(['put', 'get', 'metadata', 'rotate', 'revoke-version', 'grant', 'revoke']); export const OPERATII = Object.freeze([...OPERATII_GRANT, 'list']); /** operatiile pe care numai adminul le poate da mai departe (o re-delegare a administrarii) */ const OPERATII_ADMIN = Object.freeze(['grant', 'revoke']); /** regulile pe care le poate numi un refuz al serviciului; orice alta e o greseala a codului, nu un refuz */ export const REGULI = Object.freeze(['request-form', 'secret-name', 'presentation-form', 'nonce', 'authorization', 'secret-exists', 'value-form', 'version-exists', 'version-revoked', 'version-scope', 'grant-form', 'grant-exists', 'grant-scope', 'grant-limit', 'kms', 'storage', 'internal']); export const MAX_VALUE = 65536; // octeti (Azure Key Vault: 25 KB; AWS Secrets Manager: 64 KB) export const MAX_METADATA = 4096; // octeti de JSON canonic export const MAX_GRANTS = 32; // grant-uri pe un secret: fiecare costa o judecata de politica pentru orice apelant export const MAX_JSON_DEPTH = 32; // adancimea corpului unei cereri, prezentarea inclusa (una reala are 8) export const MAX_JSON_NODES = 20000; // valorile corpului unei cereri (o prezentare reala are ~150, 32 de grant-uri ~1000) export const MAX_AUDIT_ROW = 16384; // octetii unui plic de audit; campurile lui sunt marginite, plafonul e paznicul de la capat export const NONCE_TTL_S = 120; export const NONCE_MAX = 10000; export const NONCE_PER_ORIGIN = 32; // nonce-uri in zbor pe o adresa de origine (serverul HTTP o da); peste: NONCE_ORIGIN_FULL (429) const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; // numele de dispozitiv ale Windows: un fisier con.json, nul.json... nu e un fisier obisnuit pe toate versiunile de Windows const NUME_REZERVATE = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/; const GRANT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/; const META_KEY_RE = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/; const CHEI_REZERVATE = new Set(['__proto__', 'constructor', 'prototype']); const AERE_ID_RE = /^aere-id:[0-9a-f]{40}$/; const L = Object.freeze({ dek: 'aeresecrets/v1/dek', value: 'aeresecrets/v1/value', mac: 'aeresecrets/v1/mac-key', request: 'aere-secrets-request' }); /** raspunsul unui refuz de autorizare cu identitate valida: acelasi text pentru orice secret, existent sau nu, cu grant-uri sau fara */ export const MESAJ_NEAUTORIZAT = 'no grant admits this presentation for this operation on this secret (a secret that does not exist is refused the same way)'; export class SecretsError extends Error { constructor(code, message, extra) { super(message); this.name = 'SecretsError'; this.code = code; if (extra) Object.assign(this, extra); } } const refuz = (rule, code, message, extra = {}) => { if (!REGULI.includes(rule)) throw new Error('pq-secrets: undeclared rule ' + rule); return new SecretsError(code, message, { rule, ...extra }); }; // --------------------------------------------------------------------------------------------- primitive const utf8 = (s) => Buffer.from(s, 'utf8'); const sha256hex = (b) => '0x' + crypto.createHash('sha256').update(b).digest('hex'); const hmac = (key, b) => crypto.createHmac('sha256', key).update(b).digest(); const ctEq = (a, b) => a.length === b.length && crypto.timingSafeEqual(a, b); const iso = (d) => new Date(d).toISOString(); /** o versiune ceruta, numai daca e un intreg: un mesaj nu reflecta o intrare pe care nu a verificat-o nimeni */ const versiuneAfisata = (v) => (Number.isSafeInteger(v) ? String(v) : '(not an integer)'); /** un id pretins de o prezentare neverificata: numai in forma aere-id (marime fixa), altfel nimic */ const idPretins = (x) => (typeof x === 'string' && AERE_ID_RE.test(x) ? x : null); function writeFileAtomic(p, text) { const tmp = `${p}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`; const fd = fs.openSync(tmp, 'w', 0o600); try { fs.writeSync(fd, text); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } try { fs.renameSync(tmp, p); } catch (e) { try { fs.rmSync(tmp, { force: true }); } catch { /* temporarul ramane; nu poarta nicio valoare in clar */ } throw e; } } function checkName(name) { if (typeof name !== 'string' || !NAME_RE.test(name)) throw refuz('secret-name', 'INVALID_SECRET_NAME', 'secret name must match ^[a-z0-9][a-z0-9_-]{0,63}$'); if (NUME_REZERVATE.test(name)) throw refuz('secret-name', 'INVALID_SECRET_NAME', 'secret name is a device name reserved by Windows (con, prn, aux, nul, com0-9, lpt0-9)'); return name; } function checkAudience(a) { if (typeof a !== 'string' || a.length === 0 || a.length > 256 || !/^[\x21-\x7e]+$/.test(a)) { throw new SecretsError('AUDIENCE_INVALID', 'the audience must be 1..256 printable ASCII characters without spaces (the name under which presentations are made for this service)'); } return a; } /** * Marimea unui corp de cerere, fara recursie (o lista imbricata de 30.000 de ori nu arunca stiva aici): null daca e in limite, altfel * ce limita a depasit. Judecata INAINTEA oricarei canonizari; octetii unei valori (Buffer) sunt o singura valoare. */ function preaComplex(v, maxAdancime = MAX_JSON_DEPTH, maxNoduri = MAX_JSON_NODES) { const stiva = [[v, 1]]; let noduri = 1; while (stiva.length) { const [x, d] = stiva.pop(); if (d > maxAdancime) return 'depth'; if (x === null || typeof x !== 'object' || Buffer.isBuffer(x) || x instanceof Uint8Array) continue; for (const k of Object.keys(x)) { if (++noduri > maxNoduri) return 'nodes'; stiva.push([x[k], d + 1]); } } return null; } /** metadatele unei versiuni: un obiect mic de perechi nume -> sir/numar/boolean; refuzat altfel */ function checkMetadata(m) { if (m === undefined || m === null) return {}; if (!m || typeof m !== 'object' || Array.isArray(m)) throw refuz('value-form', 'INVALID_METADATA', 'metadata must be a JSON object of name -> string | number | boolean'); const chei = Object.keys(m); if (chei.length > 16) throw refuz('value-form', 'INVALID_METADATA', 'metadata has at most 16 entries'); const out = {}; for (const k of chei.sort()) { const v = m[k]; if (!META_KEY_RE.test(k)) throw refuz('value-form', 'INVALID_METADATA', 'a metadata name must match ^[A-Za-z_][A-Za-z0-9_.-]{0,63}$'); // __proto__ ar disparea in tacere la out[k] = v (ar seta prototipul), constructor si prototype sunt numele lui: refuzate pe nume if (CHEI_REZERVATE.has(k)) throw refuz('value-form', 'INVALID_METADATA', `metadata name "${k}" is reserved (__proto__, constructor, prototype)`); if (!((typeof v === 'string' && v.length <= 1024) || (typeof v === 'number' && Number.isFinite(v)) || typeof v === 'boolean')) { throw refuz('value-form', 'INVALID_METADATA', `metadata "${k}" must be a string of at most 1024 characters, a finite number or a boolean`); } out[k] = v; } if (Buffer.byteLength(canonicalJson(out)) > MAX_METADATA) throw refuz('value-form', 'INVALID_METADATA', `metadata is larger than ${MAX_METADATA} bytes`); return out; } function toValue(v) { let b; if (Buffer.isBuffer(v) || v instanceof Uint8Array) b = Buffer.from(v); else if (typeof v === 'string') b = utf8(v); else throw refuz('value-form', 'INVALID_VALUE', 'value must be a Buffer, Uint8Array or string'); if (b.length === 0) throw refuz('value-form', 'INVALID_VALUE', 'value must not be empty'); if (b.length > MAX_VALUE) throw refuz('value-form', 'VALUE_TOO_LARGE', `value is larger than ${MAX_VALUE} bytes`); return b; } /** * Digestul unei CERERI catre acest serviciu, intr-un singur loc: clientul il pune in prezentare (present({ request })) INAINTE de * semnare, serviciul il calculeaza din cererea primita si cere ca prezentarea sa poarte exact acel digest (regula `request` a * verificatorului de identitate). Modelul: `transaction_data` din OpenID4VP 1.0 si `transaction_data_hashes` din Key Binding JWT. * * sha256( JSON canonic al { v: 1, kind: 'aere-secrets-request', service, operation, secret, body } ) * * `service` = audienta serviciului; `operation` = una din OPERATII; `secret` = numele (null la list); `body` = TOATE campurile corpului * cererii in afara de `presentation` si de cele `undefined` (version, value, metadata, grants, grant, grantId, bytes, encoding, si orice * alt camp: unul adaugat de cine nu a semnat schimba digestul). `value` (la put) intra ca base64-ul octetilor: din biblioteca, un Buffer * sau un sir (octetii lui UTF-8, `valueEncoding: 'bytes'`, implicit); din HTTP, sirul base64 asa cum pleaca (`valueEncoding: 'base64'`). * JSON-ul canonic e al identitatii (chei sortate, fara spatii; arunca pe o valoare pe care JSON nu o poate spune). */ export function requestHash({ audience, operation, secret = null, body = {}, valueEncoding = 'bytes' } = {}) { if (typeof audience !== 'string' || !audience) throw new SecretsError('REQUEST_HASH_INPUT', 'requestHash needs the audience of the service'); if (!OPERATII.includes(operation)) throw new SecretsError('REQUEST_HASH_INPUT', 'requestHash needs an operation, one of: ' + OPERATII.join(', ')); if (secret !== null && typeof secret !== 'string') throw new SecretsError('REQUEST_HASH_INPUT', 'requestHash: secret is a name, or null for list'); if (!body || typeof body !== 'object' || Array.isArray(body)) throw new SecretsError('REQUEST_HASH_INPUT', 'requestHash: body is the object of the request'); if (valueEncoding !== 'bytes' && valueEncoding !== 'base64') throw new SecretsError('REQUEST_HASH_INPUT', 'requestHash: valueEncoding is bytes or base64'); const corp = Object.create(null); // fara prototip: o cheie __proto__ a cererii ramane o cheie, nu un prototip for (const k of Object.keys(body)) { if (k === 'presentation' || body[k] === undefined) continue; let v = body[k]; if (k === 'value' && operation === 'put' && valueEncoding === 'bytes') { if (Buffer.isBuffer(v) || v instanceof Uint8Array) v = Buffer.from(v).toString('base64'); else if (typeof v === 'string') v = utf8(v).toString('base64'); } corp[k] = v; } return sha256hex(utf8(ID.canonical({ v: 1, kind: L.request, service: audience, operation, secret, body: corp }))); } /** Un grant, in forma normala: { id, operations, versions, holders?, delegates?, policy } ; politica trece prin definePolicy. */ export function normalizeGrant(g) { if (!g || typeof g !== 'object' || Array.isArray(g)) throw refuz('grant-form', 'INVALID_GRANT', 'a grant is an object { id, operations, policy, versions?, holders?, delegates? }'); const extra = Object.keys(g).filter((k) => !['id', 'operations', 'policy', 'versions', 'holders', 'delegates'].includes(k)); if (extra.length) throw refuz('grant-form', 'INVALID_GRANT', 'unknown grant field: ' + extra.join(', ')); if (typeof g.id !== 'string' || !GRANT_ID_RE.test(g.id)) throw refuz('grant-form', 'INVALID_GRANT', 'grant id must match ^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$'); // `admin` numeste politica de admin in jurnal (grant: "admin"): un grant al unui secret nu poate purta acelasi nume if (!(g.id !== 'admin' /*PAZA:grant-form*/)) throw refuz('grant-form', 'INVALID_GRANT', 'grant id "admin" is reserved for the admin policy'); if (!Array.isArray(g.operations) || !g.operations.length || g.operations.some((o) => !OPERATII_GRANT.includes(o))) { throw refuz('grant-form', 'INVALID_GRANT', 'grant operations is a non-empty list from: ' + OPERATII_GRANT.join(', ')); } const versions = g.versions === undefined ? 'current' : g.versions; if (versions !== 'current' && versions !== 'any') throw refuz('grant-form', 'INVALID_GRANT', 'grant versions is "current" (default) or "any"'); let holders; if (g.holders !== undefined) { if (!Array.isArray(g.holders) || !g.holders.length || g.holders.some((h) => typeof h !== 'string' || !AERE_ID_RE.test(h))) { throw refuz('grant-form', 'INVALID_GRANT', 'grant holders is a non-empty list of aere-id:<40 hex> identities'); } holders = [...new Set(g.holders)].sort(); } if (g.delegates !== undefined && typeof g.delegates !== 'boolean') throw refuz('grant-form', 'INVALID_GRANT', 'grant delegates is true (default: a delegate of an admitted holder is admitted) or false (the holder alone)'); let policy; try { policy = CF.definePolicy(g.policy).policy; } catch (e) { throw refuz('grant-form', 'INVALID_GRANT', 'grant policy: ' + String(e && e.message || e)); } // Un grant nu poate scuti starea credentialului: o credentiala pe care emitentul nu o poate revoca nu deschide un secret. Politica de // conformitate accepta requireStatus: false pentru alte verificatoare; aici e refuzat PE NUME, la scriere (unicul loc, ca o plantare // sa aiba un singur paznic de scos). if (!(g.policy.requireStatus !== false /*PAZA:grant-form*/)) throw refuz('grant-form', 'INVALID_GRANT', 'a grant cannot waive the credential status (requireStatus: false): the status must be judged'); return { id: g.id, operations: [...new Set(g.operations)].sort(), versions, ...(holders ? { holders } : {}), ...(g.delegates === false ? { delegates: false } : {}), policy }; } /** * Un grant facut de un ne-admin trebuie sa fie o ATENUARE a grantului `o` prin care a intrat cererea `grant`: operatii incluse in ale * lui, fara grant si revoke (numai adminul da administrarea mai departe); versiuni incluse (current nu da any); emitentii crezuti * inclusi; fiecare regula a politicii lui prezenta, neschimbata (deci afirmatii cel putin la fel de stricte), varsta maxima cel mult a * lui; detinatorii inclusi in ai lui, cand el ii numeste; delegatii opriti, cand el ii opreste. O comparatie care nu se poate face * sigur (o regula scrisa altfel, chiar daca mai stricta) e un refuz: regula grant-scope. */ function atenuare(g, o) { const nu = (ce) => { throw refuz('grant-scope', 'GRANT_NOT_ATTENUATED', `a grant made by a holder who is not the admin must be narrower than grant "${o.id}" that admitted the request: ${ce}`); }; if (!(g.operations.every((x) => o.operations.includes(x) && !OPERATII_ADMIN.includes(x)) /*PAZA:grant-scope*/)) nu('its operations must be among those of that grant, without grant and revoke'); if (!(o.versions === 'any' || g.versions === 'current' /*PAZA:grant-scope*/)) nu('it may not read older versions (versions: any) when that grant reads only the current one'); if (!(g.policy.trustedIssuers.every((x) => o.policy.trustedIssuers.includes(x)) /*PAZA:grant-scope*/)) nu('it may trust only issuers that grant trusts'); const ale = new Set(g.policy.require.map((r) => ID.canonical(r))); if (!(o.policy.require.every((r) => ale.has(ID.canonical(r))) && g.policy.maxAgeS <= o.policy.maxAgeS && g.policy.requireStatus === true /*PAZA:grant-scope*/)) { nu('its policy must keep every rule of that grant unchanged, a maximum age no longer, and the status required'); } if (!(o.holders === undefined || (Array.isArray(g.holders) && g.holders.every((h) => o.holders.includes(h))) /*PAZA:grant-scope*/)) nu('it must name holders among those that grant names'); if (!(o.delegates !== false || g.delegates === false /*PAZA:grant-scope*/)) nu('it must refuse delegates (delegates: false) as that grant does'); } // --------------------------------------------------------------------------------------------- clientul HTTP al KMS-ului /** * Un client pentru serverul Aere PQ KMS (pq-kms/server.mjs), cu exact metodele pe care le foloseste serviciul de secrete, in formele * bibliotecii KMS: createKey, getKey, datakey (plaintext base64), decrypt (plaintext Buffer). Refuzurile KMS devin SecretsError cu codul * `KMS_` si regula `kms`. Tokenul nu se tipareste si nu apare in erori. */ export function kmsClientHttp({ url, token, timeoutMs = 10000 } = {}) { let base; try { base = new URL(url); } catch { throw new SecretsError('KMS_URL_INVALID', 'AERE_KMS_URL is not a URL'); } if (base.protocol !== 'http:' && base.protocol !== 'https:') throw new SecretsError('KMS_URL_INVALID', 'AERE_KMS_URL must be http(s)'); if (typeof token !== 'string' || token.length < 32) throw new SecretsError('KMS_TOKEN_INVALID', 'AERE_KMS_TOKEN must be at least 32 characters'); const mod = base.protocol === 'https:' ? https : http; const call = (method, p, body) => new Promise((resolve, reject) => { const data = body === undefined ? null : Buffer.from(JSON.stringify(body)); const headers = { authorization: `Bearer ${token}`, accept: 'application/json' }; if (data) { headers['content-type'] = 'application/json'; headers['content-length'] = data.length; } const req = mod.request(new URL(p, base), { method, headers, timeout: timeoutMs }, (res) => { const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { let json = null; try { json = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { json = null; } resolve({ status: res.statusCode, json }); }); }); req.on('timeout', () => req.destroy(new Error('timeout'))); req.on('error', (e) => reject(new SecretsError('KMS_UNREACHABLE', 'the KMS did not answer (' + (e && e.code || 'error') + ')', { rule: 'kms' }))); if (data) req.write(data); req.end(); }); const ok = (r) => { if (r.status === 200 && r.json) return r.json; const cod = r.json && typeof r.json.error === 'string' ? r.json.error : 'HTTP_' + r.status; throw new SecretsError('KMS_' + cod, 'the KMS refused: ' + cod, { rule: 'kms' }); }; const b64 = (aad) => (aad === undefined || aad === null ? undefined : Buffer.from(aad).toString('base64')); return { kind: 'http', async createKey(name, { type }) { return ok(await call('POST', `/v1/keys/${encodeURIComponent(name)}`, { type })); }, async getKey(name) { return ok(await call('GET', `/v1/keys/${encodeURIComponent(name)}`)); }, async datakey(name, { aad, bits = 256, includePlaintext = true } = {}) { return ok(await call('POST', `/v1/datakey/${encodeURIComponent(name)}`, { aad: b64(aad), bits, include_plaintext: includePlaintext })); }, async decrypt(name, ciphertext, aad) { const j = ok(await call('POST', `/v1/decrypt/${encodeURIComponent(name)}`, { ciphertext, aad: b64(aad) })); return { plaintext: Buffer.from(String(j.plaintext), 'base64'), version: j.version }; }, }; } // --------------------------------------------------------------------------------------------- serviciul export function openSecrets(opts) { return Secrets.open(opts); } /** traieste procesul cu acest PID pe gazda aceasta? (EPERM: exista, al altui utilizator) */ function traieste(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } } const V_IDENTITATE = Symbol('identitatea, verificata o data pe cerere'); export class Secrets { #dir; #secretsDir; #jurnal; #trust; #kms; #kmsKey; #audience; #admin; #macKey; #nonces = new Map(); #peOrigine = new Map(); #noncePerOrigin = NONCE_PER_ORIGIN; #acum; #nonceTtlMs; #maxAgeS; #closed = false; #lacate = new Map(); #lacatDosar = null; #lacatPreluat = null; /** * @param {object} o * @param {string} o.dataDir dosarul de date (service.json, secrets/.json, jurnalul implicit, lacatul service.lock) * @param {object} o.kms o instanta Kms (pq-kms/kms.mjs, in proces) sau kmsClientHttp(...) * @param {string} o.audience numele sub care se fac prezentarile pentru acest serviciu (ex. https://secrets.example) * @param {object} o.adminPolicy o politica de conformitate (id, trustedIssuers, require) care permite ORICE operatie * @param {string} [o.trustDir] dosarul cu status-lists/*.json si revocations/*.json (citite la fiecare cerere) * @param {string} [o.auditLog] jurnalul sidecar-ului (implicit /aere-audit.log) * @param {string} [o.kmsKey] numele cheii KMS de tip encrypt (implicit "secrets"; creata daca lipseste) * @param {function} [o.now] ceasul serviciului (pentru probe) * @param {string} [o.staleLock] 'refuse' (implicit) sau 'take': ce face cu un service.lock al unui proces care nu mai ruleaza * @param {number} [o.noncePerOrigin] nonce-uri in zbor pe o adresa de origine (implicit 32) */ static async open({ dataDir, kms, audience, adminPolicy, trustDir = null, auditLog = null, kmsKey = 'secrets', now = () => new Date(), nonceTtlS = NONCE_TTL_S, maxAgeS = 300, staleLock = 'refuse', noncePerOrigin = NONCE_PER_ORIGIN } = {}) { const s = new Secrets(); if (typeof dataDir !== 'string' || dataDir === '') throw new SecretsError('DATA_DIR_MISSING', 'dataDir is required'); if (!kms || typeof kms.datakey !== 'function' || typeof kms.decrypt !== 'function') throw new SecretsError('KMS_MISSING', 'a KMS (openKms(...) or kmsClientHttp(...)) is required'); s.#audience = checkAudience(audience); if (!NAME_RE.test(String(kmsKey))) throw new SecretsError('KMS_KEY_NAME_INVALID', 'kmsKey must match ^[a-z0-9][a-z0-9_-]{0,63}$'); if (typeof now !== 'function') throw new SecretsError('CLOCK_INVALID', 'now must be a function returning a Date'); if (!Number.isInteger(nonceTtlS) || nonceTtlS < 5 || nonceTtlS > 3600) throw new SecretsError('NONCE_TTL_INVALID', 'nonceTtlS is 5..3600 seconds'); if (!Number.isInteger(maxAgeS) || maxAgeS < 1 || maxAgeS > 3600) throw new SecretsError('MAX_AGE_INVALID', 'maxAgeS is 1..3600 seconds'); if (staleLock !== 'refuse' && staleLock !== 'take') throw new SecretsError('STALE_LOCK_INVALID', 'staleLock is "refuse" (default) or "take"'); if (!Number.isInteger(noncePerOrigin) || noncePerOrigin < 1 || noncePerOrigin > NONCE_MAX) throw new SecretsError('NONCE_PER_ORIGIN_INVALID', `noncePerOrigin is 1..${NONCE_MAX}`); let admin; try { admin = CF.definePolicy(adminPolicy).policy; } catch (e) { throw new SecretsError('ADMIN_POLICY_INVALID', 'adminPolicy: ' + String(e && e.message || e)); } if (adminPolicy.requireStatus === false) throw new SecretsError('ADMIN_POLICY_INVALID', 'adminPolicy cannot waive the credential status (requireStatus: false)'); s.#admin = admin; s.#kms = kms; s.#kmsKey = String(kmsKey); s.#acum = now; s.#nonceTtlMs = nonceTtlS * 1000; s.#maxAgeS = maxAgeS; s.#noncePerOrigin = noncePerOrigin; s.#dir = path.resolve(dataDir); s.#secretsDir = path.join(s.#dir, 'secrets'); s.#jurnal = auditLog ? path.resolve(auditLog) : path.join(s.#dir, 'aere-audit.log'); s.#trust = trustDir ? path.resolve(trustDir) : null; fs.mkdirSync(s.#secretsDir, { recursive: true, mode: 0o700 }); s.#iaLacatulDosarului(staleLock); try { await s.#deschideServiciu(); } catch (e) { s.#elibereazaLacatulDosarului(); throw e; } return s; } get audience() { return this.#audience; } get dataDir() { return this.#dir; } get auditLog() { return this.#jurnal; } /** daca deschiderea a preluat lacatul unui proces oprit (staleLock: 'take'): { pid, since }; altfel null */ get tookStaleLock() { return this.#lacatPreluat; } close() { if (this.#closed) return; this.#closed = true; if (this.#macKey) this.#macKey.fill(0); this.#nonces.clear(); this.#peOrigine.clear(); this.#elibereazaLacatulDosarului(); } // ------------------------------------------------------------------- lacatul dosarului: un singur serviciu pe un dosar de date // Doua instante pe acelasi dosar (doua procese, sau doua deschideri in acelasi proces) isi pierd una alteia actualizarile: fiecare // are mutexul ei. Lacatul e un fisier creat EXCLUSIV, cu PID-ul si gazda; o a doua deschidere refuza pe nume. Un lacat al unui proces // care nu mai ruleaza pe gazda asta se recunoaste dupa PID si se SPUNE (DATA_DIR_LOCK_STALE), nu se fura in tacere; se preia numai cu // staleLock: 'take', si atunci tookStaleLock il numeste. #iaLacatulDosarului(staleLock) { const p = path.join(this.#dir, 'service.lock'); for (let incercare = 0; incercare < 2; incercare++) { let fd; try { fd = fs.openSync(p, 'wx', 0o600); } catch (e) { if (e.code !== 'EEXIST') throw new SecretsError('DATA_DIR_LOCK_FAILED', 'the lock of the data directory (service.lock) could not be created (' + (e.code || 'error') + ')'); let rec = null; try { rec = JSON.parse(fs.readFileSync(p, 'utf8')); } catch { rec = null; } const pid = rec && Number.isSafeInteger(rec.pid) && rec.pid > 0 ? rec.pid : null; const gazda = rec && typeof rec.host === 'string' ? rec.host : null; if (!(pid !== null && gazda === os.hostname() && !traieste(pid))) { throw new SecretsError('DATA_DIR_LOCKED', `the data directory is held by process ${pid ?? '?'} on host ${gazda === os.hostname() ? 'this host' : 'another host or an unreadable lock'} (service.lock): two instances on one data directory lose each other's updates; refusing to open`); } if (staleLock !== 'take') throw new SecretsError('DATA_DIR_LOCK_STALE', `service.lock names process ${pid}, which is not running on this host: an instance stopped without closing. Check that no instance runs on this directory, then remove service.lock or open with staleLock: 'take' (AERE_SECRETS_STALE_LOCK=take)`); this.#lacatPreluat = { pid, since: typeof rec.since === 'string' ? rec.since : null }; fs.rmSync(p, { force: true }); continue; } try { fs.writeSync(fd, JSON.stringify({ pid: process.pid, host: os.hostname(), since: iso(Date.now()) }) + '\n'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } this.#lacatDosar = p; return; } throw new SecretsError('DATA_DIR_LOCKED', 'another instance took the lock of the data directory while this one was taking over a stale lock; refusing to open'); } #elibereazaLacatulDosarului() { const p = this.#lacatDosar; this.#lacatDosar = null; if (!p) return; try { const rec = JSON.parse(fs.readFileSync(p, 'utf8')); if (rec && rec.pid === process.pid && rec.host === os.hostname()) fs.rmSync(p, { force: true }); } catch { /* nu mai e al nostru, sau a disparut */ } } // ------------------------------------------------------------------- mutexul unui secret: incarca -> judeca -> muta -> audit -> salveaza // Cu un KMS la distanta intre citirea fisierului si scrierea lui sta un await real; fara mutex, doua put-uri citeau aceeasi stare si al // doilea il stergea pe primul (8 put-uri simultane intorceau toate versiunea 2). Mutexul tine tot ciclul unui secret, nu doar scrierea. async #cuLacat(cheie, fn) { const inainte = this.#lacate.get(cheie) || Promise.resolve(); let elibereaza; const randulMeu = new Promise((r) => { elibereaza = r; }); const coada = inainte.then(() => randulMeu); this.#lacate.set(cheie, coada); await inainte; try { return await fn(); } finally { elibereaza(); if (this.#lacate.get(cheie) === coada) this.#lacate.delete(cheie); } } // ------------------------------------------------------------------- fisierul de serviciu: cheia KMS si cheia de MAC a fisierelor async #kmsCall(f) { try { return await f(); } catch (e) { if (e instanceof SecretsError) throw e; if (e instanceof KmsError) throw new SecretsError('KMS_' + e.code, 'the KMS refused: ' + e.code, { rule: 'kms' }); throw e; } } async #deschideServiciu() { const p = path.join(this.#dir, 'service.json'); // cheia KMS de tip encrypt: creata daca lipseste; una existenta de alt tip e refuzata la prima folosire (WRONG_KEY_TYPE) let descr = null; try { descr = await this.#kmsCall(() => this.#kms.getKey(this.#kmsKey)); } catch (e) { if (e.code !== 'KMS_KEY_NOT_FOUND') throw e; } if (!descr) descr = await this.#kmsCall(() => this.#kms.createKey(this.#kmsKey, { type: 'encrypt' })); if (descr.type !== 'encrypt') throw new SecretsError('KMS_WRONG_KEY_TYPE', `KMS key "${this.#kmsKey}" is a ${descr.type} key; the secrets service needs an encrypt key`); if (fs.existsSync(p)) { let rec; try { rec = JSON.parse(fs.readFileSync(p, 'utf8')); } catch { throw new SecretsError('SERVICE_FILE_UNREADABLE', 'service.json is not valid JSON; refusing to start'); } if (!rec || rec.format !== FORMAT_SERVICE || typeof rec.mac_key_wrapped !== 'string') throw new SecretsError('SERVICE_FILE_UNREADABLE', 'service.json is not an aeresecrets-service/1 file; refusing to start'); if (rec.audience !== this.#audience) throw new SecretsError('AUDIENCE_MISMATCH', 'this data directory was created for another audience; refusing to start'); if (rec.kms_key !== this.#kmsKey) throw new SecretsError('KMS_KEY_MISMATCH', 'this data directory was created with another KMS key name; refusing to start'); const { plaintext } = await this.#kmsCall(() => this.#kms.decrypt(this.#kmsKey, rec.mac_key_wrapped, utf8(L.mac + '\0' + this.#audience))); if (plaintext.length !== 32) throw new SecretsError('SERVICE_FILE_UNREADABLE', 'the service MAC key has an unexpected size; refusing to start'); this.#macKey = plaintext; return; } const dk = await this.#kmsCall(() => this.#kms.datakey(this.#kmsKey, { aad: utf8(L.mac + '\0' + this.#audience), bits: 256, includePlaintext: true })); this.#macKey = Buffer.from(dk.plaintext, 'base64'); writeFileAtomic(p, JSON.stringify({ format: FORMAT_SERVICE, audience: this.#audience, kms_key: this.#kmsKey, kms_version: dk.version, created: iso(this.#acum()), mac_key_wrapped: dk.ciphertext }, null, 2) + '\n'); } // ------------------------------------------------------------------- fisierele secretelor #cale(name) { return path.join(this.#secretsDir, `${name}.json`); } #mac(obj) { return hmac(this.#macKey, utf8(canonicalJson(obj))).toString('hex'); } #incarca(name) { const p = this.#cale(name); if (!fs.existsSync(p)) return null; let rec; try { rec = JSON.parse(fs.readFileSync(p, 'utf8')); } catch { throw refuz('storage', 'SECRET_FILE_TAMPERED', `the file of secret "${name}" is not valid JSON`); } const { mac, ...rest } = rec || {}; if (!(typeof mac === 'string' && ctEq(utf8(mac), utf8(this.#mac(rest))))) throw refuz('storage', 'SECRET_FILE_TAMPERED', `the file of secret "${name}" failed its integrity check`); if (rest.name !== name || rest.format !== FORMAT_OBIECT) throw refuz('storage', 'SECRET_FILE_TAMPERED', `the file of secret "${name}" belongs to another secret or format`); return rest; } #salveaza(sec) { sec.updated = iso(this.#acum()); writeFileAtomic(this.#cale(sec.name), JSON.stringify({ ...sec, mac: this.#mac(sec) }, null, 2) + '\n'); } #nume() { return fs.readdirSync(this.#secretsDir).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -5)).filter((n) => NAME_RE.test(n) && !NUME_REZERVATE.test(n)).sort(); } // ------------------------------------------------------------------- cifrarea unei versiuni: cheie de date per versiune, invelita de KMS async #versiuneNoua(sec, valoare, metadata, holder) { const ver = sec.current_version + 1; const aadDek = utf8(`${L.dek}\0${sec.name}\0${ver}`); const dk = await this.#kmsCall(() => this.#kms.datakey(this.#kmsKey, { aad: aadDek, bits: 256, includePlaintext: true })); const dek = Buffer.from(String(dk.plaintext), 'base64'); if (dek.length !== 32) throw new SecretsError('KMS_DATAKEY_INVALID', 'the KMS returned a data key of an unexpected size', { rule: 'kms' }); const iv = crypto.randomBytes(12); const c = crypto.createCipheriv('aes-256-gcm', dek, iv); c.setAAD(utf8(`${L.value}\0${sec.name}\0${ver}`)); const ct = Buffer.concat([c.update(valoare), c.final()]); const sealed = Buffer.concat([iv, ct, c.getAuthTag()]).toString('base64'); dek.fill(0); valoare.fill(0); sec.versions[String(ver)] = { created: iso(this.#acum()), state: 'enabled', revoked_at: null, metadata, created_by: holder, kms_version: dk.version, dek_wrapped: dk.ciphertext, sealed }; sec.current_version = ver; return ver; } async #deschide(sec, ver) { const v = sec.versions[String(ver)]; const { plaintext: dek } = await this.#kmsCall(() => this.#kms.decrypt(this.#kmsKey, v.dek_wrapped, utf8(`${L.dek}\0${sec.name}\0${ver}`))); try { const b = Buffer.from(v.sealed, 'base64'); const d = crypto.createDecipheriv('aes-256-gcm', dek, b.subarray(0, 12)); d.setAAD(utf8(`${L.value}\0${sec.name}\0${ver}`)); d.setAuthTag(b.subarray(b.length - 16)); return Buffer.concat([d.update(b.subarray(12, b.length - 16)), d.final()]); } catch { throw refuz('storage', 'SECRET_FILE_TAMPERED', `version ${ver} of secret "${sec.name}" could not be opened (modified file)`); } finally { dek.fill(0); } } // ------------------------------------------------------------------- nonce: emis de serviciu, o singura folosire, cu termen #uitaNonce(n) { const rec = this.#nonces.get(n); this.#nonces.delete(n); if (rec && rec.origin !== null) { const set = this.#peOrigine.get(rec.origin); if (set) { set.delete(n); if (!set.size) this.#peOrigine.delete(rec.origin); } } return rec; } /** * Un nonce proaspat pentru o prezentare catre acest serviciu. `origin` (optional, serverul HTTP da adresa de la care vine cererea): * cel mult noncePerOrigin nonce-uri in zbor pe o origine (NONCE_ORIGIN_FULL), ca o singura adresa sa nu umple magazia pentru toti. */ nonce({ origin = null } = {}) { if (this.#closed) throw new SecretsError('SERVICE_CLOSED', 'this service instance was closed'); if (origin !== null && (typeof origin !== 'string' || origin.length > 128)) throw new SecretsError('NONCE_ORIGIN_INVALID', 'origin is a string of at most 128 characters (the address of the caller)'); const acum = this.#acum().getTime(); if (this.#nonces.size >= NONCE_MAX / 2) for (const [n, rec] of this.#nonces) if (rec.exp <= acum) this.#uitaNonce(n); if (origin !== null) { const set = this.#peOrigine.get(origin); if (set && set.size >= this.#noncePerOrigin) for (const n of [...set]) { const rec = this.#nonces.get(n); if (!rec || rec.exp <= acum) this.#uitaNonce(n); } const inZbor = this.#peOrigine.has(origin) ? this.#peOrigine.get(origin).size : 0; if (!(inZbor < this.#noncePerOrigin /*paznicul nonce-urilor pe origine*/)) throw new SecretsError('NONCE_ORIGIN_FULL', `this address has ${inZbor} nonces outstanding, the most one address may have; present one or let it expire (${this.#nonceTtlMs / 1000} s)`); } if (this.#nonces.size >= NONCE_MAX) throw new SecretsError('NONCE_STORE_FULL', `too many outstanding nonces (${NONCE_MAX}); try again shortly`); const n = crypto.randomBytes(32).toString('base64url'); const exp = acum + this.#nonceTtlMs; this.#nonces.set(n, { exp, origin }); if (origin !== null) { if (!this.#peOrigine.has(origin)) this.#peOrigine.set(origin, new Set()); this.#peOrigine.get(origin).add(n); } return { nonce: n, audience: this.#audience, expiresAt: iso(exp) }; } // Intoarce null daca nonce-ul e al serviciului, neexpirat si nefolosit; altfel motivul. Nonce-ul e CONSUMAT la prima vedere, oricare // ar fi verdictul de dupa (o a doua cerere cu aceeasi prezentare e refuzata chiar daca prima a fost refuzata). #judecaNonce(n, acum) { const rec = this.#nonces.has(n) ? this.#nonces.get(n) : null; const exp = rec ? rec.exp : 0; this.#uitaNonce(n); if (!(exp > acum /*PAZA:nonce*/)) return exp === 0 ? 'not issued by this service, or already used' : 'expired'; return null; } // ------------------------------------------------------------------- dosarul de incredere: listele de stare si revocarile #incredere() { const out = { statusLists: [], revocations: [], ignored: 0 }; if (!this.#trust) return out; for (const [sub, cheie] of [['status-lists', 'statusLists'], ['revocations', 'revocations']]) { const d = path.join(this.#trust, sub); if (!fs.existsSync(d)) continue; for (const f of fs.readdirSync(d).filter((x) => x.endsWith('.json')).sort()) { try { out[cheie].push(JSON.parse(fs.readFileSync(path.join(d, f), 'utf8'))); } catch { out.ignored++; } } } return out; } // ------------------------------------------------------------------- autorizarea: identitatea (o data), apoi politica fiecarui grant /** contextul judecatii unei cereri: acelasi obiect pentru toate grant-urile (si toate secretele, la list), ca verificarea sa se faca o data */ #context(p, acum, rh) { const incredere = this.#incredere(); return { audience: this.#audience, nonce: p.binding.nonce, now: acum, statusLists: incredere.statusLists, revocations: incredere.revocations, request: rh }; } #judeca(op, secret, p, ctx, cache, { oricare = false } = {}) { // 1. prezentarea, pe drumul de productie al identitatii, cu digestul cererii: fiecare refuz isi numeste regula (audience, nonce, // request, freshness, presenter, credential-revoked, delegation-revoked, ...); emitentii se judeca pe grant, mai jos let v = cache.get(V_IDENTITATE); if (!v) { v = ID.verifyPresentation(p, { ...ctx, trustedIssuers: null, maxAgeS: this.#maxAgeS }); cache.set(V_IDENTITATE, v); } if (!v.valid) return { grant: null, identity: v, judged: [] }; // 2. grantul: politica de admin permite tot; grantul unui secret permite operatiile lui; politica fiecaruia (emitenti, stare, afirmatii) // e judecata de checkCompliance, cu verificarea hibrida facuta o data pentru fiecare pereche (emitenti, varsta maxima): `cache` const candidati = [{ id: 'admin', operations: null, versions: 'any', policy: this.#admin }, ...(secret ? secret.grants : [])]; const judged = []; for (const g of candidati) { if (!(oricare || g.operations === null || g.operations.includes(op) /*PAZA:authorization*/)) continue; let r; try { r = CF.checkCompliance(p, g.policy, { ...ctx, cache }); } catch (e) { judged.push({ grant: g.id, compliant: false, rules: ['policy-unreadable'] }); continue; } const strain = Array.isArray(g.holders) && !g.holders.includes(r.holder); const delegat = g.delegates === false && !!v.subject && v.subject.delegations > 0; const reguli = [...r.rules, ...(strain ? ['holder'] : []), ...(delegat ? ['delegates'] : [])]; judged.push({ grant: g.id, compliant: r.compliant && !strain && !delegat, rules: reguli }); if (!(r.compliant && !strain && !delegat /*PAZA:authorization*/)) continue; return { grant: g, result: r, identity: v, judged }; } return { grant: null, identity: v, judged }; } // Cine a prezentat, in randul de audit: un id se scrie ca `holder` / `presenter` numai daca semnaturile prezentarii au fost VERIFICATE // (id-urile derivate din chei, credentialul semnat, prezentarea semnata de cine trebuia, lantul de delegare semnat); altfel raman // cele pretinse, in claimedHolder / claimedPresenter, si numai in forma aere-id. #cineAPrezentat(a, v, p) { const SEMNATURI = ['issuer-id', 'holder-id', 'credential-signature', 'binding-credential', 'binding-delegations', 'presenter', 'presentation-signature', 'delegation-form', 'delegation-ids', 'delegation-from', 'delegation-parent', 'delegation-signature']; const randuri = (rule) => v.rows.filter((x) => x.rule === rule); const tin = SEMNATURI.every((rule) => randuri(rule).every((x) => x.pass === true)) && ['holder-id', 'credential-signature', 'presenter', 'presentation-signature'].every((rule) => randuri(rule).some((x) => x.pass === true)); if (!(tin /*identitatile verificate*/)) return; a.holder = idPretins(p.credential.statement.holder.id); a.presenter = idPretins(p.binding.presenter.id); a.claimedHolder = null; a.claimedPresenter = null; } #refuzaAutorizarea(a, j, refuza) { if (!j.identity.valid) { // regulile identitatii nu spun nimic despre secret (identitatea se judeca inaintea oricarui grant): raman in raspuns refuza(refuz('authorization', 'UNAUTHORIZED', 'the presentation is not valid for this service (identity rules: ' + j.identity.rules.join(', ') + ')', { identityRules: j.identity.rules }), j.identity.rules); } // identitate valida, niciun grant: ACELASI raspuns pentru orice secret; grant-urile judecate si regulile lor merg numai in jurnal a.judged = j.judged.map((x) => ({ grant: x.grant, rules: [...new Set(x.rules)].sort() })); refuza(refuz('authorization', 'UNAUTHORIZED', MESAJ_NEAUTORIZAT), [...new Set(j.judged.flatMap((x) => x.rules))].sort()); } // ------------------------------------------------------------------- jurnalul: un plic secret-access pe operatie #plic(a, acum) { let prezentare = 'no-presentation'; // o legatura care nu se poate canoniza (o valoare pe care JSON nu o poate spune, data din biblioteca) nu arunca afara din refuz if (a.binding) { try { prezentare = ID.canonical(a.binding); } catch { prezentare = 'unreadable-presentation'; } } return PK.buildProof(KIND, { service: this.#audience, secret: a.secret, version: a.version, operation: a.op, verdict: a.verdict, rules: a.rules, grant: a.grant, holder: a.holder, presenter: a.presenter, ...(a.claimedHolder ? { claimedHolder: a.claimedHolder } : {}), ...(a.claimedPresenter ? { claimedPresenter: a.claimedPresenter } : {}), ...(a.judged && a.judged.length ? { judged: a.judged } : {}), ...(a.requestHash ? { requestHash: a.requestHash } : {}), presentation: prezentare, ...(a.policyHash ? { policyHash: a.policyHash } : {}), createdAt: iso(acum), }); } #scrieAudit(a, acum) { let plic; try { plic = this.#plic(a, acum); } catch (e) { throw new SecretsError('AUDIT_WRITE_FAILED', 'the audit entry could not be built; the result of the operation is withheld', { rule: 'internal' }); } if (!(Buffer.byteLength(JSON.stringify(plic)) <= MAX_AUDIT_ROW)) throw new SecretsError('AUDIT_WRITE_FAILED', `the audit entry is larger than ${MAX_AUDIT_ROW} bytes; the result of the operation is withheld`, { rule: 'internal' }); try { const r = SC.adaugaPlicuri(this.#jurnal, [plic])[0]; // randul trebuie sa fie pe disc inainte de mutatia pe care o anunta (sidecar-ul adauga fara fsync) const fd = fs.openSync(this.#jurnal, 'r+'); try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } return r; } catch (e) { throw new SecretsError('AUDIT_WRITE_FAILED', 'the audit log could not be written; the result of the operation is withheld', { rule: 'storage' }); } } /** Capul jurnalului: { ok, count, head } (verifica tot lantul; liniar in marimea jurnalului). */ auditHead() { const intrari = fs.existsSync(this.#jurnal) ? fs.readFileSync(this.#jurnal, 'utf8').split('\n').filter((l) => l.trim()).map((l) => { try { return JSON.parse(l); } catch { return { necitibil: true }; } }) : []; const v = SC.verificaJurnal(intrari); return { ok: v.ok, count: v.count, head: v.head, broken: v.ok ? null : { seq: v.rupt, reason: v.motiv } }; } // ------------------------------------------------------------------- inceputul oricarei operatii: marimea, prezentarea, cererea, nonce-ul // Intoarce { a, p, refuza }: `a` e randul de audit in lucru, `refuza(e, reguli)` scrie randul de refuz si arunca e. #inceput(op, name, body, acum) { const a = { op, secret: op === 'list' ? '*' : name, version: null, verdict: 'refused', rules: [], grant: null, holder: null, presenter: null, claimedHolder: null, claimedPresenter: null, binding: null, policyHash: null, requestHash: null, judged: null }; const refuza = (e, reguli = []) => { a.rules = [...new Set([e.rule, ...reguli])]; this.#scrieAudit(a, acum); throw e; }; // marimea, INAINTEA oricarei canonizari (o lista imbricata de 30.000 de ori arunca stiva in JSON-ul canonic): refuzat pe nume, auditat const prea = preaComplex(body); if (!(prea === null /*PAZA:request-form*/)) refuza(refuz('request-form', 'REQUEST_TOO_COMPLEX', `the request is deeper than ${MAX_JSON_DEPTH} levels or carries more than ${MAX_JSON_NODES} values`)); const p = body.presentation; if (!(!!p && typeof p === 'object' && !!p.binding && typeof p.binding === 'object' && typeof p.binding.nonce === 'string' /*PAZA:presentation-form*/)) { refuza(refuz('presentation-form', 'PRESENTATION_MISSING', 'the request carries no presentation of an AERE credential (presentation.binding.nonce is required)')); } a.binding = p.binding; // pe un rand refuzat inainte de verificare, id-urile sunt cele PRETINSE, si numai in forma aere-id (un sir oarecare nu intra in jurnal) try { a.claimedHolder = idPretins(p.credential.statement.holder.id); } catch { a.claimedHolder = null; } try { a.claimedPresenter = idPretins(p.binding.presenter.id); } catch { a.claimedPresenter = null; } try { a.requestHash = requestHash({ audience: this.#audience, operation: op, secret: op === 'list' ? null : name, body }); } catch { refuza(refuz('request-form', 'INVALID_REQUEST', 'the request cannot be read as JSON (it carries a value JSON cannot say)')); } const motivNonce = this.#judecaNonce(p.binding.nonce, acum.getTime()); if (motivNonce) refuza(refuz('nonce', 'NONCE_INVALID', 'the presentation nonce is ' + motivNonce + ' (ask POST /v1/nonce for a fresh one and present it once)')); return { a, p, refuza }; } // ------------------------------------------------------------------- o operatie: forma, nonce, autorizare, lucrul, randul de audit // `fn({ secret, grant, result, acum })` intoarce { value, version?, salveaza? } si poate arunca SecretsError (refuz numit); `salveaza` // e secretul de pus pe disc. Ordinea pentru o mutatie: randul de audit (scris si fsync-at), APOI fisierul; o scriere a fisierului care // cade dupa rand primeste un al doilea rand, `failed`, cu acelasi requestHash. async #opereaza(op, name, body, { cereSecret = true } = {}, fn) { if (this.#closed) throw new SecretsError('SERVICE_CLOSED', 'this service instance was closed'); // forma cererii si numele nu sunt operatii: refuzate fara intrare in jurnal si fara sa consume nonce-ul (spus in README) if (!(!!body && typeof body === 'object' && !Array.isArray(body) /*PAZA:request-form*/)) throw refuz('request-form', 'INVALID_REQUEST', 'the request must be an object { presentation, ... }'); checkName(name); return this.#cuLacat(name, async () => { const acum = this.#acum(); const { a, p, refuza } = this.#inceput(op, name, body, acum); // un fisier stricat (MAC-ul nu tine) se spune numai unui apelant autorizat de politica de admin, si se auditeaza; pana atunci // secretul e tratat ca inexistent (candidatii raman numai politica de admin) let secret = null, defect = null; try { secret = this.#incarca(name); } catch (e) { defect = e; } const j = this.#judeca(op, secret, p, this.#context(p, acum, a.requestHash), new Map()); this.#cineAPrezentat(a, j.identity, p); if (!j.grant) this.#refuzaAutorizarea(a, j, refuza); a.grant = j.grant.id; a.policyHash = j.result.policyHash; a.holder = j.result.holder; if (defect) refuza(defect); // existenta secretului se spune numai unui apelant autorizat (un strain nu afla daca un nume exista) if (cereSecret && !secret) refuza(refuz('secret-exists', 'SECRET_NOT_FOUND', `secret "${name}" does not exist`)); let r; try { r = await fn({ secret, grant: j.grant, result: j.result, identity: j.identity, acum }); } catch (e0) { let e = e0; if (e instanceof KmsError) e = new SecretsError('KMS_' + e.code, 'the KMS refused: ' + e.code, { rule: 'kms' }); else if (!(e instanceof SecretsError)) { e = new SecretsError('INTERNAL', 'internal error', { rule: 'internal' }); Object.defineProperty(e, 'cause', { value: e0, enumerable: false }); } if (!e.rule) e.rule = 'internal'; refuza(e); } a.verdict = 'allowed'; a.version = r.version ?? null; this.#scrieAudit(a, acum); // AUDIT_WRITE_FAILED aici: nimic nu s-a schimbat pe disc if (r.salveaza) { try { this.#salveaza(r.salveaza); } catch (es) { try { this.#scrieAudit({ ...a, verdict: 'failed', rules: ['storage'] }, acum); } catch { /* ramane randul `allowed` singur: o intrare fara schimbare */ } throw refuz('storage', 'STORAGE_WRITE_FAILED', `the change to secret "${name}" could not be stored (${es && es.code || 'error'}); its entry is followed by a failed entry`); } } return r.value; }); } // ------------------------------------------------------------------- operatiile /** Pune o versiune noua (sau creeaza secretul, cu grants, daca nu exista). Intoarce { name, version, createdAt }. */ put(name, body) { return this.#opereaza('put', name, body, { cereSecret: false }, async ({ secret, result, acum }) => { const valoare = toValue(body.value); const metadata = checkMetadata(body.metadata); let sec = secret; if (!sec) { const grants = body.grants === undefined ? [] : body.grants; if (!Array.isArray(grants)) throw refuz('grant-form', 'INVALID_GRANT', 'grants must be a list of grants'); if (!(grants.length <= MAX_GRANTS /*PAZA:grant-limit*/)) throw refuz('grant-limit', 'TOO_MANY_GRANTS', `a secret has at most ${MAX_GRANTS} grants`); const norm = grants.map(normalizeGrant); if (new Set(norm.map((g) => g.id)).size !== norm.length) throw refuz('grant-exists', 'GRANT_EXISTS', 'two grants carry the same id'); sec = { format: FORMAT_OBIECT, name, created: iso(acum), updated: iso(acum), kms_key: this.#kmsKey, current_version: 0, grants: norm.map((g) => ({ ...g, createdBy: result.holder })), versions: {} }; } else if (body.grants !== undefined) { throw refuz('request-form', 'INVALID_REQUEST', 'grants are set when a secret is created; use grant / revoke on an existing secret'); } const ver = await this.#versiuneNoua(sec, valoare, metadata, result.holder); return { value: { name, version: ver, current: ver, createdAt: sec.versions[String(ver)].created }, version: ver, salveaza: sec }; }); } /** Citeste o versiune (implicit cea curenta). Intoarce { name, version, current, value: Buffer, metadata, createdAt }. */ get(name, body) { return this.#opereaza('get', name, body, {}, async ({ secret, grant }) => { const ceruta = body.version === undefined || body.version === null ? secret.current_version : body.version; if (!(Number.isSafeInteger(ceruta) && Object.hasOwn(secret.versions, String(ceruta)) /*PAZA:version-exists*/)) throw refuz('version-exists', 'VERSION_NOT_FOUND', `secret "${name}" has no version ${versiuneAfisata(ceruta)}`); if (!(ceruta === secret.current_version || grant.versions === 'any' /*PAZA:version-scope*/)) throw refuz('version-scope', 'VERSION_NOT_IN_SCOPE', `grant "${grant.id}" reads only the current version of "${name}" (versions: any is required for version ${ceruta})`); const v = secret.versions[String(ceruta)]; if (!(v.state !== 'revoked' /*PAZA:version-revoked*/)) throw refuz('version-revoked', 'VERSION_REVOKED', `version ${ceruta} of secret "${name}" was revoked at ${v.revoked_at}`); const value = await this.#deschide(secret, ceruta); return { value: { name, version: ceruta, current: secret.current_version, value, metadata: { ...v.metadata }, createdAt: v.created }, version: ceruta }; }); } /** Metadatele secretului: versiunile cu starea lor si grants (lista de control intreaga), fara nicio valoare si fara material sigilat. */ metadata(name, body) { return this.#opereaza('metadata', name, body, {}, async ({ secret }) => { const versions = {}; for (const [k, v] of Object.entries(secret.versions)) versions[k] = { created: v.created, state: v.state, revokedAt: v.revoked_at, metadata: { ...v.metadata }, createdBy: v.created_by, kmsVersion: v.kms_version }; return { value: { name, created: secret.created, updated: secret.updated, current: secret.current_version, kmsKey: secret.kms_key, versions, grants: secret.grants.map((g) => JSON.parse(JSON.stringify(g))) }, version: secret.current_version }; }); } /** O versiune noua cu o valoare NASCUTA aici (bytes 16..64 aleatori, base64url sau hex); valoarea nu se intoarce: se citeste cu get. */ rotate(name, body) { return this.#opereaza('rotate', name, body, {}, async ({ secret, result }) => { const bytes = body.bytes === undefined ? 32 : body.bytes; const encoding = body.encoding === undefined ? 'base64url' : body.encoding; if (!Number.isInteger(bytes) || bytes < 16 || bytes > 64) throw refuz('request-form', 'INVALID_REQUEST', 'bytes is 16..64'); if (encoding !== 'base64url' && encoding !== 'hex') throw refuz('request-form', 'INVALID_REQUEST', 'encoding is base64url or hex'); const metadata = checkMetadata(body.metadata); const valoare = utf8(crypto.randomBytes(bytes).toString(encoding)); const ver = await this.#versiuneNoua(secret, valoare, { ...metadata, rotated: true }, result.holder); return { value: { name, version: ver, current: ver, createdAt: secret.versions[String(ver)].created }, version: ver, salveaza: secret }; }); } /** Revoca o versiune: nu se mai citeste niciodata prin acest serviciu (ireversibil); textul cifrat si cheia de date dispar din fisier. */ revokeVersion(name, body) { return this.#opereaza('revoke-version', name, body, {}, async ({ secret, acum }) => { const ver = body.version; if (!(Number.isSafeInteger(ver) && Object.hasOwn(secret.versions, String(ver)) /*PAZA:version-exists*/)) throw refuz('version-exists', 'VERSION_NOT_FOUND', `secret "${name}" has no version ${versiuneAfisata(ver)}`); const v = secret.versions[String(ver)]; if (v.state === 'revoked') throw refuz('version-revoked', 'VERSION_REVOKED', `version ${ver} of secret "${name}" was already revoked at ${v.revoked_at}`); v.state = 'revoked'; v.revoked_at = iso(acum); v.sealed = null; v.dek_wrapped = null; // nici textul cifrat, nici cheia de date invelita nu mai stau pe disc: nimic de deschis return { value: { name, version: ver, state: 'revoked', revokedAt: v.revoked_at }, version: ver, salveaza: secret }; }); } /** Adauga un grant (id unic, cel mult MAX_GRANTS pe secret; facut de un ne-admin, o atenuare a grantului lui). */ grant(name, body) { return this.#opereaza('grant', name, body, {}, async ({ secret, grant: origine, result }) => { const g = normalizeGrant(body.grant); if (!(!secret.grants.some((x) => x.id === g.id) /*PAZA:grant-exists*/)) throw refuz('grant-exists', 'GRANT_EXISTS', `secret "${name}" already has a grant "${g.id}"`); if (!(secret.grants.length < MAX_GRANTS /*PAZA:grant-limit*/)) throw refuz('grant-limit', 'TOO_MANY_GRANTS', `secret "${name}" has ${MAX_GRANTS} grants, the most a secret may have`); if (origine.id !== 'admin') atenuare(g, origine); secret.grants.push({ ...g, createdBy: result.holder }); return { value: { name, grant: g.id, grants: secret.grants.map((x) => x.id) }, version: secret.current_version, salveaza: secret }; }); } /** Retrage un grant dupa id (un ne-admin: numai un grant facut chiar de el). */ revoke(name, body) { return this.#opereaza('revoke', name, body, {}, async ({ secret, grant: origine, result }) => { const id = body.grantId; if (typeof id !== 'string' || !GRANT_ID_RE.test(id)) throw refuz('request-form', 'INVALID_REQUEST', 'grantId is required'); const tinta = secret.grants.find((x) => x.id === id); if (!(!!tinta /*PAZA:grant-exists*/)) throw refuz('grant-exists', 'GRANT_NOT_FOUND', `secret "${name}" has no grant "${id}"`); if (!(origine.id === 'admin' || tinta.createdBy === result.holder /*PAZA:grant-scope*/)) throw refuz('grant-scope', 'GRANT_NOT_OWNED', `grant "${id}" may be revoked by the admin, or by the holder who made it`); secret.grants = secret.grants.filter((x) => x.id !== id); return { value: { name, revoked: id, grants: secret.grants.map((x) => x.id) }, version: secret.current_version, salveaza: secret }; }); } /** Numele secretelor pe care apelantul le poate atinge: toate pentru admin, altfel cele cu un grant care il admite (orice operatie). */ list(body) { if (this.#closed) throw new SecretsError('SERVICE_CLOSED', 'this service instance was closed'); if (!(!!body && typeof body === 'object' && !Array.isArray(body) /*PAZA:request-form*/)) throw refuz('request-form', 'INVALID_REQUEST', 'the request must be an object { presentation }'); const acum = this.#acum(); const { a, p, refuza } = this.#inceput('list', null, body, acum); // identitatea o data (si politica fiecarei perechi de emitenti o data, pentru toate secretele); admin vede tot; altfel fiecare // secret cu un grant (orice operatie) care il admite const ctx = this.#context(p, acum, a.requestHash); const cache = new Map(); const j = this.#judeca('list', null, p, ctx, cache, { oricare: true }); this.#cineAPrezentat(a, j.identity, p); if (!j.identity.valid) this.#refuzaAutorizarea(a, j, refuza); const nume = this.#nume(); let secrets; if (j.grant) { secrets = nume; a.grant = 'admin'; a.policyHash = j.result.policyHash; a.holder = j.result.holder; } else { secrets = []; for (const n of nume) { let sec; try { sec = this.#incarca(n); } catch { continue; } // un fisier stricat nu apare in lista nimanui; adminul il afla la metadata const r = this.#judeca('list', sec, p, ctx, cache, { oricare: true }); if (r.grant && r.grant.id !== 'admin') { secrets.push(n); a.holder = r.result.holder; } } if (!secrets.length) refuza(refuz('authorization', 'UNAUTHORIZED', 'no secret has a grant that admits this presentation')); a.grant = 'grants'; } a.verdict = 'allowed'; this.#scrieAudit(a, acum); return { secrets }; } }