// 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. import crypto from 'node:crypto'; import { canonical, signText, verifyText, verifyPresentation, idOf, publicKeyObjects } from './identity.mjs'; export const KEM_ALG = 'x25519+ml-kem-768'; 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) function eVasp(pres, { registries, audience, nonce, id, now, statusLists, maxAgeS }) { const v = verifyPresentation(pres, { audience, nonce, now, trustedIssuers: registries, statusLists, maxAgeS }); const motive = v.rows.filter((r) => r.pass === false).map((r) => r.name + (r.detail ? ' (' + r.detail + ')' : '')); const stare = v.rows.find((r) => /^credential: (not revoked|status)/.test(r.name)); if (!(stare && stare.pass === true)) motive.push('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; if (!holder || holder.id !== id) motive.push(`the VASP credential belongs to ${holder && holder.id}, not to ${id}`); if (v.valid && (!v.claims || v.claims.vasp !== true)) motive.push('the credential does not say vasp: true'); return { ok: !motive.length, motive, 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, beneficiary: { id, keys, kem, claims } }. */ export function acceptBeneficiary({ binding, presentation, registries, originatorId, nonce, now = new Date(), statusLists = [], maxAgeS = 300 }) { const motive = []; const acum = new Date(now).getTime(); const S = binding && binding.statement; try { if (!S || S.kind !== 'aere-travel-rule-kem-binding') throw new Error('not an aere-travel-rule-kem-binding'); if (idOf(S.vasp.keys) !== S.vasp.id) motive.push('the binding names an id not derived from its keys'); if (!verifyText('kem-binding', canonical(S), binding.signature, S.vasp.keys)) motive.push('the binding is not signed by the VASP it names'); kemPublicObjects(S.kem); if (!(timp(S.validFrom, 'validFrom') <= acum && acum <= timp(S.validUntil, 'validUntil'))) motive.push(`the binding is valid from ${S.validFrom} until ${S.validUntil}`); } catch (e) { motive.push('the binding cannot be read: ' + e.message); return { ok: false, motive }; } const v = eVasp(presentation, { registries, audience: originatorId, nonce, id: S.vasp.id, now, statusLists, maxAgeS }); motive.push(...v.motive); return { ok: !motive.length, motive, 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. * Intoarce { ok, motive, ivms101, transfer, from, messageHash }. */ export function openMessage(m, { me, kem, registries, now = new Date(), statusLists = [], seen = null, maxAgeS = 300 }) { const motive = []; const acum = new Date(now).getTime(); try { if (!m || m.kind !== 'aere-travel-rule-message' || m.v !== 1) throw new Error('not an aere-travel-rule-message'); const semnat = { ...antet(m), ciphertext: m.ciphertext }; if (idOf(m.from.keys) !== m.from.id) motive.push('the message names an originator id not derived from its keys'); if (!verifyText('travel-rule', canonical(semnat), m.signature, m.from.keys)) motive.push('the message is not signed by the originator it names'); 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'); 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 }; const ePub = Buffer.from(m.kem.ephemeralX25519, 'base64'), ctK = Buffer.from(m.kem.mlkemCiphertext, 'base64'); if (m.kem.alg !== KEM_ALG || ePub.length !== 32) return { ok: false, motive: ['the key encapsulation is not ' + KEM_ALG] }; 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 { return { ok: false, motive: ['the key encapsulation does not open with these keys'] }; } 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 { 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); 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] }; } } /** 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). */ export function verifyReceipt(r, { message, beneficiaryId }) { const S = r && r.statement; const motive = []; try { if (!S || S.kind !== 'aere-travel-rule-receipt') throw new Error('not an aere-travel-rule-receipt'); if (S.from.id !== beneficiaryId || idOf(S.from.keys) !== S.from.id) motive.push(`the receipt is from ${S.from.id}, not from the accepted beneficiary`); if (!verifyText('travel-rule-receipt', canonical(S), r.signature, S.from.keys)) motive.push('the receipt is not signed by the beneficiary'); const semnat = { ...antet(message), ciphertext: message.ciphertext }; if (S.messageId !== message.id || S.messageHash !== sha(Buffer.from(canonical(semnat), 'utf8'))) motive.push('the receipt is for another message'); } catch (e) { motive.push('the receipt cannot be read: ' + e.message); } return { ok: !motive.length, accepted: !motive.length && !!S.accepted, motive }; } /** 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 };