diff --git a/identity/README.md b/identity/README.md index 498b7bc..bea502d 100644 --- a/identity/README.md +++ b/identity/README.md @@ -120,7 +120,9 @@ import { generateKemKeys, bindKemKeys, acceptBeneficiary, sealMessage, openMessa ``` `openMessage` refuses a message not signed by the originator it names, sealed for another VASP or other keys, older or newer than 300 -seconds on its clock, already received (`seen`), or whose originator is not proven a VASP. `travelRuleRecord` writes an AIP-23 +seconds on its clock, already received, or whose originator is not proven a VASP. The replay store `seen` is required (a `Set`, or +any object with `has` and `add`, that keeps the ids for at least the 300 seconds): without one, `openMessage` opens nothing, since +the same signed and sealed message could otherwise be opened any number of times. `travelRuleRecord` writes an AIP-23 `compliance` envelope with no personal data and no amount: a pseudonymous transfer reference, the policy name, the result and a digest of the exchange. What it does not do: validate the IVMS101 schema (it carries the object as given and requires `originator` and `beneficiary`), find the beneficiary's VASP from an address (that is a discovery protocol's job), or say that a transfer is lawful. @@ -139,8 +141,8 @@ node proba-identity.mjs # 43: the paths above, and each attack of t node control-negativ-identity.mjs # on a copy, each of 46 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-travel-rule.mjs # 18: 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 18 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 ``` The envelope test needs the AIP-23 reference verifier (`AERE_VERIFY_PROOF=`); without it that test is reported as diff --git a/identity/control-negativ-travel-rule.mjs b/identity/control-negativ-travel-rule.mjs index c114d2e..ac64e07 100644 --- a/identity/control-negativ-travel-rule.mjs +++ b/identity/control-negativ-travel-rule.mjs @@ -22,7 +22,9 @@ const PLANTARI = [ ['destinatarul nu se mai compara', [['if (m.to.id !== me) motive.push(', 'if (false) motive.push(']], 'ATAC: alt VASP (C)'], ['cheile KEM ale destinatarului nu se mai compara', [['if (canonical(m.to.kem) !== canonical(kem.public)) motive.push(', 'if (false) motive.push(']], 'ATAC: alt VASP (C)'], ['prospetimea nu se mai cere', [["if (Math.abs(acum - timp(m.createdAt, 'createdAt')) > maxAgeS * 1000) motive.push(", 'if (false) motive.push(']], 'ATAC: acelasi mesaj primit a doua oara'], - ['reluarea nu se mai opreste', [['if (seen && seen.has(m.id)) motive.push(', 'if (false) motive.push(']], 'ATAC: acelasi mesaj primit a doua oara'], + ['reluarea nu se mai opreste', [[' else if (seen.has(m.id)) motive.push(', ' else if (false) motive.push(']], 'ATAC: acelasi mesaj primit a doua oara'], + // B-26: forma publicata in f75c334 (seen optional: fara el, orice reluare trecea) + ['magazia de reluare iar optionala (B-26)', [[" if (!seen || typeof seen.has !== 'function' || typeof seen.add !== 'function') motive.push(", " if (false) motive.push("], [" else if (seen.has(m.id)) motive.push(", " else if (seen && seen.has && seen.has(m.id)) motive.push("], [' seen.add(m.id);', ' if (seen && seen.add) seen.add(m.id);']], 'B-26'], ['initiatorul nu mai trebuie dovedit VASP', [["motive.push(...v.motive.map((x) => 'originator VASP: ' + x));", '']], 'ATAC: initiatorul nedovedit ca VASP'], ['antetul nu mai e legat de cifru (nici AAD, nici derivarea)', [["const c = crypto.createCipheriv('aes-256-gcm', d.key, d.iv); c.setAAD(aad);", "const c = crypto.createCipheriv('aes-256-gcm', d.key, d.iv);"], ['dc.setAAD(aad); dc.setAuthTag', 'dc.setAuthTag'], ["crypto.createHash('sha256').update(transcript).digest()", 'Buffer.alloc(32)']], 'ATAC: suma din antet schimbata'], ['numai secretul X25519 intra in cheie', [['const ikm = Buffer.concat([ssK, ssX]);', 'const ikm = Buffer.concat([ssX]);']], 'KEM hibrid'], diff --git a/identity/proba-travel-rule.mjs b/identity/proba-travel-rule.mjs index 621467b..0c84869 100644 --- a/identity/proba-travel-rule.mjs +++ b/identity/proba-travel-rule.mjs @@ -35,7 +35,7 @@ const bindB = TR.bindKemKeys({ vasp: B, kem: kemB, validUntil: '2026-12-31T00:00 const accB = TR.acceptBeneficiary({ binding: bindB, presentation: pres(credB, B, A.id, 'n-acc'), registries: [reg.id], originatorId: A.id, nonce: 'n-acc', now: NOW, statusLists: [LISTA] }); const MID = 'urn:uuid:11111111-2222-4333-8444-555555555555'; const sigileaza = (o = {}) => TR.sealMessage({ from: A, beneficiary: accB.beneficiary, ivms101: IVMS, transfer: TRANSFER, presentation: pres(credA, A, B.id, MID), messageId: MID, now: NOW, ...o }); -const deschide = (m, o = {}) => TR.openMessage(m, { me: B.id, kem: kemB, registries: [reg.id], now: NOW, statusLists: [LISTA], ...o }); +const deschide = (m, o = {}) => TR.openMessage(m, { me: B.id, kem: kemB, registries: [reg.id], now: NOW, statusLists: [LISTA], seen: new Set(), ...o }); const are = (r, re) => !r.ok && r.motive.some((x) => re.test(x)); // ---------------------------------------------------------------- beneficiarul acceptat @@ -99,8 +99,8 @@ test('ATAC: mesaj semnat de un strain in numele lui A -> refuzat; semnatura lui const m2 = clon(M); m2.signature = I.signText('kem-binding', text, A); cere(are(deschide(m2), /not signed by the originator/), 'alt scop a trecut'); }); test('ATAC: alt VASP (C) incearca sa deschida mesajul lui B -> refuzat; cu cheile lui KEM in numele lui B -> refuzat', () => { - cere(are(TR.openMessage(M, { me: C.id, kem: kemC, registries: [reg.id], now: NOW, statusLists: [LISTA] }), /is for/), 'C l-a deschis'); - cere(are(TR.openMessage(M, { me: B.id, kem: kemC, registries: [reg.id], now: NOW, statusLists: [LISTA] }), /other KEM keys/), 'cheile lui C au trecut'); + cere(are(TR.openMessage(M, { me: C.id, kem: kemC, registries: [reg.id], now: NOW, statusLists: [LISTA], seen: new Set() }), /is for/), 'C l-a deschis'); + cere(are(TR.openMessage(M, { me: B.id, kem: kemC, registries: [reg.id], now: NOW, statusLists: [LISTA], seen: new Set() }), /other KEM keys/), 'cheile lui C au trecut'); }); test('KEM hibrid: fara secretul ML-KEM corect (cheia X25519 buna) sau fara cel X25519 (ML-KEM bun), mesajul nu se descifreaza', () => { const k1 = { public: kemB.public }; Object.defineProperty(k1, 'privat', { value: { x25519: kemB.privat.x25519, mlkem768: kemC.privat.mlkem768 } }); @@ -108,6 +108,11 @@ test('KEM hibrid: fara secretul ML-KEM corect (cheia X25519 buna) sau fara cel X const r1 = deschide(M, { kem: k1 }), r2 = deschide(M, { kem: k2 }); cere(!r1.ok && !r2.ok && r1.motive.concat(r2.motive).every((x) => /modified|does not open/.test(x)), r1.motive.concat(r2.motive).join(' | ')); }); +// B-26 (2026-09-30, revizuirea adversariala): fara magazia de reluare, acelasi mesaj se deschidea de oricate ori +test('B-26: fara magazia de reluare (seen lipsa sau fara has/add) nu se deschide nimic', () => { + cere(are(deschide(M, { seen: undefined }), /no replay store/), 'fara seen s-a deschis'); + cere(are(deschide(M, { seen: {} }), /no replay store/), 'un obiect fara has/add a trecut drept magazie'); +}); test('ATAC: acelasi mesaj primit a doua oara (reluare) -> refuzat; un mesaj vechi (301 s) -> refuzat', () => { const seen = new Set(); cere(deschide(M, { seen }).ok, 'prima deschidere'); cere(are(deschide(M, { seen }), /replay/), 'reluarea a trecut'); diff --git a/identity/travel-rule.mjs b/identity/travel-rule.mjs index c5f7017..41d5770 100644 --- a/identity/travel-rule.mjs +++ b/identity/travel-rule.mjs @@ -137,6 +137,8 @@ export function sealMessage({ from, beneficiary, ivms101, transfer, presentation /** * Beneficiarul deschide un mesaj: semnatura initiatorului, VASP-ul lui (prezentare pentru beneficiar, nonce = id-ul mesajului), * destinatarul (cheile KEM ale beneficiarului), prospetimea, reluarea (`seen`: id-urile deja primite), apoi descifrarea. + * B-26 (2026-09-30, revizuirea adversariala): `seen` e OBLIGATORIU (un Set sau orice obiect cu has/add care tine id-urile cel putin + * maxAgeS); fara el, acelasi mesaj semnat si sigilat se deschidea de oricate ori, deci reluarea era prinsa numai de cine stia sa ceara. * Intoarce { ok, motive, ivms101, transfer, from, messageHash }. */ export function openMessage(m, { me, kem, registries, now = new Date(), statusLists = [], seen = null, maxAgeS = 300 }) { @@ -149,7 +151,8 @@ export function openMessage(m, { me, kem, registries, now = new Date(), statusLi if (m.to.id !== me) motive.push(`the message is for ${m.to.id}, not for ${me}`); if (canonical(m.to.kem) !== canonical(kem.public)) motive.push('the message is sealed to other KEM keys'); if (Math.abs(acum - timp(m.createdAt, 'createdAt')) > maxAgeS * 1000) motive.push(`the message was made at ${m.createdAt}, outside ${maxAgeS} s of this clock`); - if (seen && seen.has(m.id)) motive.push('replay: this message id was received before'); + if (!seen || typeof seen.has !== 'function' || typeof seen.add !== 'function') motive.push('no replay store was given (seen: a set of the message ids already received, kept at least maxAgeS)'); + else if (seen.has(m.id)) motive.push('replay: this message id was received before'); const v = eVasp(m.fromPresentation, { registries, audience: me, nonce: m.id, id: m.from.id, now, statusLists, maxAgeS }); motive.push(...v.motive.map((x) => 'originator VASP: ' + x)); if (motive.length) return { ok: false, motive }; @@ -168,7 +171,7 @@ export function openMessage(m, { me, kem, registries, now = new Date(), statusLi try { const dc = crypto.createDecipheriv('aes-256-gcm', d.key, d.iv); dc.setAAD(aad); dc.setAuthTag(ct.subarray(ct.length - 16)); pt = Buffer.concat([dc.update(ct.subarray(0, ct.length - 16)), dc.final()]); } catch { return { ok: false, motive: ['the ciphertext or its header was modified, or it is not for these keys'] }; } finally { d.key.fill(0); } - if (seen) seen.add(m.id); + seen.add(m.id); return { ok: true, motive: [], ivms101: JSON.parse(pt.toString('utf8')), transfer: m.transfer, from: { id: m.from.id, claims: v.claims }, messageHash: sha(Buffer.from(canonical(semnat), 'utf8')), payloadHash: sha(pt) }; } catch (e) { return { ok: false, motive: [...motive, 'the message cannot be read: ' + e.message] }; } } diff --git a/verify-layer/README.md b/verify-layer/README.md index 6642c01..50091db 100644 --- a/verify-layer/README.md +++ b/verify-layer/README.md @@ -103,14 +103,16 @@ and can be notarized. An inclusion proof carries the entry itself: the verifier envelope, so an internal node cannot be passed off as an entry. A proof names a tree by its size and root; bind it to a head you received (`--head`), not to the numbers in the proof. Two heads of the same log with no consistency proof between them mean the history was rewritten. What it does not prove, as for the chain: that the entries are true (the host writes them), or that no -other log with other heads was shown to someone else; whoever receives heads from both, or notarized heads, sees it. +other log with other heads was shown to someone else; whoever receives heads from both, or notarized heads, sees it. A head is +only as good as where you got it: without `--signer`, anyone can make two unsigned heads of an invented log that extend each +other, so both verify commands then say whether each head is signed and by which key, and that no expected signer was checked. ## Tests node proba-sidecar.mjs # 44 checks node control-negativ-sidecar.mjs # puts each guard back to its absent form and requires the named check to fail - node proba-arbore.mjs # 19: the tree (the reference roots of Certificate Transparency for 1..8 leaves, every proof up to 64 leaves, each attack) - node control-negativ-arbore.mjs # 13 guards removed in a copy, each turns its named test red + node proba-arbore.mjs # 20: the tree (the reference roots of Certificate Transparency for 1..8 leaves, every proof up to 64 leaves, each attack) + node control-negativ-arbore.mjs # 14 guards removed in a copy, each turns its named test red `proba-sidecar.mjs` records runtime, deployment and envelope entries; checks that a modified entry breaks the chain at its seq, a changed hash is caught, and nothing is appended to a broken chain; that a chain **rebuilt from genesis** passes diff --git a/verify-layer/control-negativ-arbore.mjs b/verify-layer/control-negativ-arbore.mjs index 269231e..3c2cb8d 100644 --- a/verify-layer/control-negativ-arbore.mjs +++ b/verify-layer/control-negativ-arbore.mjs @@ -22,6 +22,8 @@ const PLANTARI = [ ['semnatarul cerut nu mai trece in verificare', J, [['const s = verificaSemnaturaCap(cap, semnatar);', 'const s = verificaSemnaturaCap(cap, null);']], 'includere: intrarea 7'], ['dovada de consistenta nu mai trebuie sa porneasca de la capul vechi', J, [['if (vechi.statement.treeSize !== d.firstSize || vechi.statement.rootHash !== d.firstRoot) return', 'if (false) return']], 'ATAC: capetele inversate'], ['un jurnal rupt primeste cap', J, [['if (!v.ok) throw new Error(`tree: the log is broken', 'if (false) throw new Error(`tree: the log is broken']], 'un jurnal rupt'], + // B-25: forma publicata in 082b862 (iesirea tace despre semnatura capetelor) + ['verify-consistency tace despre semnatura capetelor', 'sidecar.mjs', [['the history up to the old head was not rewritten)${semn}', 'the history up to the old head was not rewritten)']], 'B-25'], ]; const FISIERE = [M, J, 'sidecar.mjs', 'proba-arbore.mjs']; function copie() { diff --git a/verify-layer/jurnal-arbore.mjs b/verify-layer/jurnal-arbore.mjs index 38bbbba..69302d4 100644 --- a/verify-layer/jurnal-arbore.mjs +++ b/verify-layer/jurnal-arbore.mjs @@ -84,6 +84,8 @@ export function verificaConsistenta(d, { vechi, nou, semnatar = null }) { if (vechi.statement.treeSize !== d.firstSize || vechi.statement.rootHash !== d.firstRoot) return { ok: false, motiv: 'the proof does not start at the old head' }; if (nou.statement.treeSize !== d.secondSize || nou.statement.rootHash !== d.secondRoot) return { ok: false, motiv: 'the proof does not end at the new head' }; if (!verifyConsistency(d)) return { ok: false, motiv: `the new head (${d.secondSize}) does not extend the old one (${d.firstSize}): the history was rewritten` }; - return { ok: true }; + // B-25 (2026-09-30): cine semneaza fiecare cap se intoarce si se spune; fara `semnatar`, doua capete nesemnate (sau semnate de + // oricine) care se continua trec, si cititorul trebuie sa vada asta, nu doar "EXTENDS" + return { ok: true, semnatVechi: a.semnatDe, semnatNou: b.semnatDe }; } catch (x) { return { ok: false, motiv: 'the proof cannot be read: ' + x.message }; } } diff --git a/verify-layer/proba-arbore.mjs b/verify-layer/proba-arbore.mjs index 614f646..170e03d 100644 --- a/verify-layer/proba-arbore.mjs +++ b/verify-layer/proba-arbore.mjs @@ -165,6 +165,27 @@ test('linia de comanda: tree-head, prove-inclusion, verify-inclusion (0; alt cap const w2 = run('verify-consistency', '--proof', 'c.json', '--old', 'h20.json', '--new', 'h12.json'); cere(w2.cod === 1 && /NOT consistent/.test(w2.out), w2.out); } finally { fs.rmSync(T, { recursive: true, force: true }); } }); +// B-25 (2026-09-30, revizuirea adversariala): fara --signer, doua capete NESEMNATE (pe care le poate fabrica oricine) care se continua +// ieseau "EXTENDS" fara niciun cuvant despre semnatura, desi README spunea ca iesirea o spune; verify-inclusion o spunea deja +test('B-25: verify-consistency fara --signer spune ca ambele capete sunt nesemnate si ca nu s-a cerut semnatarul; cu --signer, ca e cel asteptat', () => { + const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-arbore-b25-')); const S = path.join(AICI, 'sidecar.mjs'); + const run = (...a) => { const r = spawnSync(process.execPath, [S, ...a], { cwd: T, encoding: 'utf8' }); return { cod: r.status, out: (r.stdout || '') + (r.stderr || '') }; }; + try { + const lg = path.join(T, 'audit.log'); + fs.writeFileSync(lg, L.slice(0, 12).map((e) => JSON.stringify(e)).join('\n') + '\n'); + fs.writeFileSync(path.join(T, 'k.pem'), pem(k1)); fs.writeFileSync(path.join(T, 'p.pem'), pub(k1)); + cere(run('tree-head', '--log', lg, '--out', 'u12.json').cod === 0 && run('tree-head', '--log', lg, '--sign-key', 'k.pem', '--out', 's12.json').cod === 0, 'capete 12'); + fs.appendFileSync(lg, L.slice(12).map((e) => JSON.stringify(e)).join('\n') + '\n'); + cere(run('tree-head', '--log', lg, '--out', 'u20.json').cod === 0 && run('tree-head', '--log', lg, '--sign-key', 'k.pem', '--out', 's20.json').cod === 0, 'capete 20'); + cere(run('prove-consistency', '--log', lg, '--from', '12', '--out', 'c.json').cod === 0, 'prove-consistency'); + const u = run('verify-consistency', '--proof', 'c.json', '--old', 'u12.json', '--new', 'u20.json'); + cere(u.cod === 0 && /old head unsigned, new head unsigned/.test(u.out) && /no expected signer was checked/.test(u.out), 'nesemnate: ' + u.out); + const s = run('verify-consistency', '--proof', 'c.json', '--old', 's12.json', '--new', 's20.json', '--signer', 'p.pem'); + cere(s.cod === 0 && /both heads signed by the expected signer/.test(s.out), 'cu --signer: ' + s.out); + const r = J.verificaConsistenta(J.dovadaConsistenta(L, 12), { vechi: JSON.parse(fs.readFileSync(path.join(T, 's12.json'))), nou: JSON.parse(fs.readFileSync(path.join(T, 'u20.json'))) }); + cere(r.ok && r.semnatVechi && r.semnatNou === null, 'modulul intoarce cine a semnat fiecare cap: ' + JSON.stringify(r)); + } finally { fs.rmSync(T, { recursive: true, force: true }); } +}); console.log(`\naere-arbore: ${treceri}/${treceri + esecuri.length} cum trebuia`); process.exitCode = esecuri.length ? 1 : 0; diff --git a/verify-layer/sidecar.mjs b/verify-layer/sidecar.mjs index d19c75c..88a529c 100644 --- a/verify-layer/sidecar.mjs +++ b/verify-layer/sidecar.mjs @@ -402,7 +402,11 @@ async function main() { return r.ok ? 0 : 1; } const r = A.verificaConsistenta(citeste(get('--proof'), '--proof'), { vechi: citeste(get('--old'), '--old'), nou: citeste(get('--new'), '--new'), semnatar }); - console.log(r.ok ? 'the new head EXTENDS the old one (the history up to the old head was not rewritten)' : `NOT consistent: ${r.motiv}`); + // B-25 (2026-09-30): ca la verify-inclusion, iesirea spune cine a semnat capetele si daca s-a cerut semnatarul + const cine = (k) => (k ? `signed by key ${k.slice(0, 18)}` : 'unsigned'); + const semn = !r.ok ? '' : semnatar ? `; both heads signed by the expected signer (${r.semnatNou.slice(0, 18)})` + : `; old head ${cine(r.semnatVechi)}, new head ${cine(r.semnatNou)}${r.semnatVechi && r.semnatNou && r.semnatVechi !== r.semnatNou ? ' (DIFFERENT keys)' : ''}; no expected signer was checked (--signer): this proves only that the second head you hold extends the first one you hold`; + console.log(r.ok ? `the new head EXTENDS the old one (the history up to the old head was not rewritten)${semn}` : `NOT consistent: ${r.motiv}`); return r.ok ? 0 : 1; } catch (e) { console.log(`${cmd}: ${String(e.message).replace(/[0-9a-fA-F]{32,}/g, '').slice(0, 200)}`); return 2; } }