829 lines
62 KiB
JavaScript
829 lines
62 KiB
JavaScript
// 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:<regula>*/ 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_<cod>` 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/<name>.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 <dataDir>/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 };
|
|
}
|
|
}
|