aere-quantum/agents/agent-ancora.mjs

186 lines
16 KiB
JavaScript

#!/usr/bin/env node
// agent-ancora.mjs: LANTUL ca martor al registrului unui agent (punctul 22, pista B, 2026-09-30).
//
// Ce ramasese deschis dupa B-17 (spus in README): momentul unei intrari e declaratia celui care tine cheia agentului; verificatorul il
// margineste de SUS cu ceasul lui, dar de JOS numai daca primeste ancore de la un martor (`anchors` in verifyLedger), iar fara martor
// cine tine cheia poate antedata intrari. Aici martorul e AereNotary: capul registrului (seq, hash) devine un plic AIP-23
// (`aere-agent-ledger-head`), se noteaza pe lant, iar momentul `firstSeen` pe care il tine contractul, citit prin verificatorul AIP-23 de
// referinta sub un certificat post-cuantic de ancora (dovada Merkle in starea certificata), devine ancora {seq, hash, at}. De aici:
// - o intrare de DUPA cap nu poate declara un moment sub firstSeen - toleranta (antedatarea e prinsa);
// - o intrare de PANA LA cap nu poate declara un moment peste firstSeen + toleranta;
// - un registru care nu contine capul notarizat (alta ramura a aceluiasi agent) nu verifica cu aceasta ancora.
// Momentul nu il alege nici agentul, nici cine noteaza: il scrie lantul. Cine noteaza poate numai sa INTARZIE notarizarea (atunci
// marginea e mai slaba), nu sa o mute inainte. Nimic din registru (actiuni, sume) nu ajunge pe lant: numai hash-ul declaratiei capului.
//
// Fisierul de ancore il produce VERIFICATORUL, cu RPC-ul si verificatorul AIP-23 alese de el; o lista de ancore primita de la cel
// verificat nu dovedeste nimic (ar fi declaratia lui).
//
// node agent-ancora.mjs head --ledger l.json [--seq N] [--out head.json] the head as an AIP-23 envelope (no network)
// node agent-ancora.mjs notarize --head head.json --rpc URL --key-file f [--notary 0x..] [--out notarized.json]
// refuses chain 2800 (mainnet: a real transaction paid by the key's account) unless AERE_CONFIRM_MAINNET=yes
// node agent-ancora.mjs anchors --ledger l.json --head n1.json [--head n2.json ...] --rpc URL [--verify-proof verify-proof.mjs]
// [--chain 28001] [--out anchors.json] anchors for `agent-cli.mjs verify --anchors`, each read through the AIP-23 verifier
// Iesiri: 0 da; 1 nu (un cap nu e al registrului, notarizarea sau finalitatea pica); 2 folosire gresita sau NEMASURAT (finalitate
// in asteptare, verificator lipsa).
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { spawn } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
export const HEAD_KIND = 'aere-agent-ledger-head';
export const NOTARI = { 2800: '0x4aB392c4Aca7D9D4C16c0b60a9514c5025bd58c7', 28001: '0x70099E62735500AA2F85B60C518551a57B202d54' };
const AICI = path.dirname(fileURLToPath(import.meta.url));
const sha = (s) => '0x' + crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex');
const H32 = /^0x[0-9a-f]{64}$/;
const HASH_INTRARE = /^[0-9a-f]{64}$/; // hash-ul unei intrari de registru: 64 hex, fara 0x (agent-ledger.mjs)
/** Capul registrului la `seq` (implicit ultima intrare), ca plic AIP-23. Declaratia numeste agentul, sesiunea, politica, seq si hash-ul. */
export function ledgerHeadEnvelope(ledger, { seq = null, createdAt = new Date().toISOString() } = {}) {
if (!ledger || !Array.isArray(ledger.entries) || !ledger.entries.length) throw new Error('agent-ancora: a ledger with at least one entry is required');
const s = seq == null ? ledger.entries.length - 1 : Number(seq);
const e = ledger.entries[s];
if (!Number.isInteger(s) || !e || e.seq !== s || !HASH_INTRARE.test(String(e.hash))) throw new Error(`agent-ancora: the ledger has no entry ${seq}`);
const statement = { v: 1, kind: HEAD_KIND, agentId: ledger.agentId, session: ledger.session, policyHash: String(ledger.policyHash).toLowerCase(), seq: s, hash: e.hash, createdAt };
return { v: 1, kind: HEAD_KIND + '-attestation', statement, statementHash: sha(JSON.stringify(statement)) };
}
/** Plicul e un cap de registru intreg (statementHash se reface) si numeste o intrare a ACESTUI registru? */
export function headMatchesLedger(env, ledger) {
const st = env && env.statement;
if (!st || env.kind !== HEAD_KIND + '-attestation' || st.kind !== HEAD_KIND) return { ok: false, motiv: `not an ${HEAD_KIND} attestation` };
if (!H32.test(String(env.statementHash)) || sha(JSON.stringify(st)) !== env.statementHash) return { ok: false, motiv: 'the statementHash does not match the statement (modified envelope)' };
if (st.agentId !== ledger.agentId || st.session !== ledger.session || String(st.policyHash).toLowerCase() !== String(ledger.policyHash).toLowerCase()) return { ok: false, motiv: 'the head names another agent, session or policy' };
const e = Number.isInteger(st.seq) ? ledger.entries[st.seq] : null;
if (!e || e.hash !== st.hash) return { ok: false, motiv: `the head names seq ${st.seq} with a hash this ledger does not have there (another branch or another ledger)` };
return { ok: true, seq: st.seq, hash: st.hash };
}
/** Cheia secp256k1 dintr-un fisier: hex singur (cu sau fara 0x) sau un rand `d=` / `PRIVATE_KEY=`; nu se tipareste niciodata. */
export function cheiaDinFisier(f) {
const randuri = fs.readFileSync(f, 'utf8').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
const r = randuri.find((l) => /^(d|PRIVATE_KEY)=/.test(l)) || (randuri.length === 1 ? randuri[0] : '');
const hex = r.replace(/^(d|PRIVATE_KEY)=/, '').replace(/^0x/, '').trim();
if (!/^[0-9a-fA-F]{1,64}$/.test(hex)) throw new Error('the key file holds no secp256k1 key (a hex key, or a d= / PRIVATE_KEY= line)');
return '0x' + hex.padStart(64, '0');
}
/** Pune statementHash-ul capului pe AereNotary. Lantul se citeste de pe RPC, nu se crede din argument; cheia din fisier, netiparita. */
export async function notarizeHead({ envelope, rpc, keyFile, ethers, notary = null, confirmMainnet = process.env.AERE_CONFIRM_MAINNET === 'yes' }) {
const taie = (s) => String(s ?? '').replace(/(0x)?[0-9a-fA-F]{40,}/g, '<hex>').slice(0, 300);
const st = envelope && envelope.statement;
if (!st || envelope.kind !== HEAD_KIND + '-attestation' || st.kind !== HEAD_KIND || !H32.test(String(envelope.statementHash))) return { ok: false, cod: 2, motiv: `REFUSED: not an ${HEAD_KIND} attestation with a 32-byte statementHash` };
if (sha(JSON.stringify(st)) !== envelope.statementHash) return { ok: false, cod: 1, motiv: 'REFUSED: statementHash does not match the statement (modified envelope)' };
try {
const provider = new ethers.JsonRpcProvider(rpc);
const chainId = Number((await provider.getNetwork()).chainId);
if (chainId === 2800 && !confirmMainnet) return { ok: false, cod: 3, motiv: 'REFUSED: this RPC serves chain 2800 (Aere Network mainnet); a notarization there is a real transaction paid by the key\'s account. Set AERE_CONFIRM_MAINNET=yes to send it.' };
const adr = notary || NOTARI[chainId];
if (!adr || !/^0x[0-9a-fA-F]{40}$/.test(adr)) return { ok: false, cod: 2, motiv: `REFUSED: no known notary on chain ${chainId}; pass --notary` };
const cod = await provider.getCode(adr);
if (!cod || cod === '0x') return { ok: false, cod: 1, motiv: `REFUSED: there is no contract at ${adr} on chain ${chainId}` };
const wallet = new ethers.Wallet(cheiaDinFisier(keyFile), provider);
const c = new ethers.Contract(adr, ['function notarize(bytes32 h) external', 'function firstSeen(bytes32) view returns (uint64)'], wallet);
let txHash = null, block = null;
if (!(Number(await c.firstSeen(envelope.statementHash)) > 0)) {
const tx = await c.notarize(envelope.statementHash); const rc = await tx.wait(1, 180000);
if (!rc || rc.status !== 1) return { ok: false, cod: 1, motiv: 'the notarization transaction failed' };
txHash = rc.hash; block = rc.blockNumber;
}
const firstSeen = Number(await c.firstSeen(envelope.statementHash));
if (!(firstSeen > 0)) return { ok: false, cod: 1, motiv: 'firstSeen is still 0 after the notarization' };
return { ok: true, envelope: { ...envelope, notarization: { chainId, notary: adr, firstSeen, ...(txHash ? { txHash, block } : { already: true }) } } };
} catch (e) { return { ok: false, cod: 1, motiv: 'the notarization failed: ' + taie(e.shortMessage || e.message) }; }
}
// verificatorul AIP-23 intoarce momentul in detaliul nivelului de finalitate, "as first seen at <ISO> (<unix>)"; il citim de acolo si
// cerem ca cele doua forme sa fie acelasi moment. Daca forma detaliului se schimba, raspunsul e NEMASURAT, nu o ancora ghicita.
const PRIMA_VEDERE = /as first seen at (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z) \((\d+)\)/;
export function firstSeenFromVerdict(v) {
const f = v && Array.isArray(v.levels) ? v.levels.find((x) => x.level === 'finality') : null;
if (!f) return { stare: 'NEMASURAT', motiv: 'the verifier gave no finality level' };
if (f.state !== 'PASSED') return { stare: f.state === 'FAILED' ? 'FAILED' : 'NEMASURAT', motiv: `finality ${f.state}: ${String(f.detail).slice(0, 200)}` };
const m = PRIMA_VEDERE.exec(String(f.detail));
if (!m || Math.floor(Date.parse(m[1]) / 1000) !== Number(m[2])) return { stare: 'NEMASURAT', motiv: 'finality PASSED, but its detail does not state the first-seen time in the form this tool reads' };
return { stare: 'PASSED', at: Number(m[2]) };
}
/** Ruleaza verificatorul AIP-23 pe un plic si intoarce JSON-ul lui (sau null). */
export function ruleazaVerificatorul(verifyProof, envFile, { rpc, chain }) {
return new Promise((ok) => {
const args = [verifyProof, envFile, '--json', ...(rpc ? ['--rpc', rpc] : []), ...(chain ? ['--chain', String(chain)] : [])];
const c = spawn(process.execPath, args); let out = '';
c.stdout.on('data', (d) => { out += d; }); c.stderr.on('data', () => {});
const ceas = setTimeout(() => c.kill(), 300000);
c.on('error', () => { clearTimeout(ceas); ok(null); });
c.on('close', () => { clearTimeout(ceas); try { ok(JSON.parse(out)); } catch { ok(null); } });
});
}
/**
* Ancorele registrului din capete notarizate: fiecare cap trebuie sa fie al registrului si sa aiba finalitate post-cuantica PASSED
* la verificatorul AIP-23 (rulat de `ruleaza`, implicit verify-proof.mjs). Intoarce {stare: 'OK'|'FAILED'|'NEMASURAT', anchors, randuri}.
*/
export async function anchorsFromNotarizedHeads(ledger, envelopes, { verifyProof, rpc, chain = null, ruleaza = ruleazaVerificatorul, tmpDir = null } = {}) {
const anchors = [], randuri = []; let stare = 'OK';
const dir = fs.mkdtempSync(path.join(tmpDir || os.tmpdir(), 'aere-agent-ancora-'));
try {
for (const env of envelopes) {
const m = headMatchesLedger(env, ledger);
if (!m.ok) { randuri.push({ head: env && env.statementHash, pass: false, detail: m.motiv }); stare = 'FAILED'; continue; }
const f = path.join(dir, `${m.seq}.json`); fs.writeFileSync(f, JSON.stringify(env));
// lantul: cel cerut de verificator; altfel cel declarat de plic (spus in ancora, ca un timp de testnet sa nu treaca drept unul de 2800)
const lant = chain || (env.notarization && Number(env.notarization.chainId)) || null;
const v = await ruleaza(verifyProof, f, { rpc, chain: lant });
if (!v) { randuri.push({ head: env.statementHash, seq: m.seq, pass: null, detail: 'the AIP-23 verifier did not answer' }); if (stare === 'OK') stare = 'NEMASURAT'; continue; }
if (v.statementHash && String(v.statementHash).toLowerCase() !== env.statementHash) { randuri.push({ head: env.statementHash, seq: m.seq, pass: false, detail: 'the verifier judged another statementHash' }); stare = 'FAILED'; continue; }
const fs2 = firstSeenFromVerdict(v);
if (fs2.stare === 'FAILED') { randuri.push({ head: env.statementHash, seq: m.seq, pass: false, detail: fs2.motiv }); stare = 'FAILED'; continue; }
if (fs2.stare !== 'PASSED') { randuri.push({ head: env.statementHash, seq: m.seq, pass: null, detail: fs2.motiv }); if (stare === 'OK') stare = 'NEMASURAT'; continue; }
anchors.push({ seq: m.seq, hash: m.hash, at: fs2.at, statementHash: env.statementHash, chainId: lant, chainAsked: !!chain, witness: 'AereNotary first-seen under a post-quantum certified anchor' });
randuri.push({ head: env.statementHash, seq: m.seq, pass: true, detail: `seq ${m.seq} first seen on chain ${lant}${chain ? '' : ' (the chain the envelope declares; pass --chain to require one)'} at ${new Date(fs2.at * 1000).toISOString()}` });
}
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
return { stare, anchors: anchors.sort((a, b) => a.seq - b.seq), randuri };
}
// ---------------------------------------------------------------- linia de comanda
class Folosire extends Error {}
async function main(argv) {
const [cmd, ...rest] = argv;
const get = (f) => { const i = rest.indexOf(f); return i >= 0 ? rest[i + 1] : undefined; };
const toate = (f) => rest.flatMap((a, i) => (a === f && rest[i + 1] != null ? [rest[i + 1]] : []));
const citeste = (f, ce) => { if (!f) throw new Folosire(`--${ce} is required`); try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { throw new Folosire(`cannot read ${ce} file ${f}: ${e.message}`); } };
const scrie = (o, f) => { const s = JSON.stringify(o, null, 1) + '\n'; if (f) fs.writeFileSync(f, s); else process.stdout.write(s); };
if (cmd === 'head') {
const env = ledgerHeadEnvelope(citeste(get('--ledger'), 'ledger'), { seq: get('--seq') != null ? Number(get('--seq')) : null });
scrie(env, get('--out')); if (get('--out')) console.log(`head seq ${env.statement.seq}: ${env.statementHash}`); return 0;
}
if (cmd === 'notarize') {
if (!get('--rpc') || !get('--key-file')) throw new Folosire('notarize --head head.json --rpc URL --key-file f [--notary 0x..] [--out f]');
const { incarcaEthers } = await import(pathToFileURL(path.join(AICI, 'x402', 'wallet.mjs')).href);
let ethers; try { ethers = incarcaEthers(); } catch (e) { console.error(e.message); return 2; }
const r = await notarizeHead({ envelope: citeste(get('--head'), 'head'), rpc: get('--rpc'), keyFile: get('--key-file'), ethers, notary: get('--notary') || null });
if (!r.ok) { console.error(r.motiv); return r.cod; }
scrie(r.envelope, get('--out'));
const n = r.envelope.notarization; console.log(`notarized on chain ${n.chainId} at ${n.notary}: firstSeen ${n.firstSeen}${n.txHash ? `, block ${n.block}` : ' (already notarized)'}`);
return 0;
}
if (cmd === 'anchors') {
const verifyProof = get('--verify-proof') || process.env.AERE_VERIFY_PROOF;
if (!verifyProof || !fs.existsSync(verifyProof)) { console.error('NEMASURAT: the AIP-23 reference verifier is needed (--verify-proof <verify-proof.mjs> or AERE_VERIFY_PROOF)'); return 2; }
if (!get('--rpc')) throw new Folosire('anchors needs --rpc <url>: the chain is read, not trusted from the envelope');
const heads = toate('--head'); if (!heads.length) throw new Folosire('anchors needs at least one --head notarized.json');
const r = await anchorsFromNotarizedHeads(citeste(get('--ledger'), 'ledger'), heads.map((f) => citeste(f, 'head')), { verifyProof, rpc: get('--rpc'), chain: get('--chain') ? Number(get('--chain')) : null });
for (const x of r.randuri) console.error(`${x.pass === true ? 'ok' : x.pass === false ? 'FAIL' : '--'} ${x.detail}`);
if (r.stare === 'OK') scrie(r.anchors, get('--out'));
console.error(r.stare === 'OK' ? `${r.anchors.length} anchor(s): pass them to agent-cli.mjs verify --anchors` : r.stare === 'FAILED' ? 'FAILED: no anchors written' : 'NOT MEASURED: no anchors written (finality pending or the verifier did not answer)');
return r.stare === 'OK' ? 0 : r.stare === 'FAILED' ? 1 : 2;
}
throw new Folosire('usage: agent-ancora.mjs head|notarize|anchors ... (see README.md)');
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main(process.argv.slice(2)).then((c) => { process.exitCode = c; }, (e) => { console.error(`agent-ancora: ${e.message}`); process.exitCode = e instanceof Folosire ? 2 : 1; });
}