- verify-consistency without --signer now says whether each head is signed and by which key, and that no expected signer was checked (verify-inclusion already did; the README said the output does) - openMessage opens nothing without a replay store (seen: an object with has/add that keeps the ids for at least maxAgeS); before, the same signed and sealed message could be opened any number of times when no store was given Tests: verify-layer tree 20/20, negative control 14/14; sidecar 44/44, 9/9; travel rule 19/19, negative control 19/19.
162 lines
14 KiB
JavaScript
162 lines
14 KiB
JavaScript
// Proba Travel Rule post-cuantic (travel-rule.mjs): doi VASP reali (chei hibride, credentiale de VASP emise de un registru, lista de
|
|
// stare), drumul intreg si fiecare atac al revizuirii ca proba numita. Offline. Plicul AIP-23 al inregistrarii se judeca si cu
|
|
// verificatorul de referinta (AERE_VERIFY_PROOF sau ../aere-proof-protocol/verify.mjs); fara el, acea proba iese NEMASURATA (cod 2).
|
|
// node proba-travel-rule.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 * as TR from './travel-rule.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 clon = (o) => JSON.parse(JSON.stringify(o));
|
|
|
|
const NOW = new Date('2026-09-30T09:00:00Z');
|
|
const reg = I.generateKeys(), regStrain = I.generateKeys();
|
|
const A = I.generateKeys(), B = I.generateKeys(), C = I.generateKeys(); // A plateste, B primeste, C e un alt VASP (sau un strain)
|
|
const kemB = TR.generateKemKeys(), kemC = TR.generateKemKeys();
|
|
const vaspCred = (vasp, idx, emitent = reg, claims = { vasp: true, name: 'VASP', lei: '5493001KJTIIGC8Y1R12' }) => I.issueCredential({ issuer: emitent, holder: vasp.public, type: 'VaspCredential',
|
|
claims, disclosable: [], validUntil: '2027-09-30T00:00:00Z', status: { list: 'urn:reg:vasps', index: idx }, now: NOW });
|
|
const credA = vaspCred(A, 1), credB = vaspCred(B, 2), credC = vaspCred(C, 3);
|
|
const LISTA = I.createStatusList({ issuer: reg, id: 'urn:reg:vasps', revoked: [3], validUntil: '2026-10-07T00:00:00Z', now: NOW }); // C revocat
|
|
const pres = (cred, vasp, audience, nonce, now = NOW) => I.present({ credential: cred.credential, disclosures: cred.disclosures, presenter: vasp, audience, nonce, now });
|
|
const IVMS = { originator: { originatorPersons: [{ naturalPerson: { name: { nameIdentifier: [{ primaryIdentifier: 'Pop', secondaryIdentifier: 'Ana' }] } } }], accountNumber: ['0xAAAA000000000000000000000000000000000001'] },
|
|
beneficiary: { beneficiaryPersons: [{ naturalPerson: { name: { nameIdentifier: [{ primaryIdentifier: 'Ionescu', secondaryIdentifier: 'Dan' }] } } }], accountNumber: ['0xBBBB000000000000000000000000000000000002'] } };
|
|
const TRANSFER = { chainId: 2800, asset: 'AERE', amount: '1500000000000000000000', beneficiaryAddress: '0xBBBB000000000000000000000000000000000002' };
|
|
const bindB = TR.bindKemKeys({ vasp: B, kem: kemB, validUntil: '2026-12-31T00:00:00Z', now: NOW });
|
|
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], seen: new Set(), ...o });
|
|
const are = (r, re) => !r.ok && r.motive.some((x) => re.test(x));
|
|
|
|
// ---------------------------------------------------------------- beneficiarul acceptat
|
|
test('initiatorul accepta beneficiarul: cheile KEM legate semnat de identitatea lui, credential de VASP de la registru, pentru initiator, nerevocat', () => {
|
|
cere(accB.ok && accB.beneficiary.id === B.id && accB.beneficiary.claims.vasp === true, accB.motive.join(' | '));
|
|
});
|
|
test('ATAC: legarea KEM semnata de alt VASP in numele lui B -> refuzata', () => {
|
|
const b = clon(bindB); b.statement.kem = kemC.public; b.signature = I.signText('kem-binding', I.canonical(b.statement), C);
|
|
const r = TR.acceptBeneficiary({ binding: b, presentation: pres(credB, B, A.id, 'n'), registries: [reg.id], originatorId: A.id, nonce: 'n', now: NOW, statusLists: [LISTA] });
|
|
cere(are(r, /not signed by the VASP it names/), r.motive.join(' | '));
|
|
});
|
|
test('ATAC: legarea KEM a lui C cu prezentarea de VASP a lui B (alta identitate) -> refuzata', () => {
|
|
const bC = TR.bindKemKeys({ vasp: C, kem: kemC, validUntil: '2026-12-31T00:00:00Z', now: NOW });
|
|
const r = TR.acceptBeneficiary({ binding: bC, presentation: pres(credB, B, A.id, 'n'), registries: [reg.id], originatorId: A.id, nonce: 'n', now: NOW, statusLists: [LISTA] });
|
|
cere(are(r, /belongs to/), r.motive.join(' | '));
|
|
});
|
|
test('ATAC: credential de VASP de la un registru in care initiatorul nu are incredere -> refuzat; credential fara vasp:true -> refuzat', () => {
|
|
const cS = vaspCred(B, 2, regStrain);
|
|
const r = TR.acceptBeneficiary({ binding: bindB, presentation: pres(cS, B, A.id, 'n'), registries: [reg.id], originatorId: A.id, nonce: 'n', now: NOW, statusLists: [LISTA] });
|
|
cere(are(r, /issuer trusted/), r.motive.join(' | '));
|
|
const cF = vaspCred(B, 2, reg, { vasp: false, name: 'X' });
|
|
const r2 = TR.acceptBeneficiary({ binding: bindB, presentation: pres(cF, B, A.id, 'n'), registries: [reg.id], originatorId: A.id, nonce: 'n', now: NOW, statusLists: [LISTA] });
|
|
cere(are(r2, /vasp: true/), r2.motive.join(' | '));
|
|
});
|
|
test('ATAC: un VASP revocat de registru nu mai primeste date; fara lista de stare, starea nejudecata e refuz, nu "nerevocat"', () => {
|
|
const bC = TR.bindKemKeys({ vasp: C, kem: kemC, validUntil: '2026-12-31T00:00:00Z', now: NOW });
|
|
const r = TR.acceptBeneficiary({ binding: bC, presentation: pres(credC, C, A.id, 'n'), registries: [reg.id], originatorId: A.id, nonce: 'n', now: NOW, statusLists: [LISTA] });
|
|
cere(are(r, /revoked/), r.motive.join(' | '));
|
|
const r2 = TR.acceptBeneficiary({ binding: bindB, presentation: pres(credB, B, A.id, 'n'), registries: [reg.id], originatorId: A.id, nonce: 'n', now: NOW, statusLists: [] });
|
|
cere(are(r2, /status is not judged/), r2.motive.join(' | '));
|
|
});
|
|
test('ATAC: prezentarea beneficiarului facuta pentru alt initiator (reluata) -> refuzata; legarea expirata -> refuzata', () => {
|
|
const r = TR.acceptBeneficiary({ binding: bindB, presentation: pres(credB, B, C.id, 'n'), registries: [reg.id], originatorId: A.id, nonce: 'n', now: NOW, statusLists: [LISTA] });
|
|
cere(are(r, /made for/), r.motive.join(' | '));
|
|
const tarziu = new Date('2027-01-02T00:00:00Z');
|
|
const r2 = TR.acceptBeneficiary({ binding: bindB, presentation: pres(credB, B, A.id, 'n', tarziu), registries: [reg.id], originatorId: A.id, nonce: 'n', now: tarziu, statusLists: [] });
|
|
cere(are(r2, /binding is valid from/), r2.motive.join(' | '));
|
|
});
|
|
|
|
// ---------------------------------------------------------------- mesajul
|
|
const M = sigileaza();
|
|
test('drumul intreg: beneficiarul deschide mesajul; datele IVMS101 si transferul sunt cele trimise; nimic din ele nu e in clar in mesaj', () => {
|
|
const o = deschide(M);
|
|
cere(o.ok && I.canonical(o.ivms101) === I.canonical(IVMS) && o.transfer.amount === TRANSFER.amount && o.from.id === A.id, o.motive.join(' | '));
|
|
const s = JSON.stringify(M); cere(!s.includes('Ionescu') && !s.includes('Pop') && !s.includes('0xAAAA000000000000000000000000000000000001'), 'date personale in clar in mesaj');
|
|
});
|
|
test('ATAC: un octet al cifrului schimbat -> refuzat (semnatura; si fara ea, GCM)', () => {
|
|
const m = clon(M); const b = Buffer.from(m.ciphertext, 'base64'); b[5] ^= 1; m.ciphertext = b.toString('base64');
|
|
cere(are(deschide(m), /not signed by the originator/), 'trecut');
|
|
m.signature = I.signText('travel-rule', I.canonical({ ...(({ ciphertext, signature, fromPresentation, ...h }) => h)(m), ciphertext: m.ciphertext }), A);
|
|
cere(are(deschide(m), /modified/), 'cifrul atins, resemnat, a trecut de GCM');
|
|
});
|
|
test('ATAC: suma din antet schimbata si mesajul resemnat de initiator (cifrul facut pentru alta suma) -> GCM il refuza (antetul e AAD)', () => {
|
|
const m = clon(M); m.transfer.amount = '1';
|
|
m.signature = I.signText('travel-rule', I.canonical({ ...(({ ciphertext, signature, fromPresentation, ...h }) => h)(m), ciphertext: m.ciphertext }), A);
|
|
cere(are(deschide(m), /modified/), 'antetul schimbat a trecut');
|
|
});
|
|
test('ATAC: mesaj semnat de un strain in numele lui A -> refuzat; semnatura lui A de alt scop (kem-binding) peste acelasi text -> refuzat', () => {
|
|
const m = clon(M); const text = I.canonical({ ...(({ ciphertext, signature, fromPresentation, ...h }) => h)(m), ciphertext: m.ciphertext });
|
|
m.signature = I.signText('travel-rule', text, C); cere(are(deschide(m), /not signed by the originator/), 'strainul a trecut');
|
|
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], 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 } });
|
|
const k2 = { public: kemB.public }; Object.defineProperty(k2, 'privat', { value: { x25519: kemC.privat.x25519, mlkem768: kemB.privat.mlkem768 } });
|
|
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');
|
|
cere(are(deschide(M, { now: new Date(NOW.getTime() + 301000) }), /outside 300 s/), 'mesajul vechi a trecut');
|
|
});
|
|
test('ATAC: initiatorul nedovedit ca VASP (prezentare pentru alt beneficiar, sau a altui VASP, sau cu alt nonce decat id-ul mesajului) -> refuzat', () => {
|
|
const m1 = sigileaza({ presentation: pres(credA, A, C.id, MID) }); cere(are(deschide(m1), /originator VASP: .*made for/), 'alt beneficiar a trecut');
|
|
const m2 = sigileaza({ presentation: pres(credC, C, B.id, MID) }); cere(are(deschide(m2), /originator VASP: .*belongs to/), 'alt VASP a trecut');
|
|
const m3 = sigileaza({ presentation: pres(credA, A, B.id, 'alt-nonce') }); cere(are(deschide(m3), /originator VASP: .*nonce/), 'alt nonce a trecut');
|
|
});
|
|
|
|
// ---------------------------------------------------------------- confirmarea si inregistrarea
|
|
const O = deschide(M);
|
|
const R = TR.acknowledge({ opened: O, message: M, me: B, now: NOW });
|
|
test('confirmarea: semnata de B, pentru acest mesaj, verificata de initiator', () => {
|
|
const v = TR.verifyReceipt(R, { message: M, beneficiaryId: B.id }); cere(v.ok && v.accepted, v.motive.join(' | '));
|
|
});
|
|
test('ATAC: confirmare semnata de C in numele lui B -> refuzata; confirmarea altui mesaj -> refuzata', () => {
|
|
const r = clon(R); r.signature = I.signText('travel-rule-receipt', I.canonical(r.statement), C);
|
|
cere(!TR.verifyReceipt(r, { message: M, beneficiaryId: B.id }).ok, 'semnatura lui C a trecut');
|
|
const rc = TR.acknowledge({ opened: O, message: M, me: C, now: NOW }); // o confirmare valida, dar a lui C, nu a beneficiarului acceptat
|
|
cere(!TR.verifyReceipt(rc, { message: M, beneficiaryId: B.id }).ok, 'confirmarea proprie a lui C a trecut drept a lui B');
|
|
const M2 = sigileaza({ messageId: 'urn:uuid:99999999-2222-4333-8444-555555555555', presentation: pres(credA, A, B.id, 'urn:uuid:99999999-2222-4333-8444-555555555555') });
|
|
cere(!TR.verifyReceipt(R, { message: M2, beneficiaryId: B.id }).ok, 'confirmarea altui mesaj a trecut');
|
|
});
|
|
test('inregistrarea (plic AIP-23 compliance) nu poarta date personale nici suma; verifica la verificatorul AIP-23, rescrisa nu', () => {
|
|
const e = TR.travelRuleRecord({ message: M, receipt: R, buildProof, createdAt: NOW.toISOString() });
|
|
const s = JSON.stringify(e);
|
|
for (const x of ['Ionescu', 'Pop', '0xBBBB000000000000000000000000000000000002', TRANSFER.amount, A.id, B.id]) cere(!s.includes(x), 'inregistrarea contine ' + x.slice(0, 24));
|
|
cere(e.statement.result === 'delivered-and-acknowledged' && e.statement.policy === 'travel-rule/fatf-r16', JSON.stringify(e.statement));
|
|
if (!fs.existsSync(VERIFY)) { sarite++; console.log(` SARIT plicul AIP-23: verificatorul nu e la ${VERIFY}`); return 'SARIT'; }
|
|
const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-tr-'));
|
|
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 x = clon(e); x.statement.result = 'delivered';
|
|
cere(v(e) === 'VALID' && v(x) !== 'VALID', `${v(e)} ${v(x)}`);
|
|
} finally { fs.rmSync(T, { recursive: true, force: true }); }
|
|
});
|
|
test('cheile KEM: exportKemKeys -> importKemKeys deschide acelasi mesaj; un fisier cu partea publica a altcuiva e refuzat', () => {
|
|
const k = TR.importKemKeys(clon(TR.exportKemKeys(kemB))); cere(deschide(M, { kem: k }).ok, 'cheile importate nu deschid');
|
|
const j = clon(TR.exportKemKeys(kemB)); j.public = kemC.public;
|
|
let m = null; try { TR.importKemKeys(j); } catch (e) { m = e.message; } cere(/not those of its private keys/.test(m || ''), 'acceptat');
|
|
});
|
|
|
|
console.log(`\naere-travel-rule: ${treceri}/${treceri + esecuri.length} cum trebuia${sarite ? `, ${sarite} NEMASURATE (fara verificatorul AIP-23)` : ''}`);
|
|
process.exitCode = esecuri.length ? 1 : (sarite ? 2 : 0);
|