aere-quantum/identity/conformitate.mjs

132 lines
12 KiB
JavaScript

// AERE Identity, conformitatea fara supraveghere (roadmap master punctul 13, pista B, 2026-09-30): o POLITICA de conformitate a
// verificatorului (ce emitenti crede, ce afirmatii cere: varsta, jurisdictie, nivel de verificare, acreditare), judecata pe o
// prezentare de credential (identity.mjs), si o INREGISTRARE a judecatii ca plic AIP-23 `compliance` care NU poarta date personale:
// numai hash-ul politicii, un pseudonim al detinatorului legat de verificator si digestul dovezii. Verificatorul pastreaza plicul
// (notarizabil: momentul il da lantul), nu datele; datele raman la detinator si se arata numai cat cere politica.
//
// Ce NU este: nu e o dovada cu cunoastere zero (ce se arata se arata intreg; "peste 18" e o afirmatie emisa de emitent, nu calculata
// din data nasterii fara s-o vada nimeni); nu e o verificare AML sau de sanctiuni (nu consulta nicio lista); nu face pe nimeni conform
// cu vreo lege: spune ca o prezentare anume a indeplinit o politica anume, la un moment anume, judecata de verificator.
//
// O politica e { v:1, kind:'aere-compliance-policy', id, trustedIssuers:[id], requireStatus, maxAgeS, require:[regula] }, cu regula
// { claim, equals } | { claim, in:[...] } | { claim, notIn:[...] } | { claim, atLeast:number } | { claim, present:true }. Hash-ul ei
// se recalculeaza din forma canonica, deci o politica mai laxa nu poate trece sub hash-ul uneia stricte.
//
// REGULILE NUMITE (2026-10-18, pentru suita de conformitate tools/aere-conformance): fiecare motiv al lui `checkCompliance` numeste regula
// lui, din REGULI (`presentation`: prezentarea e valida pentru acest verificator, judecata de identity.mjs cu emitentii politicii; `status`:
// starea credentialului e judecata cand politica o cere; `rule`: fiecare regula a politicii tine pe afirmatiile aratate); fiecare verificare
// e un rand { rule, pass, detail } si poarta /*PAZA:<regula>*/ pe conditia ei buna. Rezultatul poarta, pe langa `compliant` si `reasons`
// (neschimbate), `rules` (id-urile refuzate, sortate), `rows` si `unjudged` (cauzele lasate nejudecate de verificatorul de identitate).
// O prezentare invalida nu stabileste nicio afirmatie, deci si fiecare regula a politicii iese "not met" (asa spunea si ieri).
import crypto from 'node:crypto';
import { verifyPresentation, canonical, idOf } from './identity.mjs';
const sha = (s) => '0x' + crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex');
const ID = /^aere-id:[0-9a-f]{40}$/;
const OPERATORI = ['equals', 'in', 'notIn', 'atLeast', 'present'];
/** regulile pe care le poate numi un motiv al lui checkCompliance */
export const REGULI = ['presentation', 'status', 'rule'];
/** Forma normala a unei politici si hash-ul ei; refuza campuri necunoscute si reguli fara inteles. */
export function definePolicy(p) {
const chei = ['v', 'kind', 'id', 'trustedIssuers', 'requireStatus', 'maxAgeS', 'require'];
const necunoscute = Object.keys(p || {}).filter((k) => !chei.includes(k));
if (necunoscute.length) throw new Error('compliance policy: unknown field ' + necunoscute.join(', '));
if (typeof p.id !== 'string' || !p.id) throw new Error('compliance policy: id is required');
if (!Array.isArray(p.trustedIssuers) || !p.trustedIssuers.length) throw new Error('compliance policy: trustedIssuers is required (a policy that trusts anyone proves nothing)');
const emitenti = [...new Set(p.trustedIssuers.map((x) => (typeof x === 'string' && ID.test(x) ? x : idOf(x))))].sort();
if (!Array.isArray(p.require) || !p.require.length) throw new Error('compliance policy: require is a non-empty list of rules');
const reguli = p.require.map((r) => {
const op = OPERATORI.filter((o) => Object.hasOwn(r, o));
const extra = Object.keys(r).filter((k) => k !== 'claim' && !OPERATORI.includes(k));
if (typeof r.claim !== 'string' || !r.claim || op.length !== 1 || extra.length) throw new Error('compliance policy: a rule is { claim, <one of ' + OPERATORI.join('|') + '> }');
const o = op[0], v = r[o];
if ((o === 'in' || o === 'notIn') && (!Array.isArray(v) || !v.length)) throw new Error(`compliance policy: ${o} needs a non-empty list`);
if (o === 'atLeast' && !Number.isFinite(v)) throw new Error('compliance policy: atLeast needs a number');
if (o === 'present' && v !== true) throw new Error('compliance policy: present must be true');
canonical(v);
return { claim: r.claim, [o]: (o === 'in' || o === 'notIn') ? [...v].sort((a, b) => (canonical(a) < canonical(b) ? -1 : 1)) : v };
}).sort((a, b) => (canonical(a) < canonical(b) ? -1 : 1));
const maxAgeS = p.maxAgeS == null ? 300 : Number(p.maxAgeS);
if (!Number.isInteger(maxAgeS) || maxAgeS < 1 || maxAgeS > 3600) throw new Error('compliance policy: maxAgeS must be 1..3600');
const policy = { v: 1, kind: 'aere-compliance-policy', id: p.id, trustedIssuers: emitenti, requireStatus: p.requireStatus !== false, maxAgeS, require: reguli };
return { policy, policyHash: sha(canonical(policy)) };
}
function regula(r, claims) {
const are = Object.hasOwn(claims, r.claim), v = claims[r.claim];
if (Object.hasOwn(r, 'present')) return are;
if (!are) return false;
if (Object.hasOwn(r, 'equals')) return canonical(v) === canonical(r.equals);
if (Object.hasOwn(r, 'in')) return r.in.some((x) => canonical(x) === canonical(v));
if (Object.hasOwn(r, 'notIn')) return !r.notIn.some((x) => canonical(x) === canonical(v));
if (Object.hasOwn(r, 'atLeast')) return typeof v === 'number' && Number.isFinite(v) && v >= r.atLeast;
return false;
}
// 2026-10-18 (B8, revizuirea adversariala a lui pq-secrets): un serviciu care judeca ACEEASI prezentare dupa mai multe politici (un
// grant fiecare) refacea verificarea hibrida intreaga pentru fiecare: 200 de grant-uri, 0,5 s de CPU pentru un apelant fara niciun drept.
// Cu `cache` (un Map facut de apelant pentru O prezentare), verificarea se face o data pentru fiecare pereche distincta (emitentii
// politicii, varsta maxima), singurele lucruri prin care difera de la o politica la alta; contextul (prezentarea, publicul, nonce-ul,
// ceasul, listele de stare, revocarile, cererea) e legat de cache la prima folosire si o folosire cu alt context e o greseala a
// apelantului (arunca). Iesirea lui checkCompliance e aceeasi, cu cache sau fara.
const CONTEXT = Symbol('context');
function verifica(p, o, cache) {
if (cache == null) return verifyPresentation(p, o);
if (!(cache instanceof Map)) throw new Error('compliance: cache is a Map made by the caller for one presentation');
const t = new Date(o.now).getTime(); const ctx = cache.get(CONTEXT);
// listele se compara dupa identitate (aceeasi lista data de apelant), doua liste goale sunt aceeasi (implicitul `[]` e alt obiect la
// fiecare apel)
const aceeasi = (x, y) => x === y || (Array.isArray(x) && Array.isArray(y) && x.length === 0 && y.length === 0);
if (ctx === undefined) cache.set(CONTEXT, { p, audience: o.audience, nonce: o.nonce, t, statusLists: o.statusLists, revocations: o.revocations, request: o.request });
else if (!(ctx.p === p && ctx.audience === o.audience && ctx.nonce === o.nonce && ctx.t === t && aceeasi(ctx.statusLists, o.statusLists) && aceeasi(ctx.revocations, o.revocations) && ctx.request === o.request)) {
throw new Error('compliance: a cache serves one presentation judged in one context (the same presentation, audience, nonce, clock, status lists, revocations and request)');
}
const k = canonical([o.trustedIssuers, o.maxAgeS]);
if (!cache.has(k)) cache.set(k, verifyPresentation(p, o));
return cache.get(k);
}
/**
* Judeca o prezentare dupa o politica. Cere: prezentarea VALIDA, legata de publicul si nonce-ul verificatorului, emitentul intre cei
* ai politicii, starea credentialului JUDECATA (nu doar "nerevocat pentru ca nu stiu") cand politica o cere, si fiecare regula.
* Intoarce { compliant, reasons, policyHash, presentation } fara afirmatiile detinatorului (le are verificatorul in `presentation.claims`
* numai daca le cere explicit, cu `keepClaims`). `request` (optional): digestul cererii primite, dat verificatorului de identitate
* (regula lui `request`); `cache` (optional): vezi mai sus.
*/
export function checkCompliance(p, politica, { audience, nonce, now = new Date(), statusLists = [], revocations = [], keepClaims = false, request = null, cache = null } = {}) {
const { policy, policyHash } = definePolicy(politica);
if (typeof audience !== 'string' || !audience || typeof nonce !== 'string' || !nonce) throw new Error('compliance: the verifier\'s audience and nonce are required (without them a presentation can be replayed)');
const v = verifica(p, { audience, nonce, now, trustedIssuers: policy.trustedIssuers, statusLists, revocations, maxAgeS: policy.maxAgeS, request }, cache);
const rows = []; const ok = (rule, pass, detail) => { if (!REGULI.includes(rule)) throw new Error('compliance: undeclared rule ' + rule); rows.push({ rule, pass: !!pass, detail: pass ? '' : detail }); };
ok('presentation', v.valid /*PAZA:presentation*/, 'the presentation is not valid: ' + v.rows.filter((r) => r.pass === false).map((r) => r.name + (r.detail ? ' (' + r.detail + ')' : '')).join('; '));
const status = v.rows.find((r) => /^credential: (not revoked|status)/.test(r.name));
ok('status', !policy.requireStatus || !!(status && status.pass === true) /*PAZA:status*/, 'the policy requires the credential status to be judged: ' + (status ? status.detail || 'not judged' : 'no status row'));
const claims = v.claims || {};
for (const r of policy.require) ok('rule', regula(r, claims) /*PAZA:rule*/, `rule not met: ${canonical(r)}`);
const motive = rows.filter((r) => !r.pass).map((r) => r.detail);
const holder = p && p.credential && p.credential.statement && p.credential.statement.holder ? p.credential.statement.holder.id : null;
// B-29 (2026-09-30, revizuirea API-ului): digestul e al LEGATURII semnate de cel care prezinta (ea numeste prin hash credentialul intreg,
// dezvaluirile, lantul de delegare, publicul, nonce-ul si momentul), nu al obiectului intreg: un camp de sus nesemnat, adaugat de
// oricine, schimba obiectul fara sa schimbe nimic semnat, deci digestul vechi nu numea unic prezentarea. Semnatura nu intra: ML-DSA
// semneaza aleator, deci aceeasi legatura poate avea oricate semnaturi valide. Fara legatura (prezentare stricata), obiectul intreg.
const legatura = p && typeof p === 'object' && p.binding && typeof p.binding === 'object' && !Array.isArray(p.binding) ? p.binding : null;
return { compliant: !motive.length, reasons: motive, rules: [...new Set(rows.filter((r) => !r.pass).map((r) => r.rule))].sort(), rows, unjudged: [...v.unjudged], policyHash, policyId: policy.id, holder, notJudged: v.notJudged,
presentationHash: sha(canonical(legatura || p || null)), ...(keepClaims && !motive.length ? { claims: { ...claims } } : {}) };
}
/**
* Inregistrarea judecatii ca plic AIP-23 `compliance` (proof-kinds), FARA date personale: subject = un pseudonim al detinatorului
* legat de verificator, policy = hash-ul politicii, result = compliant | not-compliant, evidence = digestul prezentarii (verificatorul
* o poate pastra sau sterge; digestul ramane). Pseudonimul: fara `pseudonymKey`, sha256 peste id-ul detinatorului si public, pe care il
* poate reface oricine stie id-ul si publicul (tine id-ul afara din inregistrare, nu il ascunde de ei); cu `pseudonymKey` (un secret al
* verificatorului), HMAC-SHA256, pe care numai verificatorul il poate reface.
*/
export function complianceEnvelope(rez, { audience, createdAt = new Date().toISOString(), buildProof, pseudonymKey = null }) {
if (!rez || !rez.policyHash || !rez.presentationHash) throw new Error('compliance: a result of checkCompliance is required');
if (typeof audience !== 'string' || !audience) throw new Error('compliance: the audience names the verifier the pseudonym is bound to');
const intrare = `aere-compliance-subject|${rez.holder}|${audience}`;
const pseudonim = 'pseudonym:' + (pseudonymKey ? crypto.createHmac('sha256', pseudonymKey).update(intrare).digest('hex') : sha(intrare).slice(2)).slice(0, 40);
return buildProof('compliance', { subject: pseudonim, policy: rez.policyHash, result: rez.compliant ? 'compliant' : 'not-compliant', evidenceHash: rez.presentationHash, createdAt });
}