identity: the Travel Rule between VASPs, post-quantum - IVMS101 data sealed for the receiving VASP (X25519 + ML-KEM-768), signed, bound to the transfer and acknowledged; both VASPs proven by registry credentials
This commit is contained in:
parent
154c424e94
commit
f75c33454a
@ -15,7 +15,7 @@ notarization command of the verification layer, and the agents' x402 wallet (EIP
|
|||||||
| [`readiness/`](readiness/) | the post-quantum readiness scanner of a public hostname: real TLS handshakes (hybrid only, hybrid preferred, TLS 1.2), HSTS, the certificate; no connection to an address not proven public; a rate limit per client that X-Forwarded-For cannot bypass |
|
| [`readiness/`](readiness/) | the post-quantum readiness scanner of a public hostname: real TLS handshakes (hybrid only, hybrid preferred, TLS 1.2), HSTS, the certificate; no connection to an address not proven public; a rate limit per client that X-Forwarded-For cannot bypass |
|
||||||
| [`control-plane/`](control-plane/) | from findings to a finished migration: a prioritized plan from the inventory and the scanner, its execution through the gateway, KMS and PKI with consent per action and a measured proof afterwards, recipes for the servers the products do not touch and a rescan that judges them, a compliance report against NIST IR 8547, the EU roadmap and optionally CNSA 2.0, and a console that checks it all again |
|
| [`control-plane/`](control-plane/) | from findings to a finished migration: a prioritized plan from the inventory and the scanner, its execution through the gateway, KMS and PKI with consent per action and a measured proof afterwards, recipes for the servers the products do not touch and a rescan that judges them, a compliance report against NIST IR 8547, the EU roadmap and optionally CNSA 2.0, and a console that checks it all again |
|
||||||
| [`agents/`](agents/) | limits an AI agent cannot break unseen: a post-quantum identity (ML-DSA-65), a policy (spending per time window, allowed tools and recipients, which actions need human approval), a signed ledger of every action judged against the policy, approvals and revocation signed by people, and a verifier that re-runs the policy over the whole ledger without trusting the agent; two branches of one ledger are a proof of equivocation anyone can check; and a wallet that pays over x402 only what the agent's ledger records and its policy allows, either holding the payment key for the owner or co-signing from a 2-of-2 contract wallet that neither the agent nor the owner can spend alone; both run on the public testnet with their evidence; and the chain as the witness of a ledger: a notarized head, read through the AIP-23 verifier under a post-quantum certified anchor, bounds entry times from below (a backdated entry is caught) |
|
| [`agents/`](agents/) | limits an AI agent cannot break unseen: a post-quantum identity (ML-DSA-65), a policy (spending per time window, allowed tools and recipients, which actions need human approval), a signed ledger of every action judged against the policy, approvals and revocation signed by people, and a verifier that re-runs the policy over the whole ledger without trusting the agent; two branches of one ledger are a proof of equivocation anyone can check; and a wallet that pays over x402 only what the agent's ledger records and its policy allows, either holding the payment key for the owner or co-signing from a 2-of-2 contract wallet that neither the agent nor the owner can spend alone; both run on the public testnet with their evidence; and the chain as the witness of a ledger: a notarized head, read through the AIP-23 verifier under a post-quantum certified anchor, bounds entry times from below (a backdated entry is caught) |
|
||||||
| [`identity/`](identity/) | post-quantum credentials: an issuer signs claims (hybrid Ed25519 + ML-DSA-65, both required), the holder shows only the claims it picks (selective disclosure in the manner of SD-JWT, RFC 9901, in a format of its own), bound to the verifier's audience and nonce; delegation to a phone, an agent or a ten-minute session key that can only narrow; revocation and an issuer status list in the manner of W3C Bitstring Status List; all checked offline from the files; and compliance without surveillance: a verifier's policy (issuers, required claims) judged on a presentation, recorded as an AIP-23 envelope that carries no personal data |
|
| [`identity/`](identity/) | post-quantum credentials: an issuer signs claims (hybrid Ed25519 + ML-DSA-65, both required), the holder shows only the claims it picks (selective disclosure in the manner of SD-JWT, RFC 9901, in a format of its own), bound to the verifier's audience and nonce; delegation to a phone, an agent or a ten-minute session key that can only narrow; revocation and an issuer status list in the manner of W3C Bitstring Status List; all checked offline from the files; and compliance without surveillance: a verifier's policy (issuers, required claims) judged on a presentation, recorded as an AIP-23 envelope that carries no personal data; and the Travel Rule between VASPs, the IVMS101 data sealed for the receiving VASP with a hybrid X25519 + ML-KEM-768 key encapsulation, signed, bound to the transfer and acknowledged |
|
||||||
|
|
||||||
Each component's README says what it is **not** and what is **not measured**. No third party has reviewed any of them.
|
Each component's README says what it is **not** and what is **not measured**. No third party has reviewed any of them.
|
||||||
|
|
||||||
@ -35,7 +35,7 @@ test, counts as a failure of the control. Results measured on 2026-09-29 (Node.j
|
|||||||
| proof-kinds | 24/24 with the same verifier (`AERE_VERIFY_PROOF=... node proba-proof-kinds.mjs`) | six negative controls inside the test |
|
| proof-kinds | 24/24 with the same verifier (`AERE_VERIFY_PROOF=... node proba-proof-kinds.mjs`) | six negative controls inside the test |
|
||||||
| readiness | 6/6 (`node proba-adrese-private.mjs`: the private-address rules, and a local listener no scan may touch) | the rate limit and the queue bound are tested where the service runs, not here (its README says so) |
|
| readiness | 6/6 (`node proba-adrese-private.mjs`: the private-address rules, and a local listener no scan may touch) | the rate limit and the queue bound are tested where the service runs, not here (its README says so) |
|
||||||
| control-plane | planner 30/30, command line 9/9, execution 30/30 on real products started locally, remediation 33/33 on real TLS servers, compliance report 27/27 (with the AIP-23 verifier), console 8/8; the console viewer in a real Chromium, phone and desktop, 26/26 (`node proba-consola-web.mjs`, measured 2026-09-30; needs `playwright-core` and a Chromium, otherwise it exits 2) | remediation 7/7, compliance report 3/3 in this repository; viewer 8/8 (`node control-negativ-consola-web.mjs`) |
|
| control-plane | planner 30/30, command line 9/9, execution 30/30 on real products started locally, remediation 33/33 on real TLS servers, compliance report 27/27 (with the AIP-23 verifier), console 8/8; the console viewer in a real Chromium, phone and desktop, 26/26 (`node proba-consola-web.mjs`, measured 2026-09-30; needs `playwright-core` and a Chromium, otherwise it exits 2) | remediation 7/7, compliance report 3/3 in this repository; viewer 8/8 (`node control-negativ-consola-web.mjs`) |
|
||||||
| identity | 43/43 (`node proba-identity.mjs`, with the AIP-23 verifier for its envelope test; without it that test is reported as skipped and the exit code is 2), compliance 14/14 (`node proba-conformitate.mjs`), measured 2026-09-30 | 46/46 (`node control-negativ-identity.mjs`); compliance 13/13 (`node control-negativ-conformitate.mjs`) |
|
| identity | 43/43 (`node proba-identity.mjs`, with the AIP-23 verifier for its envelope test; without it that test is reported as skipped and the exit code is 2), compliance 14/14 (`node proba-conformitate.mjs`), Travel Rule 18/18 (`node proba-travel-rule.mjs`), measured 2026-09-30 | 46/46 (`node control-negativ-identity.mjs`); compliance 13/13 (`node control-negativ-conformitate.mjs`); Travel Rule 18/18 (`node control-negativ-travel-rule.mjs`) |
|
||||||
| agents | policy 27/27 with the AIP-23 verifier (without it 25 run, 2 are reported as skipped and the exit code is 2), ledger 51/51, approval and revocation 39/39, the chain as witness 26/26 without a network and 7/7 on testnet 28001 on 2026-09-30 (`proba-agent-ancora-testnet.mjs`, needs a funded testnet key and the AIP-23 verifier), command line 23/23 through files and processes only (on Linux and macOS one more test checks the key file mode; not measured here); x402 wallet 25/25, 2-of-2 co-signing 16/16 and payment verifier 14/14 without a network; on the public testnet 28001, 9/9 with the wallet key and 13/13 with the 2-of-2 contract wallet (`x402/proba-x402-testnet.mjs`, `x402/proba-cosign-testnet.mjs`, each needs a funded testnet key); the contract's artifact recompiles byte for byte with solc 0.8.23 (`node x402/recompileaza-contract.mjs --solc <solc>`) | 26/26 (`node control-negativ-aprobare.mjs`); the chain as witness 11/11 (`node control-negativ-ancora.mjs`); x402 30/30 (`node x402/control-negativ-wallet.mjs`); the contract's own tests (7) and their negative control (4/4) run in the Aere Network contracts project, not in this repository |
|
| agents | policy 27/27 with the AIP-23 verifier (without it 25 run, 2 are reported as skipped and the exit code is 2), ledger 51/51, approval and revocation 39/39, the chain as witness 26/26 without a network and 7/7 on testnet 28001 on 2026-09-30 (`proba-agent-ancora-testnet.mjs`, needs a funded testnet key and the AIP-23 verifier), command line 23/23 through files and processes only (on Linux and macOS one more test checks the key file mode; not measured here); x402 wallet 25/25, 2-of-2 co-signing 16/16 and payment verifier 14/14 without a network; on the public testnet 28001, 9/9 with the wallet key and 13/13 with the 2-of-2 contract wallet (`x402/proba-x402-testnet.mjs`, `x402/proba-cosign-testnet.mjs`, each needs a funded testnet key); the contract's artifact recompiles byte for byte with solc 0.8.23 (`node x402/recompileaza-contract.mjs --solc <solc>`) | 26/26 (`node control-negativ-aprobare.mjs`); the chain as witness 11/11 (`node control-negativ-ancora.mjs`); x402 30/30 (`node x402/control-negativ-wallet.mjs`); the contract's own tests (7) and their negative control (4/4) run in the Aere Network contracts project, not in this repository |
|
||||||
|
|
||||||
Code comments, most function and variable names (also many exported between the files of a component), test names and control
|
Code comments, most function and variable names (also many exported between the files of a component), test names and control
|
||||||
@ -45,4 +45,4 @@ interface, command line and data (`definePolicy`, `verifyLedger`, `approve`, ...
|
|||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
MIT, see [LICENSE](LICENSE). Files: 147 (pq-gateway 6, pq-kms 10, pq-pki 6, crypto-inventory 42, verify-layer 8, proof-kinds 3, control-plane 19, agents 39, identity 8, readiness 4).
|
MIT, see [LICENSE](LICENSE). Files: 150 (pq-gateway 6, pq-kms 10, pq-pki 6, crypto-inventory 42, verify-layer 8, proof-kinds 3, control-plane 19, agents 39, identity 11, readiness 4).
|
||||||
|
|||||||
@ -25,8 +25,8 @@ VALID, 1 on INVALID, 2 on a usage error. The claims it returns are the plain one
|
|||||||
|
|
||||||
**Signatures are hybrid**: Ed25519 and ML-DSA-65 over the same message, and both are required, so a break of either scheme alone
|
**Signatures are hybrid**: Ed25519 and ML-DSA-65 over the same message, and both are required, so a break of either scheme alone
|
||||||
forges nothing. Every message is domain-separated by purpose (`aere-identity/v1/<purpose>\n` + text, purposes `credential`,
|
forges nothing. Every message is domain-separated by purpose (`aere-identity/v1/<purpose>\n` + text, purposes `credential`,
|
||||||
`presentation`, `delegation`, `revocation`, `status-list`), so a signature made for one purpose is not valid for another over the same
|
`presentation`, `delegation`, `revocation`, `status-list`, and for the Travel Rule `kem-binding`, `travel-rule`, `travel-rule-receipt`),
|
||||||
text. The signed text is the canonical JSON of the statement (keys sorted), rebuilt by the verifier.
|
so a signature made for one purpose is not valid for another over the same text. The signed text is the canonical JSON of the statement (keys sorted), rebuilt by the verifier.
|
||||||
|
|
||||||
**An identity is its keys**: `aere-id:` + the first 20 bytes of SHA-256 over the canonical form of the two public keys (SPKI). A
|
**An identity is its keys**: `aere-id:` + the first 20 bytes of SHA-256 over the canonical form of the two public keys (SPKI). A
|
||||||
credential or a delegation that names an id not derived from the keys it carries fails.
|
credential or a delegation that names an id not derived from the keys it carries fails.
|
||||||
@ -102,6 +102,29 @@ for a time it does not choose, instead of keeping the data. What this is not: a
|
|||||||
"over 18" is a claim the issuer made), an AML or sanctions screening (it consults no list), or legal compliance with anything: it
|
"over 18" is a claim the issuer made), an AML or sanctions screening (it consults no list), or legal compliance with anything: it
|
||||||
says that one presentation met one policy at one time, as judged by the verifier.
|
says that one presentation met one policy at one time, as judged by the verifier.
|
||||||
|
|
||||||
|
## Travel Rule between VASPs, post-quantum (`travel-rule.mjs`)
|
||||||
|
|
||||||
|
The originator and beneficiary data of a transfer (an IVMS101 object) sent from the paying VASP to the receiving VASP, readable only
|
||||||
|
by it: a hybrid key encapsulation (X25519 and ML-KEM-768, both secrets required, HKDF-SHA-256, AES-256-GCM), signed by the originator
|
||||||
|
(Ed25519 and ML-DSA-65), bound to the transfer (chain, asset, amount, beneficiary address, transaction) as authenticated data and in
|
||||||
|
the key derivation, and acknowledged with a signed receipt. Each side proves it is a VASP with a presentation of an Aere Identity
|
||||||
|
credential (`vasp: true`) from a registry the other side trusts, made for the other side, with its status judged.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { generateKemKeys, bindKemKeys, acceptBeneficiary, sealMessage, openMessage, acknowledge, verifyReceipt, travelRuleRecord } from './travel-rule.mjs';
|
||||||
|
// beneficiary: its KEM keys, bound to its identity (signed), handed over with a presentation of its VASP credential for the originator
|
||||||
|
// originator: acceptBeneficiary({ binding, presentation, registries, originatorId, nonce, statusLists }) -> { ok, beneficiary }
|
||||||
|
// sealMessage({ from, beneficiary, ivms101, transfer, presentation /* its own, for the beneficiary, nonce = messageId */ })
|
||||||
|
// beneficiary: openMessage(message, { me, kem, registries, statusLists, seen }) -> { ok, ivms101, transfer } ; acknowledge(...)
|
||||||
|
// originator: verifyReceipt(receipt, { message, beneficiaryId }) ; travelRuleRecord({ message, receipt, buildProof })
|
||||||
|
```
|
||||||
|
|
||||||
|
`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
|
||||||
|
`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.
|
||||||
|
|
||||||
## What it does not do
|
## What it does not do
|
||||||
|
|
||||||
It does not bind a key to hardware: a device key is a key like any other, and no TPM or secure-enclave attestation is checked here. It
|
It does not bind a key to hardware: a device key is a key like any other, and no TPM or secure-enclave attestation is checked here. It
|
||||||
@ -116,6 +139,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 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 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 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
|
||||||
```
|
```
|
||||||
|
|
||||||
The envelope test needs the AIP-23 reference verifier (`AERE_VERIFY_PROOF=<verify-proof.mjs>`); without it that test is reported as
|
The envelope test needs the AIP-23 reference verifier (`AERE_VERIFY_PROOF=<verify-proof.mjs>`); without it that test is reported as
|
||||||
|
|||||||
74
identity/control-negativ-travel-rule.mjs
Normal file
74
identity/control-negativ-travel-rule.mjs
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
// Controlul negativ al Travel Rule (travel-rule.mjs, proba-travel-rule.mjs): fiecare paznic scos intr-o COPIE trebuie sa inroseasca
|
||||||
|
// proba NUMITA, cu proba chiar rulata; pe copia neatinsa, verde. O plantare poate avea mai multe inlocuiri (un paznic dublat, de pilda
|
||||||
|
// antetul legat si ca AAD si prin derivarea cheii, se scoate intreg). Trei stari: un tipar care nu apare exact o data sau o proba care
|
||||||
|
// nu ajunge la rezumat e STRICAT si se numara esec.
|
||||||
|
// node control-negativ-travel-rule.mjs iesire 0 = martorul verde si toate plantarile rosii pe proba lor
|
||||||
|
import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';
|
||||||
|
import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const AICI = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const DEV_VERIFY = path.resolve(AICI, '..', 'aere-proof-protocol', 'verify.mjs');
|
||||||
|
const VERIFY = process.env.AERE_VERIFY_PROOF || (fs.existsSync(DEV_VERIFY) ? DEV_VERIFY : '');
|
||||||
|
const T = 'travel-rule.mjs';
|
||||||
|
const PLANTARI = [
|
||||||
|
// [nume, [[tipar, inlocuire], ...], proba (inceputul numelui ei)]
|
||||||
|
['legarea KEM nesemnata de VASP-ul ei primita', [["if (!verifyText('kem-binding', canonical(S), binding.signature, S.vasp.keys)) motive.push(", 'if (false) motive.push(']], 'ATAC: legarea KEM semnata de alt VASP'],
|
||||||
|
['credentialul altei identitati primit', [['if (!holder || holder.id !== id) motive.push(', 'if (false) motive.push(']], 'ATAC: legarea KEM a lui C cu prezentarea'],
|
||||||
|
['un credential fara vasp:true primit', [['if (v.valid && (!v.claims || v.claims.vasp !== true)) motive.push(', 'if (false) motive.push(']], 'ATAC: credential de VASP de la un registru'],
|
||||||
|
['registrele de incredere nu mai ajung la verificare', [['trustedIssuers: registries,', 'trustedIssuers: null,']], 'ATAC: credential de VASP de la un registru'],
|
||||||
|
['starea nejudecata a VASP-ului primita', [['if (!(stare && stare.pass === true)) motive.push(', 'if (false) motive.push(']], 'ATAC: un VASP revocat'],
|
||||||
|
['legarea expirata primita', [["if (!(timp(S.validFrom, 'validFrom') <= acum && acum <= timp(S.validUntil, 'validUntil'))) motive.push(", 'if (false) motive.push(']], 'ATAC: prezentarea beneficiarului facuta pentru alt initiator'],
|
||||||
|
['semnatura mesajului nu se mai verifica', [["if (!verifyText('travel-rule', canonical(semnat), m.signature, m.from.keys)) motive.push(", 'if (false) motive.push(']], 'ATAC: mesaj semnat de un strain'],
|
||||||
|
['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'],
|
||||||
|
['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'],
|
||||||
|
['confirmarea altui VASP primita', [['if (S.from.id !== beneficiaryId || idOf(S.from.keys) !== S.from.id) motive.push(', 'if (false) motive.push(']], 'ATAC: confirmare semnata de C'],
|
||||||
|
['confirmarea altui mesaj primita', [["if (S.messageId !== message.id || S.messageHash !== sha(Buffer.from(canonical(semnat), 'utf8'))) motive.push(", 'if (false) motive.push(']], 'ATAC: confirmare semnata de C'],
|
||||||
|
['id-ul initiatorului scris in inregistrare', [["const subject = 'transfer:' + sha(", 'const subject = message.from.id + sha(']], 'inregistrarea (plic AIP-23 compliance)'],
|
||||||
|
['un fisier de chei KEM cu partea publica a altcuiva primit', [['if (canonical(pub) !== canonical(j.public)) throw', 'if (false) throw']], 'cheile KEM'],
|
||||||
|
];
|
||||||
|
const FISIERE = ['identity.mjs', T, 'proba-travel-rule.mjs'];
|
||||||
|
function copie() {
|
||||||
|
const t = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-tr-ctl-'));
|
||||||
|
fs.mkdirSync(path.join(t, 'aere-identity')); fs.mkdirSync(path.join(t, 'proof-kinds'));
|
||||||
|
for (const f of FISIERE) fs.copyFileSync(path.join(AICI, f), path.join(t, 'aere-identity', f));
|
||||||
|
fs.copyFileSync(path.join(AICI, '..', 'proof-kinds', 'proof-kinds.mjs'), path.join(t, 'proof-kinds', 'proof-kinds.mjs'));
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
function ruleaza(t) {
|
||||||
|
const env = { ...process.env }; if (VERIFY) env.AERE_VERIFY_PROOF = VERIFY; else delete env.AERE_VERIFY_PROOF;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const c = spawn(process.execPath, [path.join(t, 'aere-identity', 'proba-travel-rule.mjs')], { env }); let out = '';
|
||||||
|
const ceas = setTimeout(() => c.kill(), 180000);
|
||||||
|
c.stdout.on('data', (x) => { out += x; }); c.stderr.on('data', (x) => { out += x; });
|
||||||
|
c.on('close', (cod) => { clearTimeout(ceas); resolve({ cod, rulat: /aere-travel-rule: \d+\/\d+/.test(out), rosii: out.split('\n').filter((l) => l.startsWith(' RAU ')) }); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function planteaza([nume, perechi, tinta]) {
|
||||||
|
const t = copie();
|
||||||
|
try {
|
||||||
|
const f = path.join(t, 'aere-identity', T); let src = fs.readFileSync(f, 'utf8');
|
||||||
|
for (const [din, inl] of perechi) {
|
||||||
|
if (src.split(din).length !== 2) return [false, ` STRICAT ${nume}: tiparul "${din.slice(0, 40)}" apare de ${src.split(din).length - 1} ori`];
|
||||||
|
src = src.replace(din, inl);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(f, src);
|
||||||
|
const r = await ruleaza(t);
|
||||||
|
if (!r.rulat) return [false, ` STRICAT ${nume}: proba nu a ajuns la rezumat (cod ${r.cod})`];
|
||||||
|
if (r.rosii.some((l) => l.startsWith(' RAU ' + tinta))) return [true, ` ROSU cum trebuia ${nume} (proba '${tinta}' pica)`];
|
||||||
|
return [false, ` CONTROL CAZUT ${nume}: proba '${tinta}' a ramas verde (${r.rosii.length} rosii altundeva)`];
|
||||||
|
} finally { fs.rmSync(t, { recursive: true, force: true }); }
|
||||||
|
}
|
||||||
|
let rele = 0;
|
||||||
|
const t0 = copie(); const m = await ruleaza(t0); fs.rmSync(t0, { recursive: true, force: true });
|
||||||
|
if (m.rulat && !m.rosii.length && (m.cod === 0 || (m.cod === 2 && !VERIFY))) console.log(' OK martorul: copia neatinsa verde' + (m.cod === 2 ? ' (plicul AIP-23 NEMASURAT: fara verificator)' : ''));
|
||||||
|
else { rele++; console.log(` STRICAT martorul nu e verde (cod ${m.cod}, ${m.rosii.length} rosii)`); }
|
||||||
|
const rez = new Array(PLANTARI.length); let i = 0;
|
||||||
|
await Promise.all(Array.from({ length: 4 }, async () => { while (i < PLANTARI.length) { const k = i++; rez[k] = await planteaza(PLANTARI[k]); } }));
|
||||||
|
for (const [bun, linie] of rez) { console.log(linie); if (!bun) rele++; }
|
||||||
|
console.log(rele ? `CONTROL NEGATIV: ${rele} probleme` : `DOVEDIT: martorul verde, ${PLANTARI.length} din ${PLANTARI.length} paznici scosi -> proba lor rosie`);
|
||||||
|
process.exitCode = rele ? 1 : 0;
|
||||||
@ -40,7 +40,8 @@ import zlib from 'node:zlib';
|
|||||||
export const VERSION = 1;
|
export const VERSION = 1;
|
||||||
export const ALG = 'ed25519+ml-dsa-65';
|
export const ALG = 'ed25519+ml-dsa-65';
|
||||||
const DOMENIU = 'aere-identity/v1/';
|
const DOMENIU = 'aere-identity/v1/';
|
||||||
const SCOPURI = new Set(['credential', 'presentation', 'delegation', 'revocation', 'status-list']);
|
// 2026-09-30: si scopurile Travel Rule (travel-rule.mjs): legarea cheilor KEM de un VASP, mesajul, confirmarea
|
||||||
|
const SCOPURI = new Set(['credential', 'presentation', 'delegation', 'revocation', 'status-list', 'kem-binding', 'travel-rule', 'travel-rule-receipt']);
|
||||||
const REZERVATE = new Set(['__proto__', 'constructor', 'prototype']);
|
const REZERVATE = new Set(['__proto__', 'constructor', 'prototype']);
|
||||||
const NUME = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/;
|
const NUME = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/;
|
||||||
const ID = /^aere-id:[0-9a-f]{40}$/;
|
const ID = /^aere-id:[0-9a-f]{40}$/;
|
||||||
|
|||||||
156
identity/proba-travel-rule.mjs
Normal file
156
identity/proba-travel-rule.mjs
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
// 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], ...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] }), /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');
|
||||||
|
});
|
||||||
|
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(' | '));
|
||||||
|
});
|
||||||
|
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);
|
||||||
202
identity/travel-rule.mjs
Normal file
202
identity/travel-rule.mjs
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
// 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 };
|
||||||
Loading…
Reference in New Issue
Block a user