aere-quantum/identity/conformitate.mjs

93 lines
7.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.
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'];
/** 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 motive = [];
if (!v.valid) motive.push('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));
if (policy.requireStatus && !(status && status.pass === true)) motive.push('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) if (!regula(r, claims)) motive.push(`rule not met: ${canonical(r)}`);
const holder = p && p.credential && p.credential.statement && p.credential.statement.holder ? p.credential.statement.holder.id : null;
return { compliant: !motive.length, reasons: motive, policyHash, policyId: policy.id, holder, notJudged: v.notJudged,
presentationHash: sha(canonical(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 });
}