108 lines
9.9 KiB
JavaScript
108 lines
9.9 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;
|
|
}
|
|
|
|
/**
|
|
* 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`).
|
|
*/
|
|
export function checkCompliance(p, politica, { audience, nonce, now = new Date(), statusLists = [], revocations = [], keepClaims = false } = {}) {
|
|
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 = verifyPresentation(p, { audience, nonce, now, trustedIssuers: policy.trustedIssuers, statusLists, revocations, maxAgeS: policy.maxAgeS });
|
|
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 } : {}) };
|
|
}
|
|
|
|
/**
|
|
* 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 });
|
|
}
|