#!/usr/bin/env node // martor-agenti-serviciu.mjs: SERVICIUL martorului gazduit al agentilor (B3, 2026-10-19), sandbox catre testnetul 28001. Un proces pe loopback // (Node 24: ML-DSA-65 din node:crypto), in spatele gateway-ului Aere Cloud (/v1/agents/witness, cum serveste adunatorul scutului /v1/shield/*): // POST /v1/agents/witness un cap de registru semnat de agent (martor-agenti.mjs cerereMartor); 202 primit, cu // promisiunea semnata de martor; 200 deja vazut; 409 echivocare (dovada intoarsa, si // pastrata: una pe agent si politica), cap vechi, sau cap care nu continua ultimul; 4xx // refuz numit; 429 ritm; 503 bugetul jurnalului pe ora (log_busy, cu Retry-After) // GET /v1/agents/witness/0x starea capului: pending | checkpointed | notarized, cu chitanta (plicul AIP-23) // GET /v1/agents/witness/aere-agent:<40 hex>/heads?session=<32 hex>[&from=][&limit<=100] o pagina a capetelor unei sesiuni, cu // pagina SEMNATA de martor (`signedPage`, cu momentul ei), singura pe care o crede verificatorul // GET /v1/agents/witness/aere-agent:<40 hex>/heads[?after=] sesiunile agentului (cel mult 100) // GET /v1/agents/witness/equivocation/0x o dovada de echivocare pastrata // GET /v1/agents/witness/checkpoint[?shard=][&from=] ultimul cap de jurnal al ciobului curent (sau al ciobului pastrat `k`, si al // unuia inghetat: cel final) si cel notarizat, cu dovada de consistenta in acel ciob de la `from` // GET /v1/agents/witness/health starea martorului si a limitatoarelor (chei tinute, scoase la plafon) // La fiecare `--interval` minute: un cap de jurnal nou daca au venit capete, si notarizarea capetelor de jurnal nenotarizate (ultimul al fiecarui // ciob pastrat) pe AereNotary 28001, cu o cheie de PROBA a testnetului (nascuta in starea serviciului daca lipseste, alimentata din robinet daca // e dat). Orice alt lant decat 28001 e refuzat inainte de cheie: mainnet-ul cere tranzactiile fondatorului ("sandbox: 28001; mainnet: at activation"). // Ce tine jurnalul de cereri: momentul, ruta (fara id-uri) si statusul; o eroare interna merge INTREAGA numai aici, raspunsul public poarta un cod // (revizia B3, constatarea 18). Limitele de ritm stau in memorie, cu cheile sarate pe proces, fiecare cu un plafon de chei. // // node martor-agenti-serviciu.mjs --stare [--port 8802] [--interval 10] [--rpc https://testnet-rpc.aere.network] [--cheie ] // [--robinet ] [--log aere-agent-witness/28001] [--merge-max 1800] [--fara-lant] [--o-data] // --fara-lant laborator: capete de jurnal construite si semnate, nicio notarizare (chitantele raman `checkpointed`) // --o-data un singur tur (cap de jurnal + notarizare), apoi iesire: 0 notarizat sau nimic de facut, 2 notarizarea n-a reusit import fs from 'node:fs'; import net from 'node:net'; import path from 'node:path'; import http from 'node:http'; import crypto from 'node:crypto'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { deschideMartor, CERERE_MAX_OCTETI, SANDBOX, LANT, CHECKPOINT_KIND, PLAFON_FRUNZE, PAGINA_MAX } from './martor-agenti.mjs'; import { notarizeEnvelope, cheiaDinFisier, NOTARI } from './agent-ancora.mjs'; const AICI = path.dirname(fileURLToPath(import.meta.url)); export const PORT_IMPLICIT = 8802; export const RPC_28001 = 'https://testnet-rpc.aere.network'; // limitele, pe fereastra de 10 minute: un agent cinstit trimite un cap la cateva minute; GET-urile sunt ieftine dar nu nelimitate. Bugetul // jurnalului (capete NOI primite pe ora, de la toti) e DERIVAT din plafonul ciobului: PLAFON_FRUNZE / 24 pe ora, deci un ciob nu se umple in mai // putin de o zi, iar un cap al lui se serveste cel putin o zi dupa ce ciobul e inghetat (martorul tine ciobul curent si pe cel de dinainte). export const LIMITE = { postPeClient: { cereri: 60, ms: 600_000 }, postPeAgent: { cereri: 30, ms: 600_000 }, getPeClient: { cereri: 1200, ms: 600_000 }, acceptatePeOra: { cereri: Math.floor(PLAFON_FRUNZE / 24), ms: 3_600_000 } }; // plafonul de chei al unui limitator, DERIVAT din masuratoare (2026-10-19, README "Bounds"): 198 de octeti pe cheie (100.000 de chei = 19,8 MB pe // limitator, deci ~60 MB pentru cele trei). La plafon (dupa curatarea cheilor expirate) se scoate cheia cu fereastra cea mai VECHE si clientul nou // intra (revizia B3 a doua, R2-03): prima reparatie limita aici orice client NOU, deci cine umplea 100.000 de chei (de pe /64-uri reale) tinea // afara pe toti clientii noi cat le reimprospata. Un client scos asa primeste doar o fereastra noua; un atacator care are deja 100.000 de chei are // deja 100.000 de cote. Memoria nu creste cu adresele (pana la prima reparatie: 293 de octeti pe adresa, pe viata procesului, si niciun plafon). export const LIMITATOR_MAX_CHEI = 100_000; const taie = (s) => String(s ?? '').replace(/(0x)?[0-9a-fA-F]{40,}/g, '').slice(0, 300); /** * Cheia unui client pentru limite (revizia B3, constatarea 13): o adresa IPv6 pe prefixul ei /64 (un client IPv6 primeste de obicei un /64 intreg, * deci pe adresa intreaga ar fi avut 2^64 de chei), o adresa IPv4 mapata in IPv6 ca IPv4, o adresa IPv4 intreaga; orice altceva (o proba) ca atare. */ export function cheieClient(a) { let s = String(a ?? '').trim().toLowerCase(); if (s.startsWith('[') && s.includes(']')) s = s.slice(1, s.indexOf(']')); const pct = s.indexOf('%'); if (pct >= 0) s = s.slice(0, pct); const m4 = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(s); if (m4 && net.isIPv4(m4[1])) return m4[1]; if (net.isIPv4(s)) return s; if (net.isIPv6(s)) { let [cap, coada = ''] = s.split('::'); const g = (t) => (t ? t.split(':') : []); let a1 = g(cap), a2 = g(coada); const v4 = (l) => { const u = l[l.length - 1]; if (u && u.includes('.')) { const o = u.split('.').map(Number); l.splice(-1, 1, ((o[0] << 8) | o[1]).toString(16), ((o[2] << 8) | o[3]).toString(16)); } }; if (s.includes('::')) v4(a2); else v4(a1); const tot = s.includes('::') ? [...a1, ...Array(Math.max(0, 8 - a1.length - a2.length)).fill('0'), ...a2] : a1; return tot.slice(0, 4).map((x) => (parseInt(x, 16) || 0).toString(16)).join(':') + '::/64'; } return 'altul:' + s.slice(0, 64); } /** * Un limitator pe fereastra fixa (de la prima cerere a cheii), cu cheile sarate pe proces (nicio adresa sau id nu se tine in clar), cu cheile * expirate CURATATE (la 1024 de apeluri, si cand plafonul e atins, dar cel mult o data pe secunda) si cu un PLAFON de chei: pana la reparatie * nicio cheie nu se stergea si fiecare adresa noua lasa ~293 de octeti pe viata procesului (revizia B3, constatarea 13). La plafon se scoate cheia * cu fereastra cea mai veche (harta e in ordinea inceputului ferestrei: o fereastra noua se muta la coada), numarata (`scoase`), in loc sa fie * limitat clientul nou (revizia B3 a doua, R2-03). `ar(cheie)`: ar fi limitata cheia acum? fara sa consume nimic (bugetul jurnalului, R2-13). */ export function limitator({ cereri, ms }, { maxChei = LIMITATOR_MAX_CHEI, ceas = Date.now } = {}) { const sare = crypto.randomBytes(16); const m = new Map(); let apeluri = 0, ultimaCuratare = 0, scoase = 0; const cheieDe = (cheie) => crypto.createHash('sha256').update(sare).update(String(cheie)).digest('base64url').slice(0, 16); const curata = (acum) => { ultimaCuratare = acum; for (const [k, v] of m) if (acum - v[0] >= ms) m.delete(k); }; const f = (cheie) => { const k = cheieDe(cheie); const acum = ceas(); if (++apeluri % 1024 === 0) curata(acum); let v = m.get(k); if (v && acum - v[0] >= ms) { m.delete(k); v[0] = acum; v[1] = 0; m.set(k, v); } if (!v) { if (m.size >= maxChei && acum - ultimaCuratare >= 1000) curata(acum); if (!(m.size < maxChei /*PAZA-MARTOR:ritm-plafon*/)) { m.delete(m.keys().next().value); scoase++; } v = [acum, 0]; m.set(k, v); } if (v[1] >= cereri) return true; v[1]++; return false; }; f.ar = (cheie) => { const v = m.get(cheieDe(cheie)); return !!v && ceas() - v[0] < ms && v[1] >= cereri; }; // cat mai are de asteptat cheia pana i se deschide o fereastra noua (ms; 0 daca nu e limitata), pentru Retry-After (revizia B3 a cincea, R5-06) f.ramasMs = (cheie) => { const v = m.get(cheieDe(cheie)); return v && v[1] >= cereri ? Math.max(0, ms - (ceas() - v[0])) : 0; }; f.marime = () => m.size; f.scoase = () => scoase; return f; } const loopback = (a) => /^(127\.|::1$|::ffff:127\.)/.test(String(a || '')); /** Serverul HTTP peste starea deschisa `m` (deschideMartor). Clientul real e X-Real-IP numai cand cererea vine de pe loopback (gateway-ul il pune). */ export function porneste(m, { port = 0, limite = LIMITE, jurnal = () => {}, maxChei = LIMITATOR_MAX_CHEI } = {}) { const peClient = limitator(limite.postPeClient, { maxChei }), peAgent = limitator(limite.postPeAgent, { maxChei }), citiri = limitator(limite.getPeClient, { maxChei }); const acceptate = limite.acceptatePeOra ? limitator(limite.acceptatePeOra, { maxChei: 1 }) : null; // bugetul jurnalului: martorul il CITESTE intai (`ar`, fara sa consume) si il consuma abia dupa cota agentului (R2-13) const limitaGlobala = acceptate ? Object.assign(() => acceptate('jurnal'), { ar: () => acceptate.ar('jurnal') /*PAZA-MARTOR:buget-citit*/ }) : null; // starea limitatoarelor, in /health: cate chei tin si cate au scos la plafon (o inundatie din multe adrese se vede, nu se ghiceste) const stareLimite = () => Object.fromEntries([['postPerClient', peClient], ['postPerAgent', peAgent], ['getPerClient', citiri]].map(([n, l]) => [n, { keys: l.marime(), maxKeys: maxChei, droppedAtCap: l.scoase() }])); const raspunde = (req, res, cod, obiect, antete = {}) => { const text = Buffer.from(JSON.stringify(obiect)); res.writeHead(cod, { 'content-type': 'application/json; charset=utf-8', 'content-length': text.length, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...antete }); res.end(text); jurnal(`${req.method} ${String(req.url).replace(/0x[0-9a-fA-F]{64}/g, '0x..').replace(/aere-agent:[0-9a-f]{40}/g, 'aere-agent:..').replace(/\?.*$/, '').slice(0, 60)} ${cod}`); }; // o eroare interna: INTREAGA numai in jurnalul local; public un cod (fara cai, fara numele starii) const interna = (req, res, e) => { jurnal(`internal error on ${req.method} ${String(req.url).replace(/\?.*$/, '').slice(0, 60)}: ${taie(e && (e.stack || e.message))}`); if (!res.headersSent) raspunde(req, res, 500, { error: 'witness_error', code: e && /^E[A-Z]+$/.test(String(e.code)) ? 'io_error' : 'internal', sandbox: SANDBOX }); }; const RUTE = ['POST /v1/agents/witness', 'GET /v1/agents/witness/0x', 'GET /v1/agents/witness/aere-agent:<40 hex>/heads[?session=<32 hex>&from=&limit=<1..100>][?after=]', 'GET /v1/agents/witness/equivocation/0x', 'GET /v1/agents/witness/checkpoint[?shard=][&from=]', 'GET /v1/agents/witness/health']; const intreg = (s, d) => (s === null ? d : /^\d{1,15}$/.test(s) ? Number(s) : NaN); const handler = (req, res) => { try { const url = new URL(req.url, 'http://x'); const cale = url.pathname; const client = cheieClient(loopback(req.socket.remoteAddress) && req.headers['x-real-ip'] ? String(req.headers['x-real-ip']) : String(req.socket.remoteAddress || '')); if (req.method === 'POST' && cale === '/v1/agents/witness') { if (peClient(client) /*PAZA-MARTOR:ritm*/) return raspunde(req, res, 429, { error: 'rate_limited', hint: `${limite.postPeClient.cereri} heads per ${limite.postPeClient.ms / 60000} minutes per client`, sandbox: SANDBOX }); const ct = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase(); const parti = []; let n = 0, prea = false; req.on('data', (d) => { n += d.length; if (n > CERERE_MAX_OCTETI) { if (!prea) { prea = true; raspunde(req, res, 413, { error: 'body_too_large', max: CERERE_MAX_OCTETI, hint: 'a signed head is about 8 KB; the witness never takes the ledger', sandbox: SANDBOX }); req.destroy(); } } else parti.push(d); }); req.on('end', () => { if (prea) return; try { if (ct !== 'application/json') return raspunde(req, res, 415, { error: 'bad_content_type', hint: 'application/json: the signed head (martor-agenti.mjs request)', sandbox: SANDBOX }); let env; try { env = JSON.parse(Buffer.concat(parti).toString('utf8')); } catch { return raspunde(req, res, 400, { error: 'bad_json', sandbox: SANDBOX }); } const r = m.primeste(env, { limitaAgent: peAgent, limitaGlobala }); return raspunde(req, res, r.cod, r.corp, r.corp && r.corp.retryAfterS ? { 'retry-after': String(r.corp.retryAfterS) } : {}); } catch (e) { return interna(req, res, e); } }); req.on('error', () => {}); return; } if (req.method !== 'GET') return raspunde(req, res, 405, { error: 'method_not_allowed', routes: RUTE, sandbox: SANDBOX }); // o citire peste limita spune si cand se poate reincerca (Retry-After, secundele pana la fereastra noua a clientului): verificatorul aduce // cate o chitanta pe cap tinut si asteapta cat spune antetul (revizia B3 a cincea, R5-06) if (citiri(client)) { const ra = Math.max(1, Math.ceil(citiri.ramasMs(client) / 1000)); return raspunde(req, res, 429, { error: 'rate_limited', retryAfterS: ra, sandbox: SANDBOX }, { 'retry-after': String(ra) } /*PAZA-MARTOR:get-retry-after*/); } if (cale === '/v1/agents/witness/health') return raspunde(req, res, 200, { ...m.sanatate(), limiters: stareLimite() }); if (cale === '/v1/agents/witness/checkpoint') { const sh = url.searchParams.get('shard'); if (sh !== null && !/^\d{1,9}$/.test(sh)) return raspunde(req, res, 400, { error: 'bad_shard', sandbox: SANDBOX }); const r = m.capulJurnalului(url.searchParams.get('from'), sh === null ? null : Number(sh)); return raspunde(req, res, r.cod, r.corp); } const me = /^\/v1\/agents\/witness\/equivocation\/(0x[0-9a-fA-F]{64})$/.exec(cale); if (me) { const r = m.dovadaEchivocarii(me[1]); return raspunde(req, res, r.cod, r.corp); } const mi = /^\/v1\/agents\/witness\/(0x[0-9a-fA-F]{64})$/.exec(cale); if (mi) { const r = m.stareaCapului(mi[1]); return raspunde(req, res, r.cod, r.corp); } const ma = /^\/v1\/agents\/witness\/(aere-agent:[0-9a-f]{40})\/heads$/.exec(cale); if (ma) { const s = url.searchParams.get('session'), after = url.searchParams.get('after'); const from = intreg(url.searchParams.get('from'), 0), limit = intreg(url.searchParams.get('limit'), PAGINA_MAX); if (s !== null && !/^[0-9a-f]{32}$/.test(s)) return raspunde(req, res, 400, { error: 'bad_session', sandbox: SANDBOX }); if (after !== null && !/^[0-9a-f]{32}$/.test(after)) return raspunde(req, res, 400, { error: 'bad_after', sandbox: SANDBOX }); if (!(Number.isSafeInteger(from) && from >= 0 && Number.isSafeInteger(limit) && limit >= 1 && limit <= PAGINA_MAX)) return raspunde(req, res, 400, { error: 'bad_page', hint: `from >= 0, 1 <= limit <= ${PAGINA_MAX}`, sandbox: SANDBOX }); return raspunde(req, res, 200, m.capeteAgent(ma[1], s, { from, limit, after })); } return raspunde(req, res, 404, { error: 'not_found', routes: RUTE, sandbox: SANDBOX }); } catch (e) { return interna(req, res, e); } }; const server = http.createServer(handler); return new Promise((ok) => server.listen(port, '127.0.0.1', () => ok({ server, port: server.address().port, close: () => new Promise((g) => server.close(() => g())) }))); } /** * Notarizatorul real: capul de jurnal pe AereNotary, NUMAI pe testnetul 28001 (lantul citit de la RPC, refuzat altfel inainte de cheie), cu o * cheie de proba din fisier (nascuta acolo, 0600, daca lipseste; niciodata tiparita), alimentata din robinetul testnetului cand soldul nu * ajunge si cand `robinet` numeste clientul lui (tools/aere-quantum-shield/robinet.mjs; fara el, `unfunded`, spus). Intoarce {ok, notarization} * sau {ok: false, cod, motiv}: `cod` e cel public (unfunded, chain_refused, ...), `motiv` merge numai in jurnalul local. */ export function notarizatorReal({ rpc = RPC_28001, cheieFisier, robinet = null, ethers = null }) { return async (env) => { let E = ethers; if (!E) { try { const { incarcaEthers } = await import(pathToFileURL(path.join(AICI, 'x402', 'wallet.mjs')).href); E = incarcaEthers(); } catch (e) { return { ok: false, cod: 'ethers_missing', motiv: 'ethers is not here: ' + taie(e.message) }; } } try { const provider = new E.JsonRpcProvider(rpc); const chainId = Number((await provider.getNetwork()).chainId); if (!(chainId === LANT /*PAZA-MARTOR:sandbox*/)) return { ok: false, cod: 'chain_refused', motiv: `REFUSED: this RPC serves chain ${chainId}; the agents witness notarizes on the public testnet ${LANT} only (mainnet at activation, with the founder's transactions)` }; if (!fs.existsSync(cheieFisier)) { const w = E.Wallet.createRandom(); fs.mkdirSync(path.dirname(cheieFisier), { recursive: true }); fs.writeFileSync(cheieFisier, `# AERE-SINTETIC: test key of the agents witness for testnet ${LANT}, made ${new Date().toISOString()}; funds only from the faucet\nd=${w.privateKey.slice(2)}\n`, { mode: 0o600, flag: 'wx' }); } const wallet = new E.Wallet(cheiaDinFisier(cheieFisier), provider); const min = E.parseEther('0.01'); if ((await provider.getBalance(wallet.address)) < min) { if (!robinet) return { ok: false, cod: 'unfunded', motiv: 'unfunded: the test key has no testnet balance and no faucet client was given (--robinet)' }; const { cereRobinet } = await import(pathToFileURL(path.resolve(robinet)).href); const c = await cereRobinet(wallet.address); if (!c.ok) return { ok: false, cod: 'unfunded', motiv: 'unfunded: the faucet refused: ' + taie(JSON.stringify(c.claim || c.motiv)) }; let sold = 0n; for (let i = 0; i < 40 && sold < min; i++) { await new Promise((g) => setTimeout(g, 1500)); sold = await provider.getBalance(wallet.address); } if (sold < min) return { ok: false, cod: 'unfunded', motiv: 'unfunded: the faucet transaction did not land in 60 s' }; } const r = await notarizeEnvelope({ envelope: env, kind: CHECKPOINT_KIND, rpc, keyFile: cheieFisier, ethers: E, notary: NOTARI[LANT], confirmMainnet: false }); return r.ok ? { ok: true, notarization: { ...r.envelope.notarization, submitter: wallet.address } } : { ok: false, cod: 'notarization_failed', motiv: taie(r.motiv) }; } catch (e) { return { ok: false, cod: 'notarization_failed', motiv: 'the notarization failed: ' + taie(e.shortMessage || e.message) }; } }; } /** Un tur: un cap de jurnal nou (daca au venit capete), apoi notarizarea fiecarui cap de jurnal nenotarizat (cel final al unui ciob inghetat intai). */ export async function tur(m, { notarizeaza = null } = {}) { const nou = m.construiestePunct(); const deFacut = m.deNotarizate(); if (!deFacut.length) return { built: nou ? nou.n : null, notarized: null, note: nou ? 'built' : 'nothing new' }; if (!notarizeaza) return { built: nou ? nou.n : null, notarized: null, note: 'laboratory: no notarization' }; let ultim = null; for (const p of deFacut) { const r = await notarizeaza(m.plicPunct(p)); m.inregistreazaNotarizarea(p, r); if (!r.ok) return { built: nou ? nou.n : null, notarized: ultim ? ultim.n : null, shard: p.k, firstSeen: null, error: r.motiv, code: r.cod || 'notarization_failed' }; ultim = { n: p.n, k: p.k, firstSeen: r.notarization.firstSeen }; } return { built: nou ? nou.n : null, notarized: ultim.n, shard: ultim.k, firstSeen: ultim.firstSeen, error: null }; } // ------------------------------------------------------------------------------------------------ CLI if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); async function main() { const arg = (n, d) => { const i = process.argv.indexOf(n); return i > 0 ? process.argv[i + 1] : d; }; const stare = arg('--stare', null); if (!stare) { console.error('usage: martor-agenti-serviciu.mjs --stare [--port 8802] [--interval 10] [--rpc url] [--cheie f] [--robinet robinet.mjs] [--log name] [--merge-max s] [--fara-lant] [--o-data]'); process.exitCode = 2; return; } const scrieJurnal = (l) => console.log(new Date().toISOString() + ' ' + l); let m; const faraLant = process.argv.includes('--fara-lant'); // laboratorul (--fara-lant) nu notarizeaza nimic: rotirea nu asteapta notarizarea capului de jurnal final al ciobului scos (R2-07) try { m = deschideMartor(stare, { log: arg('--log', undefined), maxMergeDelay: arg('--merge-max', null) != null ? Number(arg('--merge-max')) : undefined, jurnal: scrieJurnal, faraLant }); } catch (e) { console.error('cannot open the state: ' + taie(e.message)); process.exitCode = 2; return; } const notarizeaza = faraLant ? null : notarizatorReal({ rpc: arg('--rpc', RPC_28001), cheieFisier: arg('--cheie', path.join(stare, 'cheie-de-proba-28001.key')), robinet: arg('--robinet', null) }); const t = async () => { try { const r = await tur(m, { notarizeaza }); console.log(new Date().toISOString() + ' round: ' + JSON.stringify(r).slice(0, 300)); return r; } catch (e) { console.log(new Date().toISOString() + ' round UNMEASURED: ' + taie(e.message)); return { error: e.message }; } }; if (process.argv.includes('--o-data')) { const r = await t(); m.inchide(); process.exitCode = r.error ? 2 : 0; return; } const srv = await porneste(m, { port: Number(arg('--port', PORT_IMPLICIT)), jurnal: scrieJurnal }); console.log(`agents witness ${m.witnessId} on 127.0.0.1:${srv.port} (log ${m.log}, state ${stare}, ${faraLant ? 'laboratory: no chain' : 'notarizing on chain ' + LANT}); ${SANDBOX}`); const opreste = () => { m.inchide(); srv.close().then(() => process.exit(0)); }; process.on('SIGTERM', opreste); process.on('SIGINT', opreste); await t(); setInterval(t, Number(arg('--interval', 10)) * 60_000); }