// AERE Agent Approval (roadmap punctul 22, "aprobarea umana, revocarea"): doua lucruri pe care un agent AI NU le poate face singur, // semnate de oameni cu chei post-cuantice (ML-DSA-65) si verificabile de oricine. // // APROBAREA: politica agentului numeste aprobatorii (id-uri 'aere-human:' derivate din cheie, legate prin hash-ul politicii) si // pragul k; o actiune care cere aprobare (o plata peste un prag, o unealta numita) trece numai cu k aprobari VALIDE de la aprobatori // distincti. O aprobare semneaza EXACT continutul actiunii (fel, destinatar, suma, activ, unealta, argumentele uneltei prin hash; // nu momentul, pe care il pune registrul), agentul si politica, are o fereastra de valabilitate [issuedAt, expiresAt] si un nonce // care se poate folosi O SINGURA DATA in registru. Deci o aprobare pentru 50 catre X nu aproba 500 catre Y, un deploy pe staging nu // aproba unul pe production, nu se reutilizeaza si nu se muta la alt agent. Registrul unui agent sub o politica e UNUL singur // (sesiunea lui e derivata din agent si politica, agent-ledger.mjs), deci o aprobare folosita de doua ori in "doua registre" e // folosita in doua RAMURI ale aceluiasi registru, si doua ramuri semnate de agent sunt o dovada de echivocare (findEquivocation). // // REVOCAREA: proprietarul numit in politica semneaza "agentul e revocat de la momentul T"; de atunci orice actiune e refuzata. Un // agent isi tine propriul registru, deci ar putea omite revocarea din el: verificatorul primeste revocarile SEPARAT (de la proprietar) // si spune fata de ce set a judecat. Fara revocari date, verificatorul nu poate vedea o revocare omisa, si o spune. // // 2026-09-29 (forma 2, revizuirea adversariala B-17): datele si mesajele in engleza; aprobarea leaga hash-ul argumentelor uneltei // (masurat pe forma 1: aprobarea unui deploy pe staging a trecut pe production). Tot pe forma 1, o singura aprobare pentru 5000 a trecut // in DOUA registre ale aceluiasi agent, fiindca nonce-ul era unic numai pe registru: de acum cele doua registre sunt doua ramuri ale // aceluiasi registru, iar ramificarea e dovedibila cu doua intrari semnate. Ce ramane, spus: cine vede O SINGURA ramura nu o poate // deosebi de registru; o deosebeste numai un martor al capului (`anchors` in verifyLedger) sau cine vede ambele ramuri. // // Numai Node 24 (crypto ML-DSA). Nu atinge reteaua. Cheile private nu ies din obiectele lor. import crypto from 'node:crypto'; import { canonical } from './agent-policy.mjs'; export const VERSION = 'aere-agent-approval/2 (2026-09-29)'; const ALG = 'ml-dsa-65'; const FEREASTRA_MAXIMA_S = 7 * 86400; // o aprobare nu poate fi valabila mai mult de o saptamana const sha = (s) => crypto.createHash('sha256').update(typeof s === 'string' ? Buffer.from(s, 'utf8') : s).digest('hex'); /** id-ul unui om derivat din cheia lui publica (acelasi fel ca agentId, alt prefix: un om nu poate fi confundat cu un agent). */ export function humanIdFromKey(publicKey) { return 'aere-human:' + sha(publicKey.export({ type: 'spki', format: 'der' })).slice(0, 40); } export function newHumanIdentity() { const { publicKey, privateKey } = crypto.generateKeyPairSync(ALG); return { humanId: humanIdFromKey(publicKey), publicKey, privateKey, publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }) }; } /** Identitatea unui om din cheia lui privata (PEM PKCS#8); cheia privata nu iese din obiect. */ export function humanFromPrivateKeyPem(pem) { const privateKey = crypto.createPrivateKey(pem); if (privateKey.asymmetricKeyType !== ALG) throw new Error('agent-approval: the key is not ML-DSA-65'); const publicKey = crypto.createPublicKey(privateKey); return { privateKey, publicKey, publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }), humanId: humanIdFromKey(publicKey) }; } /** Continutul actiunii care se aproba: fara momentul ei (il pune registrul la inregistrare); argumentele uneltei prin hash. */ export function actionContent(a) { const c = { kind: String(a.kind) }; if (a.to != null) c.to = String(a.to).toLowerCase(); if (a.amount != null) c.amount = String(a.amount); if (a.asset != null) c.asset = String(a.asset); if (a.tool != null) c.tool = String(a.tool); if (a.args !== undefined) c.argsHash = sha(canonical(a.args)); return c; } export const actionHash = (a) => sha(canonical(actionContent(a))); function semneaza(corp, privateKey) { return crypto.sign(null, Buffer.from(canonical(corp), 'utf8'), privateKey).toString('base64'); } function cheiaSemnatarului(pem) { let k; try { k = crypto.createPublicKey(pem); } catch { return null; } return k.asymmetricKeyType === ALG ? k : null; // numai ML-DSA-65: o cheie clasica nu aproba nimic } /** O aprobare umana pentru o actiune a unui agent. */ export function approve({ human, agentId, policyHash, action, nonce = crypto.randomBytes(16).toString('hex'), issuedAt, expiresAt }) { if (!human || !human.privateKey) throw new Error('agent-approval: the approver with their key is required'); const corp = { v: 2, kind: 'aere-agent-approval', agentId, policyHash: String(policyHash).toLowerCase(), actionHash: actionHash(action), nonce: String(nonce), issuedAt: Number(issuedAt), expiresAt: Number(expiresAt), approverPem: human.publicKeyPem }; return { ...corp, signature: semneaza(corp, human.privateKey) }; } /** * Verifica o aprobare pentru o actiune judecata la momentul `at`. NU se uita la nonce (unicitatea o tine registrul, pe tot lantul lui). * @returns {{ok:boolean, humanId?:string, error?:string}} */ export function verifyApproval(ap, { policy, policyHash, action, at }) { if (!ap || ap.kind !== 'aere-agent-approval' || ap.v !== 2) return { ok: false, error: 'not an aere-agent-approval v2' }; if (!policy || !policy.approval) return { ok: false, error: 'the policy asks for no approvals' }; if (ap.agentId !== policy.agentId) return { ok: false, error: 'the approval is for another agent' }; if (String(ap.policyHash).toLowerCase() !== String(policyHash).toLowerCase()) return { ok: false, error: 'the approval is under another policy' }; if (ap.actionHash !== actionHash(action)) return { ok: false, error: 'the approval is for another action (kind, recipient, amount, asset, tool or tool arguments)' }; const e = Number(ap.issuedAt), x = Number(ap.expiresAt), t = Number(at); if (!Number.isFinite(e) || !Number.isFinite(x) || !(x > e) || x - e > FEREASTRA_MAXIMA_S) return { ok: false, error: 'the approval window is invalid or longer than seven days' }; if (!(t >= e && t <= x)) return { ok: false, error: `the action (${t}) is outside the approval window [${e}, ${x}]` }; if (!ap.nonce || typeof ap.nonce !== 'string') return { ok: false, error: 'the approval has no nonce' }; const k = cheiaSemnatarului(ap.approverPem); if (!k) return { ok: false, error: 'the approver key is not ML-DSA-65' }; const humanId = humanIdFromKey(k); if (!policy.approval.approvers.includes(humanId)) return { ok: false, error: `approver ${humanId} is not named in the policy` }; const { signature, ...corp } = ap; let sig = false; try { sig = crypto.verify(null, Buffer.from(canonical(corp), 'utf8'), k, Buffer.from(String(signature), 'base64')); } catch { sig = false; } if (!sig) return { ok: false, error: 'the approval signature does not verify' }; return { ok: true, humanId }; } /** Revocarea unui agent, semnata de proprietarul numit in politica. */ export function revoke({ owner, agentId, policyHash, revokedAt, reason = '' }) { if (!owner || !owner.privateKey) throw new Error('agent-approval: the owner with their key is required'); const corp = { v: 2, kind: 'aere-agent-revocation', agentId, policyHash: String(policyHash).toLowerCase(), revokedAt: Number(revokedAt), reason: String(reason).slice(0, 200), ownerPem: owner.publicKeyPem }; return { ...corp, signature: semneaza(corp, owner.privateKey) }; } /** @returns {{ok:boolean, revokedAt?:number, error?:string}} */ export function verifyRevocation(rv, { policy, policyHash }) { if (!rv || rv.kind !== 'aere-agent-revocation' || rv.v !== 2) return { ok: false, error: 'not an aere-agent-revocation v2' }; if (!policy || !policy.owner) return { ok: false, error: 'the policy names no owner' }; if (rv.agentId !== policy.agentId) return { ok: false, error: 'the revocation is for another agent' }; if (String(rv.policyHash).toLowerCase() !== String(policyHash).toLowerCase()) return { ok: false, error: 'the revocation is under another policy' }; if (!Number.isFinite(Number(rv.revokedAt))) return { ok: false, error: 'the revocation time is not a number' }; const k = cheiaSemnatarului(rv.ownerPem); if (!k) return { ok: false, error: 'the owner key is not ML-DSA-65' }; if (humanIdFromKey(k) !== policy.owner) return { ok: false, error: 'the revocation is not signed by the owner named in the policy' }; const { signature, ...corp } = rv; let sig = false; try { sig = crypto.verify(null, Buffer.from(canonical(corp), 'utf8'), k, Buffer.from(String(signature), 'base64')); } catch { sig = false; } if (!sig) return { ok: false, error: 'the revocation signature does not verify' }; return { ok: true, revokedAt: Number(rv.revokedAt) }; } /** Cea mai timpurie revocare valida dintr-o lista, plus cele respinse (cu motivul), plus amprenta setului judecat. */ export function validRevocations(list, ctx) { let revokedAt = null; const rejected = []; for (const rv of list || []) { const r = verifyRevocation(rv, ctx); if (r.ok) revokedAt = revokedAt == null ? r.revokedAt : Math.min(revokedAt, r.revokedAt); else rejected.push(r.error); } return { revokedAt, rejected, setHash: sha(canonical((list || []).map((x) => x && x.signature).sort())) }; }