// AERE Identity, Travel Rule post-cuantic (roadmap master punctul 13, pista B, 2026-09-30): datele initiatorului si ale // beneficiarului unui transfer (un obiect IVMS101) trimise de VASP-ul care plateste VASP-ului care primeste, CIFRATE numai pentru el // (KEM hibrid X25519 + ML-KEM-768, ambele secrete cerute, HKDF-SHA256, AES-256-GCM), SEMNATE de initiator (Ed25519 + ML-DSA-65), legate // de transfer (lant, activ, suma, adresa, tranzactia) si CONFIRMATE semnat de beneficiar. Fiecare parte dovedeste ca e VASP cu o // prezentare a unui credential AERE Identity emis de un registru in care celalalt are incredere, legata de el (public = id-ul lui). // // Drumul: (1) beneficiarul leaga cheile lui KEM de identitatea lui (`bindKemKeys`, semnat) si le da impreuna cu o prezentare a // credentialului lui de VASP; (2) initiatorul le verifica (`acceptBeneficiary`) si sigileaza mesajul (`sealMessage`), cu prezentarea // lui atasata; (3) beneficiarul il deschide (`openMessage`): semnatura, VASP-ul initiatorului, destinatarul, prospetimea, reluarea, // apoi descifrarea; (4) confirmarea semnata (`acknowledge` / `verifyReceipt`); (5) inregistrarea fara date personale (plic AIP-23 // `compliance`, `travelRuleRecord`). // // Ce NU face: nu valideaza schema IVMS101 (poarta obiectul asa cum e dat, cere doar `originator` si `beneficiary`); nu descopera VASP-ul // beneficiarului dupa o adresa (asta e treaba unui protocol de descoperire); nu spune ca un transfer e legal, ci ca datele au ajuns, // cifrate, la VASP-ul numit si ca acesta a confirmat. Momentele sunt ale celor care semneaza, judecate pe ceasul celui care verifica. // // REGULILE NUMITE (2026-10-18, pentru suita de conformitate tools/aere-conformance): fiecare refuz al celor trei verificatoare // (`acceptBeneficiary`, `openMessage`, `verifyReceipt`) numeste regula lui, din REGULI; fiecare verificare e un rand { rule, pass, detail } // si poarta marcajul /*PAZA:*/ pe conditia ei BUNA (controlul negativ al suitei o face adevarata intr-o copie). Rezultatul poarta, // pe langa `ok` si `motive` (textele de ieri, neschimbate), `rules` (id-urile refuzate, sortate) si `rows`. Prezentarea de VASP e judecata // de verificatorul de identitate (identity.mjs, inrolat cu regulile lui); aici refuzurile ei poarta o singura regula, `vasp-presentation`. // Verificarile antetului unui mesaj se fac toate; la orice refuz al lor mesajul NU se mai deschide (incapsularea si cifrul nu se judeca). import crypto from 'node:crypto'; import { canonical, signText, verifyText, verifyPresentation, idOf, publicKeyObjects } from './identity.mjs'; export const KEM_ALG = 'x25519+ml-kem-768'; /** regulile pe care le poate numi un refuz (binding-*: acceptBeneficiary; vasp-*: prezentarea de VASP, in amandoua; message-*, replay*, kem-*, ciphertext: openMessage; receipt-*: verifyReceipt) */ export const REGULI = ['binding-form', 'binding-id', 'binding-signature', 'binding-window', 'vasp-presentation', 'vasp-status', 'vasp-identity', 'vasp-claim', 'message-form', 'message-id', 'message-signature', 'message-recipient', 'message-kem-keys', 'message-freshness', 'replay-store', 'replay', 'kem-alg', 'kem-open', 'ciphertext', 'receipt-form', 'receipt-from', 'receipt-signature', 'receipt-message']; /** regulile dupa al caror refuz verificatorul s-a oprit (ce nu poate citi): celelalte verificari nu s-au mai facut */ export const OPRIRI = ['binding-form', 'message-form', 'receipt-form']; // randurile unei verificari: ok(regula, conditia buna, motivul) -> conditia; gata() -> { ok, motive, rules, rows } function colector() { const rows = []; const ok = (rule, pass, detail = '') => { if (!REGULI.includes(rule)) throw new Error('travel rule: undeclared rule ' + rule); rows.push({ rule, pass: !!pass, detail: pass ? '' : detail }); return !!pass; }; const gata = (extra = {}) => { const rele = rows.filter((r) => !r.pass); return { ok: !rele.length, motive: rele.map((r) => r.detail), rules: [...new Set(rele.map((r) => r.rule))].sort(), rows, ...extra }; }; return { ok, gata, rows }; } const sha = (b) => '0x' + crypto.createHash('sha256').update(b).digest('hex'); const b64 = (b) => Buffer.from(b).toString('base64'); const SPKI_X25519 = Buffer.from('302a300506032b656e032100', 'hex'); const DATA = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/; const timp = (s, ce) => { if (typeof s !== 'string' || !DATA.test(s) || Number.isNaN(Date.parse(s))) throw new Error(`travel rule: ${ce} is not an RFC 3339 UTC time`); return Date.parse(s); }; function cheieKem(b64s, tip) { if (typeof b64s !== 'string') throw new Error(`travel rule: the ${tip} public key is missing`); const k = crypto.createPublicKey({ key: Buffer.from(b64s, 'base64'), format: 'der', type: 'spki' }); if (k.asymmetricKeyType !== tip) throw new Error(`travel rule: the ${tip} public key is a ${k.asymmetricKeyType} key`); if (k.export({ type: 'spki', format: 'der' }).toString('base64') !== b64s) throw new Error(`travel rule: the ${tip} public key is not in its canonical form`); return k; } function kemPublicObjects(pub) { if (!pub || pub.alg !== KEM_ALG || Object.keys(pub).some((k) => !['alg', 'x25519', 'mlkem768'].includes(k))) throw new Error(`travel rule: KEM public keys must be ${KEM_ALG}`); return { x25519: cheieKem(pub.x25519, 'x25519'), mlkem768: cheieKem(pub.mlkem768, 'ml-kem-768') }; } /** O pereche KEM hibrida noua (partea privata nu e enumerabila). */ export function generateKemKeys() { const x = crypto.generateKeyPairSync('x25519'), m = crypto.generateKeyPairSync('ml-kem-768'); const k = { public: { alg: KEM_ALG, x25519: x.publicKey.export({ type: 'spki', format: 'der' }).toString('base64'), mlkem768: m.publicKey.export({ type: 'spki', format: 'der' }).toString('base64') } }; Object.defineProperty(k, 'privat', { value: { x25519: x.privateKey, mlkem768: m.privateKey }, enumerable: false }); return k; } export function exportKemKeys(k) { return { v: 1, kind: 'aere-travel-rule-kem-keys', public: k.public, private: { x25519: k.privat.x25519.export({ type: 'pkcs8', format: 'der' }).toString('base64'), mlkem768: k.privat.mlkem768.export({ type: 'pkcs8', format: 'der' }).toString('base64') } }; } export function importKemKeys(j) { if (!j || j.kind !== 'aere-travel-rule-kem-keys') throw new Error('travel rule: not an aere-travel-rule-kem-keys file'); const x = crypto.createPrivateKey({ key: Buffer.from(String(j.private.x25519), 'base64'), format: 'der', type: 'pkcs8' }); const m = crypto.createPrivateKey({ key: Buffer.from(String(j.private.mlkem768), 'base64'), format: 'der', type: 'pkcs8' }); const pub = { alg: KEM_ALG, x25519: crypto.createPublicKey(x).export({ type: 'spki', format: 'der' }).toString('base64'), mlkem768: crypto.createPublicKey(m).export({ type: 'spki', format: 'der' }).toString('base64') }; if (canonical(pub) !== canonical(j.public)) throw new Error('travel rule: the public KEM keys in the file are not those of its private keys'); const k = { public: pub }; Object.defineProperty(k, 'privat', { value: { x25519: x, mlkem768: m }, enumerable: false }); return k; } /** VASP-ul isi leaga cheile KEM de identitatea lui, semnat, cu o fereastra de valabilitate. */ export function bindKemKeys({ vasp, kem, validUntil, now = new Date() }) { kemPublicObjects(kem.public || kem); const statement = { v: 1, kind: 'aere-travel-rule-kem-binding', vasp: { id: vasp.id, keys: vasp.public }, kem: kem.public || kem, validFrom: new Date(now).toISOString(), validUntil }; timp(validUntil, 'validUntil'); return { statement, signature: signText('kem-binding', canonical(statement), vasp) }; } // prezentarea credentialului de VASP: valida, de la un registru de incredere, facuta pentru `audience`, cu `nonce`, a detinatorului `id`, // cu afirmatia vasp:true si starea judecata (un VASP revocat nu mai primeste date). `ok` e colectorul celui care cheama; `prefix` sta in // fata fiecarui motiv (openMessage scrie 'originator VASP: ') function eVasp(pres, { registries, audience, nonce, id, now, statusLists, maxAgeS }, ok, prefix = '') { const v = verifyPresentation(pres, { audience, nonce, now, trustedIssuers: registries, statusLists, maxAgeS }); // 2026-10-18, gasit de randul intrarilor malformate al suitei de conformitate: fara publicul sau nonce-ul verificatorului (originatorId, // nonce lipsa) verifyPresentation le lasa NEJUDECATE si ramane valid, iar beneficiarul iesea acceptat cu o prezentare reluabila; aici // o prezentare al carei public sau nonce nu a fost judecat e refuzata, nu primita pe incredere const nejudecate = v.rows.filter((r) => r.pass === null && (r.unjudged === 'audience' || r.unjudged === 'nonce')); if (!((v.valid && nejudecate.length === 0) /*PAZA:vasp-presentation*/)) { for (const r of [...v.rows.filter((r) => r.pass === false), ...nejudecate]) ok('vasp-presentation', false, prefix + r.name + (r.detail ? ' (' + r.detail + ')' : '')); } else ok('vasp-presentation', true); const stare = v.rows.find((r) => /^credential: (not revoked|status)/.test(r.name)); ok('vasp-status', !!(stare && stare.pass === true) /*PAZA:vasp-status*/, prefix + 'the VASP credential status is not judged: ' + (stare ? stare.detail || 'not judged' : 'no status row')); const holder = pres && pres.credential && pres.credential.statement && pres.credential.statement.holder; ok('vasp-identity', !!(holder && holder.id === id) /*PAZA:vasp-identity*/, prefix + `the VASP credential belongs to ${holder && holder.id}, not to ${id}`); ok('vasp-claim', (!v.valid || !!(v.claims && v.claims.vasp === true)) /*PAZA:vasp-claim*/, prefix + 'the credential does not say vasp: true'); return { claims: v.valid ? v.claims : null }; } /** * Initiatorul accepta un beneficiar: legarea cheilor KEM semnata de identitatea lui, valabila acum, si prezentarea credentialului lui * de VASP de la un registru de incredere, facuta pentru initiator (`audience` = id-ul initiatorului) cu nonce-ul lui, a ACELEIASI * identitati. Intoarce { ok, motive, rules, rows, beneficiary: { id, keys, kem, claims } }. */ export function acceptBeneficiary({ binding, presentation, registries, originatorId, nonce, now = new Date(), statusLists = [], maxAgeS = 300 }) { const C = colector(); const acum = new Date(now).getTime(); const S = binding && binding.statement; try { if (!((!!S && S.kind === 'aere-travel-rule-kem-binding') /*PAZA:binding-form*/)) throw new Error('not an aere-travel-rule-kem-binding'); C.ok('binding-id', idOf(S.vasp.keys) === S.vasp.id /*PAZA:binding-id*/, 'the binding names an id not derived from its keys'); C.ok('binding-signature', verifyText('kem-binding', canonical(S), binding.signature, S.vasp.keys) /*PAZA:binding-signature*/, 'the binding is not signed by the VASP it names'); kemPublicObjects(S.kem); C.ok('binding-window', (timp(S.validFrom, 'validFrom') <= acum && acum <= timp(S.validUntil, 'validUntil')) /*PAZA:binding-window*/, `the binding is valid from ${S.validFrom} until ${S.validUntil}`); } catch (e) { if (!(false /*PAZA:binding-form*/)) C.ok('binding-form', false, 'the binding cannot be read: ' + e.message); return C.gata(); } const v = eVasp(presentation, { registries, audience: originatorId, nonce, id: S.vasp.id, now, statusLists, maxAgeS }, C.ok); return C.gata({ beneficiary: { id: S.vasp.id, keys: S.vasp.keys, kem: S.kem, claims: v.claims } }); } const TRANSFER = ['chainId', 'asset', 'amount', 'beneficiaryAddress']; function normalTransfer(t) { if (!t || typeof t !== 'object') throw new Error('travel rule: a transfer is required'); for (const k of TRANSFER) if (t[k] == null || t[k] === '') throw new Error(`travel rule: transfer.${k} is required`); const extra = Object.keys(t).filter((k) => ![...TRANSFER, 'txHash'].includes(k)); if (extra.length) throw new Error('travel rule: unknown transfer field ' + extra.join(', ')); if (!/^[0-9]+$/.test(String(t.amount))) throw new Error('travel rule: transfer.amount is a decimal integer in the asset\'s smallest unit'); return { chainId: Number(t.chainId), asset: String(t.asset), amount: String(t.amount), beneficiaryAddress: String(t.beneficiaryAddress), txHash: t.txHash ? String(t.txHash) : null }; } // antetul mesajului fara cifru si semnatura: e AAD-ul cifrului si, cu cifrul, textul semnat function antet(m) { const { ciphertext, signature, fromPresentation, ...h } = m; return h; } function derive(ssK, ssX, transcript) { const ikm = Buffer.concat([ssK, ssX]); const okm = Buffer.from(crypto.hkdfSync('sha256', ikm, crypto.createHash('sha256').update(transcript).digest(), Buffer.from('aere-travel-rule/v1/aes-256-gcm'), 44)); ikm.fill(0); return { key: okm.subarray(0, 32), iv: okm.subarray(32, 44) }; } /** * Initiatorul sigileaza datele IVMS101 pentru beneficiarul acceptat. `presentation` e prezentarea credentialului lui de VASP, facuta * pentru beneficiar (audience = id-ul beneficiarului) cu nonce = id-ul mesajului (`messageId`, dat sau generat). */ export function sealMessage({ from, beneficiary, ivms101, transfer, presentation, messageId = 'urn:uuid:' + crypto.randomUUID(), now = new Date() }) { if (!from || !from.privat) throw new Error('travel rule: sealing needs the originator VASP\'s private keys'); if (!ivms101 || typeof ivms101 !== 'object' || !ivms101.originator || !ivms101.beneficiary) throw new Error('travel rule: the IVMS101 object needs originator and beneficiary'); const tr = normalTransfer(transfer); const k = kemPublicObjects(beneficiary.kem); const eph = crypto.generateKeyPairSync('x25519'); const ePub = eph.publicKey.export({ type: 'spki', format: 'der' }).subarray(SPKI_X25519.length); const ssX = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: k.x25519 }); const { sharedKey: ssK, ciphertext: ctK } = crypto.encapsulate(k.mlkem768); const h = { v: 1, kind: 'aere-travel-rule-message', id: messageId, from: { id: from.id, keys: from.public }, to: { id: beneficiary.id, kem: beneficiary.kem }, transfer: tr, createdAt: new Date(now).toISOString(), kem: { alg: KEM_ALG, ephemeralX25519: b64(ePub), mlkemCiphertext: b64(ctK) } }; const aad = Buffer.from(canonical(h), 'utf8'); const d = derive(ssK, ssX, aad); ssX.fill(0); ssK.fill(0); const c = crypto.createCipheriv('aes-256-gcm', d.key, d.iv); c.setAAD(aad); const ct = Buffer.concat([c.update(Buffer.from(canonical(ivms101), 'utf8')), c.final(), c.getAuthTag()]); d.key.fill(0); const m = { ...h, ciphertext: b64(ct) }; return { ...m, fromPresentation: presentation, signature: signText('travel-rule', canonical(m), from) }; } /** * 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, rules, rows, ivms101, transfer, from, messageHash }. Un ceas al verificatorului care nu se poate citi e refuz * (`message-freshness`), nu un NaN care trece. */ export function openMessage(m, { me, kem, registries, now = new Date(), statusLists = [], seen = null, maxAgeS = 300 }) { const C = colector(); const acum = new Date(now).getTime(); try { if (!((!!m && m.kind === 'aere-travel-rule-message' && m.v === 1) /*PAZA:message-form*/)) throw new Error('not an aere-travel-rule-message'); const semnat = { ...antet(m), ciphertext: m.ciphertext }; C.ok('message-id', idOf(m.from.keys) === m.from.id /*PAZA:message-id*/, 'the message names an originator id not derived from its keys'); C.ok('message-signature', verifyText('travel-rule', canonical(semnat), m.signature, m.from.keys) /*PAZA:message-signature*/, 'the message is not signed by the originator it names'); C.ok('message-recipient', m.to.id === me /*PAZA:message-recipient*/, `the message is for ${m.to.id}, not for ${me}`); C.ok('message-kem-keys', canonical(m.to.kem) === canonical(kem.public) /*PAZA:message-kem-keys*/, 'the message is sealed to other KEM keys'); C.ok('message-freshness', (Number.isFinite(acum) && Math.abs(acum - timp(m.createdAt, 'createdAt')) <= maxAgeS * 1000) /*PAZA:message-freshness*/, `the message was made at ${m.createdAt}, outside ${maxAgeS} s of this clock`); if (C.ok('replay-store', (!!seen && typeof seen.has === 'function' && typeof seen.add === 'function') /*PAZA:replay-store*/, 'no replay store was given (seen: a set of the message ids already received, kept at least maxAgeS)')) C.ok('replay', !seen.has(m.id) /*PAZA:replay*/, '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 }, C.ok, 'originator VASP: '); if (!C.gata().ok) return C.gata(); const ePub = Buffer.from(m.kem.ephemeralX25519, 'base64'), ctK = Buffer.from(m.kem.mlkemCiphertext, 'base64'); if (!C.ok('kem-alg', (m.kem.alg === KEM_ALG && ePub.length === 32) /*PAZA:kem-alg*/, 'the key encapsulation is not ' + KEM_ALG)) return C.gata(); let ssX, ssK; try { const { x25519: skX, mlkem768: skK } = kem.privat; ssX = crypto.diffieHellman({ privateKey: skX, publicKey: crypto.createPublicKey({ key: Buffer.concat([SPKI_X25519, ePub]), format: 'der', type: 'spki' }) }); ssK = crypto.decapsulate(skK, ctK); } catch { if (!(false /*PAZA:kem-open*/)) { C.ok('kem-open', false, 'the key encapsulation does not open with these keys'); return C.gata(); } } const aad = Buffer.from(canonical(antet(m)), 'utf8'); const d = derive(ssK, ssX, aad); ssX.fill(0); ssK.fill(0); const ct = Buffer.from(m.ciphertext, 'base64'); let pt; 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 { if (!(false /*PAZA:ciphertext*/)) { C.ok('ciphertext', false, 'the ciphertext or its header was modified, or it is not for these keys'); return C.gata(); } } finally { d.key.fill(0); } C.ok('kem-open', true); C.ok('ciphertext', true); seen.add(m.id); return C.gata({ 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) { if (!(false /*PAZA:message-form*/)) C.ok('message-form', false, 'the message cannot be read: ' + e.message); return C.gata(); } } /** Confirmarea semnata a beneficiarului: ce mesaj (hash), ce continut (hash-ul textului clar), acceptat sau nu si de ce. */ export function acknowledge({ opened, message, me, accepted = true, reason = null, now = new Date() }) { const statement = { v: 1, kind: 'aere-travel-rule-receipt', messageId: message.id, messageHash: opened.messageHash, payloadHash: opened.payloadHash, from: { id: me.id, keys: me.public }, to: message.from.id, accepted: !!accepted, ...(reason ? { reason: String(reason) } : {}), at: new Date(now).toISOString() }; return { statement, signature: signText('travel-rule-receipt', canonical(statement), me) }; } /** Initiatorul verifica o confirmare: semnata de beneficiarul acceptat, pentru ACEST mesaj (hash-ul celui trimis). Intoarce { ok, accepted, motive, rules, rows }. */ export function verifyReceipt(r, { message, beneficiaryId }) { const S = r && r.statement; const C = colector(); try { if (!((!!S && S.kind === 'aere-travel-rule-receipt') /*PAZA:receipt-form*/)) throw new Error('not an aere-travel-rule-receipt'); C.ok('receipt-from', (S.from.id === beneficiaryId && idOf(S.from.keys) === S.from.id) /*PAZA:receipt-from*/, `the receipt is from ${S.from.id}, not from the accepted beneficiary`); C.ok('receipt-signature', verifyText('travel-rule-receipt', canonical(S), r.signature, S.from.keys) /*PAZA:receipt-signature*/, 'the receipt is not signed by the beneficiary'); const semnat = { ...antet(message), ciphertext: message.ciphertext }; C.ok('receipt-message', (S.messageId === message.id && S.messageHash === sha(Buffer.from(canonical(semnat), 'utf8'))) /*PAZA:receipt-message*/, 'the receipt is for another message'); } catch (e) { if (!(false /*PAZA:receipt-form*/)) C.ok('receipt-form', false, 'the receipt cannot be read: ' + e.message); } const g = C.gata(); return { ...g, accepted: g.ok && !!(S && S.accepted) }; } /** Inregistrarea schimbului fara date personale (plic AIP-23 `compliance`): transferul prin hash, rezultatul, digestul mesajului. */ export function travelRuleRecord({ message, receipt, buildProof, createdAt = new Date().toISOString() }) { const t = message.transfer; const subject = 'transfer:' + sha(Buffer.from(canonical({ chainId: t.chainId, asset: t.asset, txHash: t.txHash, from: message.from.id, to: message.to.id, id: message.id }), 'utf8')).slice(2, 42); return buildProof('compliance', { subject, policy: 'travel-rule/fatf-r16', result: receipt && receipt.statement.accepted ? 'delivered-and-acknowledged' : 'delivered', evidenceHash: sha(Buffer.from(canonical({ message: antet(message), receipt: receipt ? receipt.statement : null }), 'utf8')), createdAt }); } export { publicKeyObjects };