aere-quantum/identity/travel-rule.mjs
Aere Network 8f5f0bd00d verify-layer: verify-consistency names the signer of each head (or unsigned); identity: Travel Rule requires a replay store
- 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.
2026-09-30 10:31:23 +03:00

206 lines
17 KiB
JavaScript

// 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.
* 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 }) {
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 || 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 };
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); }
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 };