Dev.to Security 🔐 Cybersecurity 👁 0 📖 10 min read

What does a signed agent record actually prove?

What does a signed agent record actually prove? Agents are starting to do real work across company boundaries: request quotes, negotiate prices, place orders, approve payments. When one of those interactions goes wrong

What does a signed agent record actually prove?

Agents are starting to do real work across company boundaries: request quotes, negotiate prices, place orders, approve payments. When one of those interactions goes wrong, both sides will have logs. Both logs come from an interested party, and either side can edit its own.

One proposed fix, showing up in several protocols and products at once, is the signed record: messages signed by the sending agent's key, chained together by hash, often countersigned by a third party. I build one of these (OpenGlass, open source), so I have an obvious bias here, and also a strong incentive to be precise: this construction proves less than most marketing implies, and more than nothing.

This post builds a minimal witnessed session from scratch, in about 150 lines of dependency-free Node, and attacks it four ways. Each attack fails with a distinct error. Then the part that matters: what still isn't proven even when every signature verifies.

The model

Three parties: two agents (buyer and supplier), each with an Ed25519 keypair, and a witness with its own key (ECDSA P-256). Per message:

  1. The envelope holds the sender, a sequence number, the hash of the previous message, and the hash of the payload. Not the payload itself.
  2. The message hash chains the envelope to the prior state: hash = sha256(prevHash || canonical(envelope)).
  3. The sending agent's key signs that hash.
  4. The witness countersigns {hash, agent signature, receivedAt}.
  5. At the end, the witness signs a record statement over the final chain head.

Nothing here is new cryptography. The interesting question is semantic: what does a verifier know after checking all of it?

The experiment

The script (full source at the bottom of this post) needs Node 18+ and nothing else. It runs a five-message negotiation (quote request, quote, counter-offer, acceptance, purchase order), closes the record, and verifies it with a checker written as if it had never seen the session code.

Real output from running it:

Experiment 0 - untampered record
{ valid: true, errors: [] }

Tamper 1 - edit one payload (4.00 -> 3.50)
{ valid: false, errors: [ 'payload_hash (seq 4)' ] }

Tamper 2 - delete one message (the counter-offer)
{ valid: false,
  errors: [ 'prev_hash (seq 4)', 'message_hash (seq 4)',
            'message_signature (seq 4)', 'countersignature (seq 4)',
            'message_count' ] }

Tamper 3 - edit the record statement (messageCount 5 -> 99)
{ valid: false, errors: [ 'statement_hash', 'message_count' ] }

Tamper 4 - recompute every hash around a forged acceptance, keep old signatures
{ valid: false,
  errors: [ 'record_signature', 'message_signature (seq 4)',
            'countersignature (seq 4)', 'message_signature (seq 5)',
            'countersignature (seq 5)' ] }

Each failure is named precisely. Edit the content and it no longer matches its committed hash. Delete a message and the next message's prevHash points at nothing, cascading downstream. Edit the witness's statement and it no longer matches its own hash or the chain. And the last case is the load-bearing one. There the forger does everything the cryptography allows without private keys: it rewrites the acceptance, then recomputes every hash in the system to fit the forged history - payload hash, chain links, message hashes, even the head in the record statement. Every hash check passes. The only errors left are signature errors, at and after the forgery, because fixing those needs the supplier's and the witness's private keys. Hashes alone are malleable; signatures by keys the attacker doesn't hold are what make edits detectable.

What this establishes, under stated assumptions

Assume: the signing keys weren't compromised, the witness isn't colluding with either party, and the verifier got the public keys from a source it trusts. Then a valid: true result establishes:

  1. Integrity. The content matches the hashes and signatures committed to it. Any bit flip since signing breaks the check.
  2. Attribution to a key. Each message was signed by whoever controlled a specific private key at signing time. Not "by the company", and not proof of who controlled the key or that the holder had authority to act. (In OpenGlass, an agent is claimed by an owner through a verified email, which links the registration to an accountable account. That is an administrative link, not proof of legal authority or of continuous key control.)
  3. Internal order and self-consistency. The chain fixes the sequence of what it contains, and the signed statement fixes the message count, so the record is complete relative to itself.
  4. A witness's attestation. The witness's countersignature is its claim that it saw these bytes, in this order, at the times it stamped. Trusting those times means trusting the witness's clock; this demo has no external timestamp anchor, and neither does the current OpenGlass spec (its design log lists RFC 3161 anchoring as not yet built, decision D15).
  5. Independent verifiability. Anyone with the record and the public keys can check everything offline, without asking the witness. Whether the counterparty actually received and kept its copy is a delivery fact the bytes themselves don't prove.

What it does not prove

  1. That the agent's decision was right. A signed record of an agent buying 10 units at $99 is evidence of what happened, not of whether injected content manipulated the agent into it. Input provenance is a different layer.
  2. That the real-world effect happened. A signed purchase order is not a delivered pallet. The record covers the recorded interaction, not the world.
  3. That nothing happened outside the record. A phone call, an email thread, or a parallel session leaves no trace here. The chain proves its own completeness, not the completeness of the parties' dealings.
  4. That the signer didn't fork. A malicious or compromised signer can sign two different chains and show each to a different party. Verification catches edits to one chain; catching equivocation takes comparing copies between parties (which is an argument for both parties holding the record, and for a witness that publishes or countersigns a single ordered history).
  5. That only the checked action was committed. A signed receipt is evidence that a decision was recorded. Whether a deployment enforces that decision atomically, on every execution path, is a separate assurance property no record format provides. The AP2 community has been working through exactly this point in its execution-time revalidation discussions.
  6. Compromise and collusion degrade specific assurances, not all of them equally. A stolen agent key lets an attacker rewrite that agent's messages, but not the counterparty's. A colluding witness can lie about what it saw and when, but cannot forge either agent's signature. "Neither party can quietly alter this" holds only while keys are uncompromised and the witness stays honest - operational conditions, not mathematical ones.

The honest summary: within those assumptions, a signed, countersigned, hash-chained record turns "our logs say" into "here is something neither of us can quietly alter now, check it yourself." That is a real upgrade in a dispute. It is not a guarantee of correctness, fairness, or enforcement.

Demo assumptions, stated

The toy script simplifies on purpose: sorted-key canonical JSON instead of full RFC 8785 (JCS), freshly generated in-memory keys, a single witness key with no rotation or validity windows, no revocation, and the witness's own clock for timestamps. OpenGlass's spec uses JCS, platform keys with validity windows, key revocation, and S3 Object Lock retention, and it documents its own open gaps: sessions are exactly two agents (N-party is a stated non-goal so far, decision D7) and there is no external timestamp anchoring yet (D15). Sources: the spec and design decisions at https://github.com/federico2001/OpenGlass/blob/main/docs/SPEC.md, the public limits page at https://openglass.glass/trust, and a runnable version of this same flow against the real API at https://github.com/federico2001/OpenGlass/tree/main/examples/witnessed-negotiation.

But you don't need anyone's service to understand the primitive. Run the script, break it, read the errors. The next time a vendor says "tamper-proof agent records," you'll know which questions to ask: signed by which key, chained to what, verified by whom, and proven about what.

Full source (demo.mjs)

import crypto from 'node:crypto';

const H = (bytes) => crypto.createHash('sha256').update(bytes).digest();
const hex = (b) => Buffer.from(b).toString('hex');

// Canonical JSON: sorted keys, no whitespace.
// (A JCS/RFC 8785 subset, fine for this demo's data. Production systems
// should use a real JCS implementation and never hash JSON.stringify output.)
function canon(x) {
  if (x === null || typeof x !== 'object') return JSON.stringify(x);
  if (Array.isArray(x)) return '[' + x.map(canon).join(',') + ']';
  return '{' + Object.keys(x).sort().map(k => JSON.stringify(k) + ':' + canon(x[k])).join(',') + '}';
}

// Purpose prefix: a signature made for one purpose can't be reused for another.
function sigInput(purpose, digestHex) {
  return Buffer.concat([Buffer.from(purpose + ':', 'utf8'), Buffer.from(digestHex, 'hex')]);
}

const buyer = crypto.generateKeyPairSync('ed25519');
const supplier = crypto.generateKeyPairSync('ed25519');
const platform = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });

// A synthetic clock: all timestamps in this demo are simulated so the
// narrative ordering is explicit. Production timestamps come from real clocks.
const T0 = Date.parse('2026-11-12T12:00:00Z');

const GENESIS = hex(H('genesis:buyer+supplier:po-1042'));
let prevHash = GENESIS;
const messages = [];

function send(agent, keys, payload) {
  const envelope = {
    seq: messages.length + 1,
    prevHash,
    from: agent,
    payloadHash: hex(H(Buffer.from(canon(payload), 'utf8'))),
    sentAt: new Date(T0 + messages.length * 1000).toISOString(),
  };
  const hash = hex(H(Buffer.concat([
    Buffer.from(prevHash, 'hex'), Buffer.from(canon(envelope), 'utf8')
  ])));
  const signature = { alg: 'Ed25519', sig: crypto.sign(null, sigInput('message', hash), keys.privateKey).toString('base64url') };
  const receivedAt = new Date(T0 + messages.length * 1000 + 100).toISOString();
  const countersignDigest = hex(H(Buffer.from(canon({ hash, agentSig: signature.sig, receivedAt }), 'utf8')));
  const platformSignature = {
    alg: 'ECDSA_P256_SHA256',
    sig: crypto.sign('sha256', sigInput('countersign', countersignDigest), platform.privateKey).toString('base64url'),
  };
  messages.push({ envelope, hash, signature, receivedAt, platformSignature, payload });
  prevHash = hash;
}

send('buyer', buyer, { type: 'rfq', item: 'bracket', qty: 100, maxUnitPrice: 4.5 });
send('supplier', supplier, { type: 'quote', item: 'bracket', qty: 100, unitPrice: 4.2 });
send('buyer', buyer, { type: 'counter', unitPrice: 4.0 });
send('supplier', supplier, { type: 'accept', unitPrice: 4.0, qty: 100 });
send('buyer', buyer, { type: 'purchase_order', po: 'PO-1042', qty: 100, unitPrice: 4.0, total: 400 });

// Close: the witness signs a statement over the final head.
const statement = {
  v: 1, type: 'record', session: 'po-1042',
  participants: ['buyer', 'supplier'],
  headSeq: messages.length, headHash: prevHash, messageCount: messages.length,
  issuedAt: new Date(T0 + messages.length * 1000 + 5000).toISOString(), // after the last receive
};
const statementHash = hex(H(Buffer.from(canon(statement), 'utf8')));
const record = {
  statement, statementHash,
  platformSignature: { alg: 'ECDSA_P256_SHA256', sig: crypto.sign('sha256', sigInput('record', statementHash), platform.privateKey).toString('base64url') },
};

// The verifier. It gets the record, the messages, and public keys from
// outside the bundle. Nothing else. Payloads may be omitted entirely
// (notary-style privacy): the chain still verifies, but the verifier is then
// checking content commitments, not content - it cannot tell whether the
// omitted payloads ever said what any party claims they said.
function verifyRecord(record, messages, platformPub, agentPubs) {
  const errors = [];
  const require = (ok, code, seq) => { if (!ok) errors.push(seq ? `${code} (seq ${seq})` : code); };
  const platVerify = (purpose, digestHex, sigB64) => {
    try { return crypto.verify('sha256', sigInput(purpose, digestHex), platformPub, Buffer.from(sigB64, 'base64url')); }
    catch { return false; }
  };

  require(hex(H(Buffer.from(canon(record.statement), 'utf8'))) === record.statementHash, 'statement_hash');
  require(platVerify('record', record.statementHash, record.platformSignature.sig), 'record_signature');

  let prev = GENESIS;
  for (const m of messages) {
    const env = m.envelope;
    require(env.prevHash === prev, 'prev_hash', env.seq);
    if (m.payload !== undefined) {
      require(env.payloadHash === hex(H(Buffer.from(canon(m.payload), 'utf8'))), 'payload_hash', env.seq);
    }
    const h = hex(H(Buffer.concat([Buffer.from(prev, 'hex'), Buffer.from(canon(env), 'utf8')])));
    require(h === m.hash, 'message_hash', env.seq);
    require(crypto.verify(null, sigInput('message', h), agentPubs[env.from], Buffer.from(m.signature.sig, 'base64url')), 'message_signature', env.seq);
    const cd = hex(H(Buffer.from(canon({ hash: h, agentSig: m.signature.sig, receivedAt: m.receivedAt }), 'utf8')));
    require(platVerify('countersign', cd, m.platformSignature.sig), 'countersignature', env.seq);
    prev = m.hash;
  }

  require(record.statement.messageCount === messages.length, 'message_count');
  require(record.statement.headHash === prev, 'head_hash');
  return { valid: errors.length === 0, errors };
}

const agentPubs = { buyer: buyer.publicKey, supplier: supplier.publicKey };

console.log('Experiment 0 - untampered record');
console.log(verifyRecord(record, messages, platform.publicKey, agentPubs));

// Tamper 1: edit the accepted price
const t1 = structuredClone(messages);
t1[3].payload.unitPrice = 3.5;
console.log('\nTamper 1 - edit one payload (4.00 -> 3.50)');
console.log(verifyRecord(record, t1, platform.publicKey, agentPubs));

// Tamper 2: delete the counter-offer
const t2 = structuredClone(messages);
t2.splice(2, 1);
console.log('\nTamper 2 - delete one message (the counter-offer)');
console.log(verifyRecord(record, t2, platform.publicKey, agentPubs));

// Tamper 3: forge the record statement
const r3 = structuredClone(record);
r3.statement.messageCount = 99;
console.log('\nTamper 3 - edit the record statement (messageCount 5 -> 99)');
console.log(verifyRecord(r3, messages, platform.publicKey, agentPubs));

// Tamper 4: the forger rewrites the acceptance AND recomputes the whole
// chain to fit it - every payloadHash, prevHash, message hash, the head in
// the record statement - but is stuck keeping every original signature,
// because it holds no private keys.
const t4 = structuredClone(messages);
t4[3].payload = { type: 'accept', unitPrice: 0.01, qty: 100 };
t4[3].envelope.payloadHash = hex(H(Buffer.from(canon(t4[3].payload), 'utf8')));
let p4 = GENESIS;
for (const m of t4) {
  m.envelope.prevHash = p4;
  m.hash = hex(H(Buffer.concat([Buffer.from(p4, 'hex'), Buffer.from(canon(m.envelope), 'utf8')])));
  p4 = m.hash;
}
const r4 = structuredClone(record);
r4.statement.headHash = p4;
r4.statementHash = hex(H(Buffer.from(canon(r4.statement), 'utf8')));
console.log('\nTamper 4 - recompute every hash around a forged acceptance, keep old signatures');
console.log(verifyRecord(r4, t4, platform.publicKey, agentPubs));
📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.