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.
131 lines
13 KiB
JavaScript
131 lines
13 KiB
JavaScript
// Proba conformitatii fara supraveghere (conformitate.mjs): politica, judecata pe prezentari reale (identity.mjs), si inregistrarea
|
|
// fara date personale. Fiecare afirmatie cu perechea ei negativa. Offline. Plicul AIP-23 se judeca si cu verificatorul de referinta
|
|
// (AERE_VERIFY_PROOF sau, in depozitul de dezvoltare, ../aere-proof-protocol/verify.mjs); fara el, acea proba iese NEMASURATA (cod 2).
|
|
// node proba-conformitate.mjs iesire 0 = toate cum trebuia
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import * as I from './identity.mjs';
|
|
import { definePolicy, checkCompliance, complianceEnvelope } from './conformitate.mjs';
|
|
import { buildProof } from '../proof-kinds/proof-kinds.mjs';
|
|
|
|
const AICI = path.dirname(fileURLToPath(import.meta.url));
|
|
const VERIFY = process.env.AERE_VERIFY_PROOF || path.resolve(AICI, '..', 'aere-proof-protocol', 'verify.mjs');
|
|
let treceri = 0, sarite = 0; const esecuri = [];
|
|
function test(nume, fn) { try { if (fn() === 'SARIT') return; treceri++; console.log(' OK ' + nume); } catch (e) { esecuri.push(nume); console.log(' RAU ' + nume + ' -- ' + (e.message || e)); } }
|
|
const cere = (c, m) => { if (!c) throw new Error(m); };
|
|
const arunca = (f) => { try { f(); return null; } catch (e) { return e.message; } };
|
|
|
|
const NOW = new Date('2026-09-30T08:00:00Z'), AUD = 'https://exchange.example', NONCE = 'c-1';
|
|
const iss = I.generateKeys(), hol = I.generateKeys(), alt = I.generateKeys();
|
|
const CLAIMS = { name: 'Ana Pop', birthdate: '1990-01-01', age_over_18: true, jurisdiction: 'RO', kyc_level: 2 };
|
|
const emite = (emitent = iss, index = 3) => I.issueCredential({ issuer: emitent, holder: hol.public, type: 'KycCredential', claims: CLAIMS,
|
|
disclosable: ['name', 'birthdate', 'age_over_18', 'jurisdiction', 'kyc_level'], validUntil: '2027-09-30T00:00:00Z', status: { list: 'urn:s:kyc', index }, now: NOW });
|
|
const { credential, disclosures } = emite();
|
|
const LISTA = I.createStatusList({ issuer: iss, id: 'urn:s:kyc', revoked: [9], validUntil: '2026-10-07T00:00:00Z', now: NOW });
|
|
const POL = { id: 'exchange-onboarding-v1', trustedIssuers: [iss.id], require: [{ claim: 'age_over_18', equals: true }, { claim: 'jurisdiction', notIn: ['KP', 'IR'] }, { claim: 'kyc_level', atLeast: 2 }] };
|
|
const prez = (reveal = ['age_over_18', 'jurisdiction', 'kyc_level'], o = {}) => I.present({ credential: o.credential || credential, disclosures: o.disclosures || disclosures, reveal, presenter: hol, audience: o.audience || AUD, nonce: NONCE, now: NOW });
|
|
const judeca = (p, pol = POL, o = {}) => checkCompliance(p, pol, { audience: AUD, nonce: NONCE, now: NOW, statusLists: [LISTA], ...o });
|
|
|
|
test('politica: forma normala; aceeasi politica cu regulile si emitentii in alta ordine are acelasi hash; alta regula, alt hash', () => {
|
|
const a = definePolicy(POL), b = definePolicy({ ...POL, require: [...POL.require].reverse(), trustedIssuers: [iss.public] });
|
|
cere(a.policyHash === b.policyHash && /^0x[0-9a-f]{64}$/.test(a.policyHash), `${a.policyHash} ${b.policyHash}`);
|
|
cere(definePolicy({ ...POL, require: [{ claim: 'kyc_level', atLeast: 1 }] }).policyHash !== a.policyHash, 'o politica mai laxa are acelasi hash');
|
|
});
|
|
test('CONTROL: politica refuza campuri necunoscute, lista de emitenti goala, o regula cu doi operatori sau fara niciunul', () => {
|
|
const m = [arunca(() => definePolicy({ ...POL, allowAll: true })), arunca(() => definePolicy({ ...POL, trustedIssuers: [] })),
|
|
arunca(() => definePolicy({ ...POL, require: [{ claim: 'x', equals: 1, in: [1] }] })), arunca(() => definePolicy({ ...POL, require: [{ claim: 'x' }] }))];
|
|
cere(/unknown field/.test(m[0]) && /trustedIssuers is required/.test(m[1]) && /a rule is/.test(m[2]) && /a rule is/.test(m[3]), m.join(' | '));
|
|
});
|
|
test('conform: varsta, jurisdictia in afara celor interzise, nivelul de verificare; starea judecata', () => {
|
|
const r = judeca(prez()); cere(r.compliant && !r.reasons.length && r.notJudged === 0, JSON.stringify(r.reasons));
|
|
});
|
|
test('neconform: o afirmatie ceruta nearatata (kyc_level) -> motivul numeste regula', () => {
|
|
const r = judeca(prez(['age_over_18', 'jurisdiction'])); cere(!r.compliant && r.reasons.some((x) => /kyc_level/.test(x)), JSON.stringify(r.reasons));
|
|
});
|
|
test('neconform: jurisdictie interzisa (notIn); nivel sub prag (atLeast); valoare falsa (equals)', () => {
|
|
const c2 = I.issueCredential({ issuer: iss, holder: hol.public, type: 'KycCredential', claims: { ...CLAIMS, jurisdiction: 'KP', kyc_level: 1, age_over_18: false },
|
|
disclosable: ['age_over_18', 'jurisdiction', 'kyc_level'], validUntil: '2027-09-30T00:00:00Z', status: { list: 'urn:s:kyc', index: 4 }, now: NOW });
|
|
const r = judeca(prez(undefined, c2));
|
|
cere(!r.compliant && ['jurisdiction', 'kyc_level', 'age_over_18'].every((n) => r.reasons.some((x) => x.includes(n))), JSON.stringify(r.reasons));
|
|
});
|
|
test('neconform: emitent in afara celor ai politicii', () => {
|
|
const c3 = emite(alt); const r = judeca(prez(undefined, c3)); cere(!r.compliant && r.reasons.some((x) => /issuer trusted/.test(x)), JSON.stringify(r.reasons));
|
|
});
|
|
test('neconform: starea ceruta si lista nedata (nejudecat NU e "nerevocat"); fara cerinta de stare, conform cu randul nejudecat numarat', () => {
|
|
const r = judeca(prez(), POL, { statusLists: [] }); cere(!r.compliant && r.reasons.some((x) => /status to be judged/.test(x)), JSON.stringify(r.reasons));
|
|
const r2 = judeca(prez(), { ...POL, requireStatus: false }, { statusLists: [] }); cere(r2.compliant && r2.notJudged === 1, JSON.stringify(r2));
|
|
});
|
|
test('neconform: credentialul revocat de emitent', () => {
|
|
const c4 = emite(iss, 9); const r = judeca(prez(undefined, c4)); cere(!r.compliant && r.reasons.some((x) => /revoked/.test(x)), JSON.stringify(r.reasons));
|
|
});
|
|
test('neconform: prezentare facuta pentru alt verificator (reluare); fara public si nonce, judecata refuza sa inceapa', () => {
|
|
const r = judeca(prez(undefined, { audience: 'https://other.example' })); cere(!r.compliant && r.reasons.some((x) => /made for/.test(x)), JSON.stringify(r.reasons));
|
|
cere(/audience and nonce are required/.test(arunca(() => checkCompliance(prez(), POL, { now: NOW, statusLists: [LISTA] })) || ''), 'a judecat fara public');
|
|
});
|
|
test('rezultatul nu poarta afirmatiile detinatorului decat la cerere (keepClaims), si numai pentru o judecata conforma', () => {
|
|
const r = judeca(prez()); cere(!('claims' in r) && !JSON.stringify(r).includes('"RO"'), JSON.stringify(r));
|
|
const k = judeca(prez(), POL, { keepClaims: true }); cere(k.claims && k.claims.jurisdiction === 'RO', JSON.stringify(k.claims));
|
|
const kn = judeca(prez(['age_over_18']), POL, { keepClaims: true }); cere(!kn.compliant && !('claims' in kn), 'afirmatiile intoarse pentru o judecata neconforma');
|
|
});
|
|
test('inregistrarea (plic AIP-23 compliance) NU poarta date personale: nici valori, nici id-ul detinatorului', () => {
|
|
const e = complianceEnvelope(judeca(prez()), { audience: AUD, createdAt: NOW.toISOString(), buildProof });
|
|
const s = JSON.stringify(e);
|
|
for (const x of ['Ana Pop', '1990-01-01', '"RO"', hol.id, hol.public.mldsa65.slice(0, 40)]) cere(!s.includes(x), 'plicul contine ' + x.slice(0, 30));
|
|
cere(e.statement.result === 'compliant' && /^pseudonym:[0-9a-f]{40}$/.test(e.statement.subject) && e.statement.policy === definePolicy(POL).policyHash, JSON.stringify(e.statement));
|
|
});
|
|
// B-29 (2026-09-30): digestul din inregistrare numea obiectul intreg, deci un camp de sus nesemnat il schimba fara sa schimbe nimic semnat
|
|
test('B-29: digestul prezentarii e al legaturii semnate: un camp de sus nesemnat nu il schimba; alta prezentare (alt nonce) il schimba', () => {
|
|
const p = prez(), r1 = judeca(p);
|
|
const p2 = { ...p, nota: 'adaugat dupa semnare' }; const r2 = judeca(p2);
|
|
cere(r1.compliant && r2.compliant && r1.presentationHash === r2.presentationHash, 'un camp nesemnat a schimbat digestul: ' + r1.presentationHash + ' ' + r2.presentationHash);
|
|
const e1 = complianceEnvelope(r1, { audience: AUD, buildProof, createdAt: '2026-09-30T08:00:00Z' }), e2 = complianceEnvelope(r2, { audience: AUD, buildProof, createdAt: '2026-09-30T08:00:00Z' });
|
|
cere(e1.statementHash === e2.statementHash, 'inregistrarile difera pentru aceeasi prezentare semnata');
|
|
const p3 = I.present({ credential, disclosures, reveal: ['age_over_18', 'jurisdiction', 'kyc_level'], presenter: hol, audience: AUD, nonce: 'alt-nonce', now: NOW });
|
|
cere(checkCompliance(p3, POL, { audience: AUD, nonce: 'alt-nonce', now: NOW, statusLists: [LISTA] }).presentationHash !== r1.presentationHash, 'alta prezentare are acelasi digest');
|
|
});
|
|
test('pseudonimul: alt verificator (alt public) vede alt pseudonim; cu o cheie a verificatorului, altul decat forma publica refacuta de oricine', () => {
|
|
const r = judeca(prez());
|
|
const a = complianceEnvelope(r, { audience: AUD, buildProof }).statement.subject, b = complianceEnvelope(r, { audience: 'https://bank.example', buildProof }).statement.subject;
|
|
const k = complianceEnvelope(r, { audience: AUD, buildProof, pseudonymKey: crypto.randomBytes(32) }).statement.subject;
|
|
const refacut = 'pseudonym:' + crypto.createHash('sha256').update(`aere-compliance-subject|${hol.id}|${AUD}`).digest('hex').slice(0, 40);
|
|
cere(a !== b && a === refacut && k !== refacut, `${a} ${b} ${k}`);
|
|
});
|
|
test('plicul verifica la verificatorul AIP-23 de referinta; unul cu rezultatul rescris nu', () => {
|
|
if (!fs.existsSync(VERIFY)) { sarite++; console.log(` SARIT plicul AIP-23: verificatorul nu e la ${VERIFY}; AERE_VERIFY_PROOF=<verify-proof.mjs>`); return 'SARIT'; }
|
|
const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-conf-'));
|
|
try {
|
|
const v = (o) => { const f = path.join(T, crypto.randomUUID() + '.json'); fs.writeFileSync(f, JSON.stringify(o)); try { return JSON.parse(spawnSync(process.execPath, [VERIFY, f, '--json'], { encoding: 'utf8' }).stdout).verdict; } catch { return '?'; } };
|
|
const e = complianceEnvelope(judeca(prez(['age_over_18'])), { audience: AUD, buildProof });
|
|
const rescris = JSON.parse(JSON.stringify(e)); rescris.statement.result = 'compliant';
|
|
cere(e.statement.result === 'not-compliant' && v(e) === 'VALID' && v(rescris) !== 'VALID', `${v(e)} ${v(rescris)}`);
|
|
} finally { fs.rmSync(T, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('linia de comanda: comply -> COMPLIANT (0) si inregistrarea scrisa fara date personale; o prezentare care nu arata destul -> NOT COMPLIANT (1)', () => {
|
|
const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-conf-cli-')); const CLI = path.join(AICI, 'identity-cli.mjs');
|
|
const run = (...a) => { const r = spawnSync(process.execPath, [CLI, ...a], { cwd: T, encoding: 'utf8' }); return { cod: r.status, out: (r.stdout || '') + (r.stderr || '') }; };
|
|
try {
|
|
for (const n of ['iss', 'hol']) cere(run('keygen', '--out', n + '.keys.json').cod === 0, 'keygen');
|
|
cere(run('pub', '--keys', 'hol.keys.json', '--out', 'hol.pub.json').cod === 0, 'pub');
|
|
cere(run('issue', '--issuer-keys', 'iss.keys.json', '--holder-pub', 'hol.pub.json', '--type', 'KycCredential', '--claim', 'age_over_18=true', '--claim', 'jurisdiction=RO',
|
|
'--claim', 'name=Ana Pop', '--all-disclosable', '--status-list', 'urn:s:1', '--status-index', '2', '--out', 'cred.json').cod === 0, 'issue');
|
|
cere(run('status-list', '--issuer-keys', 'iss.keys.json', '--id', 'urn:s:1', '--out', 'list.json').cod === 0, 'status-list');
|
|
const issId = run('id', '--keys', 'iss.keys.json').out.trim();
|
|
fs.writeFileSync(path.join(T, 'pol.json'), JSON.stringify({ id: 'p1', trustedIssuers: [issId], require: [{ claim: 'age_over_18', equals: true }, { claim: 'jurisdiction', notIn: ['KP'] }] }));
|
|
cere(run('present', '--cred', 'cred.json', '--reveal', 'age_over_18,jurisdiction', '--presenter-keys', 'hol.keys.json', '--audience', AUD, '--nonce', 'n1', '--out', 'p.json').cod === 0, 'present');
|
|
const a = run('comply', '--presentation', 'p.json', '--policy', 'pol.json', '--audience', AUD, '--nonce', 'n1', '--status-list', 'list.json', '--record', 'rec.json');
|
|
const rec = fs.readFileSync(path.join(T, 'rec.json'), 'utf8');
|
|
cere(a.cod === 0 && /COMPLIANT/.test(a.out) && /"result": "compliant"/.test(rec) && !/Ana Pop|"RO"|aere-id:/.test(rec), a.out + rec);
|
|
cere(run('present', '--cred', 'cred.json', '--reveal', 'jurisdiction', '--presenter-keys', 'hol.keys.json', '--audience', AUD, '--nonce', 'n2', '--out', 'p2.json').cod === 0, 'present 2');
|
|
const b = run('comply', '--presentation', 'p2.json', '--policy', 'pol.json', '--audience', AUD, '--nonce', 'n2', '--status-list', 'list.json');
|
|
cere(b.cod === 1 && /NOT COMPLIANT/.test(b.out) && /age_over_18/.test(b.out), b.out);
|
|
} finally { fs.rmSync(T, { recursive: true, force: true }); }
|
|
});
|
|
|
|
console.log(`\naere-compliance: ${treceri}/${treceri + esecuri.length} cum trebuia${sarite ? `, ${sarite} NEMASURATE (fara verificatorul AIP-23)` : ''}`);
|
|
process.exitCode = esecuri.length ? 1 : (sarite ? 2 : 0);
|