An agent gets an ML-DSA-65 identity and a policy (spending per time window, allowed tools and recipients, which actions need human approval, who may revoke it). Every action it proposes is judged against the policy, signed by the agent and chained; a verifier that does not trust the agent re-runs the policy over the whole ledger. Approvals and revocations are signed by people with their own ML-DSA-65 keys. The ledger of an agent under a policy is one ledger: a second history is a branch, and two branches are a proof of equivocation anyone can check with the public key alone. The README says what the verifier cannot see: entry times are bounded from below only with a witness (anchors or a start time), and someone who sees one branch cannot know of another. Tests: policy 23/23 with the AIP-23 reference verifier (21 run without it), ledger 51/51, approval and revocation 39/39, command line 23/23; negative control 25/25.
128 lines
9.5 KiB
JavaScript
128 lines
9.5 KiB
JavaScript
// 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())) };
|
|
}
|