From 6c42fb2c0bc65ae547a74e2447c2f0d74c3dbf61 Mon Sep 17 00:00:00 2001 From: Aere Network Date: Wed, 30 Sep 2026 11:46:51 +0300 Subject: [PATCH] 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. --- identity/README.md | 8 +++++--- identity/conformitate.mjs | 7 ++++++- identity/control-negativ-conformitate.mjs | 2 ++ identity/proba-conformitate.mjs | 10 ++++++++++ 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/identity/README.md b/identity/README.md index d0a5a14..9c1142d 100644 --- a/identity/README.md +++ b/identity/README.md @@ -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 ``` diff --git a/identity/conformitate.mjs b/identity/conformitate.mjs index 70e8d96..1523965 100644 --- a/identity/conformitate.mjs +++ b/identity/conformitate.mjs @@ -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 } : {}) }; } /** diff --git a/identity/control-negativ-conformitate.mjs b/identity/control-negativ-conformitate.mjs index 8ed7907..e4b724d 100644 --- a/identity/control-negativ-conformitate.mjs +++ b/identity/control-negativ-conformitate.mjs @@ -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'], diff --git a/identity/proba-conformitate.mjs b/identity/proba-conformitate.mjs index 0753aaa..5a0cd01 100644 --- a/identity/proba-conformitate.mjs +++ b/identity/proba-conformitate.mjs @@ -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;