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.
This commit is contained in:
Aere Network 2026-09-30 11:46:51 +03:00
parent 0b56d8e5df
commit 6c42fb2c0b
4 changed files with 23 additions and 4 deletions

View File

@ -108,7 +108,9 @@ and `POST /v1/compliance/check`, and each answer names the SHA-256 of the files
reproduces it.
The record (`complianceEnvelope`) is an AIP-23 `compliance` envelope that carries no personal data: the policy's hash, the result, the
digest of the presentation, and a pseudonym of the holder bound to this verifier. Without `--pseudonym-key-file` the pseudonym is a
digest of the presentation's signed binding (which names, by hash, the credential, the disclosures, the delegation chain, the
audience, the nonce and the time; an unsigned field added to the presentation does not change it), and a pseudonym of the holder
bound to this verifier. Without `--pseudonym-key-file` the pseudonym is a
SHA-256 over the holder's id and the audience, which anyone who knows both can recompute (it keeps the id out of the record, it does
not hide it from them); with a key it is an HMAC only the verifier can recompute. The verifier keeps the record, and can notarize it
for a time it does not choose, instead of keeping the data. What this is not: a zero-knowledge proof (a shown claim is shown whole;
@ -152,8 +154,8 @@ line is reported as not judged. The private key files are written with mode 0600
```
node proba-identity.mjs # 44: the paths above, and each attack of the adversarial review as its own test
node control-negativ-identity.mjs # on a copy, each of 49 guards removed -> its own named test turns red
node proba-conformitate.mjs # 14: compliance policies judged on real presentations, the record without personal data, the command line
node control-negativ-conformitate.mjs # on a copy, each of 13 guards removed -> its own named test turns red
node proba-conformitate.mjs # 15: compliance policies judged on real presentations, the record without personal data, the command line
node control-negativ-conformitate.mjs # on a copy, each of 14 guards removed -> its own named test turns red
node proba-travel-rule.mjs # 19: two VASPs with registry credentials, the whole exchange, and each attack of the review
node control-negativ-travel-rule.mjs # on a copy, each of 19 guards removed -> its own named test turns red
```

View File

@ -72,8 +72,13 @@ export function checkCompliance(p, politica, { audience, nonce, now = new Date()
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(p || null)), ...(keepClaims && !motive.length ? { claims } : {}) };
presentationHash: sha(canonical(legatura || p || null)), ...(keepClaims && !motive.length ? { claims } : {}) };
}
/**

View File

@ -10,6 +10,8 @@ const DEV_VERIFY = path.resolve(AICI, '..', 'aere-proof-protocol', 'verify.mjs')
const VERIFY = process.env.AERE_VERIFY_PROOF || (fs.existsSync(DEV_VERIFY) ? DEV_VERIFY : '');
const C = 'conformitate.mjs';
const PLANTARI = [
// B-29: forma publicata in 154c424 (digestul obiectului intreg)
['digestul prezentarii peste obiectul intreg (B-29)', 'presentationHash: sha(canonical(legatura || p || null)),', 'presentationHash: sha(canonical(p || null)),', 'B-29'],
// [nume, tipar, inlocuire, proba (inceputul numelui ei)]
['equals nu mai compara', "if (Object.hasOwn(r, 'equals')) return canonical(v) === canonical(r.equals);", "if (Object.hasOwn(r, 'equals')) return true;", 'neconform: jurisdictie interzisa'],
['notIn nu mai compara', "if (Object.hasOwn(r, 'notIn')) return !r.notIn.some((x) => canonical(x) === canonical(v));", "if (Object.hasOwn(r, 'notIn')) return true;", 'neconform: jurisdictie interzisa'],

View File

@ -77,6 +77,16 @@ test('inregistrarea (plic AIP-23 compliance) NU poarta date personale: nici valo
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;