aere-quantum/identity/conformitate.mjs
Aere Network 6c42fb2c0b identity: the compliance record's evidence digest is the digest of the presentation's signed binding
Before, it was the digest of the whole presentation object, so an unsigned top-level field added by anyone changed it without
changing anything signed, and the digest did not name one presentation. The binding names, by hash, the whole credential, the
disclosures, the delegation chain, the audience, the nonce and the time; the signature is left out (ML-DSA signs with randomness,
so one binding can carry many valid signatures).

Tests: compliance 15/15, negative control 14/14.
2026-09-30 11:46:51 +03:00

98 lines
8.5 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;
// 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, 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 });
}