// 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:*/ 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, }'); 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 }); }