Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 27 additions & 23 deletions lib/verifyReceipt.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,15 @@ async function verifyReceipt(receiptInput, options = {}) {
}

const proof = receipt?.metadata?.proof || null;
const canonicalization = proof?.canonical || proof?.canonicalization || null;
const kid = receipt?.signature?.kid || proof?.kid || null;
const sig = receipt?.signature?.sig || proof?.signature || null;
const canonicalization = proof?.canonicalization || proof?.canonical || null;
const hashAlg = proof?.hash?.alg || null;
const hashValue = proof?.hash?.value || null;
const sigAlg = proof?.signature?.alg || null;
const kid = proof?.signature?.kid || null;
const sig = proof?.signature?.value || null;
const signerId = proof?.signer_id || null;

const hasLegacyTopLevelProof = Boolean(receipt?.signature?.kid || receipt?.signature?.sig || proof?.hash_sha256 || proof?.kid || proof?.signature === 'string');

const schemaValid = Boolean(
receipt &&
Expand All @@ -156,26 +162,25 @@ async function verifyReceipt(receiptInput, options = {}) {
typeof receipt.verb === 'string' &&
typeof receipt.ts === 'string' &&
canonicalization &&
hashAlg &&
hashValue &&
sigAlg &&
kid &&
sig,
sig &&
signerId,
);

const ens = await resolveSignerFromEns(receipt?.signer, options.ens || {});
const expectedHash = proof?.hash_sha256 || null;
const isLegacyMode = Boolean(expectedHash);
const expectedHash = hashValue;
const canonicalPayload = canonicalReceiptPayload(receipt);
const canonicalStr = canonicalize(canonicalPayload);
const recomputedHash = await sha256Hex(canonicalStr);

const expectedCanonical = ens.records['cl.sig.canonical'];
const canonicalizationOk = canonicalization === expectedCanonical;
const hashMatched = Boolean(
schemaValid &&
canonicalizationOk &&
isLegacyMode &&
typeof expectedHash === 'string' &&
expectedHash === recomputedHash,
);
const hashAlgOk = hashAlg == 'SHA-256';
const sigAlgOk = sigAlg === 'Ed25519';
const hashMatched = Boolean(schemaValid && canonicalizationOk && hashAlgOk && typeof expectedHash === 'string' && expectedHash === recomputedHash);

const keyIdMatched = kid === ens.records['cl.sig.kid'];
const prefixedPubkey = ens.records['cl.sig.pub'];
Expand All @@ -187,28 +192,24 @@ async function verifyReceipt(receiptInput, options = {}) {
if (keyIdMatched && pubkeyBase64 && sig) {
try {
const publicKey = await importEd25519PublicKey(pubkeyBase64);
if (isLegacyMode) {
if (hashMatched) {
signatureValid = await verifyHashHexSignature(recomputedHash, sig, publicKey);
}
} else {
signatureValid = await verifyCanonicalSignature(canonicalStr, sig, publicKey);
if (hashMatched) {
signatureValid = await verifyHashHexSignature(recomputedHash, sig, publicKey);
}
} catch {
signatureValid = false;
}
}

const signerMatched = Boolean(
ens.records['cl.receipt.signer'] && receipt?.signer === ens.records['cl.receipt.signer'],
);
const signerMatched = Boolean(ens.records['cl.receipt.signer'] && receipt?.signer === ens.records['cl.receipt.signer'] && signerId === ens.records['cl.receipt.signer']);

const ok = Boolean(
schemaValid &&
signatureValid &&
signerMatched &&
ens.ensResolved &&
(isLegacyMode ? hashMatched : true),
!hasLegacyTopLevelProof &&
hashMatched &&
sigAlgOk,
);

return {
Expand All @@ -225,6 +226,9 @@ async function verifyReceipt(receiptInput, options = {}) {
public_key_source: ens.keySource,
debug: {
expected_hash_sha256: expectedHash,
hash_alg_matched: hashAlgOk,
signature_alg_matched: sigAlgOk,
has_legacy_top_level_proof: hasLegacyTopLevelProof,
key_id_matched: keyIdMatched,
canonicalization_matched: canonicalizationOk,
signer_matched: signerMatched,
Expand Down
11 changes: 4 additions & 7 deletions tests/api-agents-verifyagent.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const sampleReceipt = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'examples', 'sample-receipt.json'), 'utf8')
);

test('POST /api/agents/verifyagent with valid sample => VERIFIED', async () => {
test('POST /api/agents/verifyagent with legacy sample => INVALID', async () => {
const req = { method: 'POST', body: { task: 'verify this receipt', receipt: sampleReceipt } };
const res = makeRes();

Expand All @@ -31,12 +31,9 @@ test('POST /api/agents/verifyagent with valid sample => VERIFIED', async () => {
assert.equal(res.statusCode, 200);
assert.equal(res.body.agent, 'verifyagent.eth');
assert.equal(res.body.action, 'verify_receipt');
assert.equal(res.body.ok, true);
assert.equal(res.body.status, 'VERIFIED');
assert.equal(res.body.result.reason, 'Receipt verification passed.');
assert.equal(res.body.result.hash_matches, true);
assert.equal(res.body.result.signature_valid, true);
assert.equal(res.body.result.ens_resolved, true);
assert.equal(res.body.ok, false);
assert.equal(res.body.status, 'INVALID');
assert.equal(res.body.result.reason, 'Receipt is invalid, tampered, or does not match the signer key metadata.');
});

test('POST /api/agents/verifyagent with tampered receipt => INVALID', async () => {
Expand Down
17 changes: 7 additions & 10 deletions tests/api-verify.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,32 +22,29 @@ const sampleReceipt = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'examples', 'sample-receipt.json'), 'utf8')
);

test('POST /api/verify with valid sample => VERIFIED', async () => {
test('POST /api/verify with legacy sample => INVALID', async () => {
const req = { method: 'POST', body: sampleReceipt };
const res = makeRes();

await handler(req, res);

assert.equal(res.statusCode, 200);
assert.equal(res.body.ok, true);
assert.equal(res.body.status, 'VERIFIED');
assert.equal(res.body.reason, 'Receipt verification passed.');
assert.equal(res.body.hash_matches, true);
assert.equal(res.body.signature_valid, true);
assert.equal(res.body.ens_resolved, true);
assert.equal(res.body.ok, false);
assert.equal(res.body.status, 'INVALID');
assert.equal(res.body.debug.has_legacy_top_level_proof, true);
});



test('POST /api/verify with wrapped receipt payload => VERIFIED', async () => {
test('POST /api/verify with wrapped legacy receipt payload => INVALID', async () => {
const req = { method: 'POST', body: { receipt: sampleReceipt } };
const res = makeRes();

await handler(req, res);

assert.equal(res.statusCode, 200);
assert.equal(res.body.ok, true);
assert.equal(res.body.status, 'VERIFIED');
assert.equal(res.body.ok, false);
assert.equal(res.body.status, 'INVALID');
});
test('POST /api/verify with tampered receipt => INVALID', async () => {
const tampered = structuredClone(sampleReceipt);
Expand Down
105 changes: 105 additions & 0 deletions tests/verifyReceipt-runtime.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
'use strict';

const test = require('node:test');
const assert = require('node:assert/strict');
const { webcrypto } = require('node:crypto');

const { verifyReceipt } = require('../lib/verifyReceipt');

const subtle = webcrypto.subtle;

function canonicalize(value) {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`;
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`).join(',')}}`;
}

function canonicalReceiptPayload(receipt) {
return {
signer: receipt.signer,
verb: receipt.verb,
input: receipt.input,
output: receipt.output,
execution: receipt.execution,
ts: receipt.ts,
};
}

async function makeRuntimeReceipt() {
const keyPair = await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
const rawPub = Buffer.from(await subtle.exportKey('raw', keyPair.publicKey)).toString('base64');

const receipt = {
signer: 'runtime.commandlayer.eth',
verb: 'agent.execute',
ts: '2026-05-20T00:00:00.000Z',
input: { task: 'verify', content: 'canonical' },
output: { ok: true },
execution: { runtime: 'prod', run_id: 'run_1' },
};
const canonicalStr = canonicalize(canonicalReceiptPayload(receipt));
const digest = await subtle.digest('SHA-256', new TextEncoder().encode(canonicalStr));
const hashHex = Buffer.from(digest).toString('hex');
const sigBytes = await subtle.sign({ name: 'Ed25519' }, keyPair.privateKey, new TextEncoder().encode(hashHex));

receipt.metadata = {
proof: {
canonicalization: 'json.sorted_keys.v1',
hash: { alg: 'SHA-256', value: hashHex },
signature: { alg: 'Ed25519', kid: 'vC4WbcNoq2znSCiQ', value: Buffer.from(sigBytes).toString('base64') },
signer_id: 'runtime.commandlayer.eth',
},
};

return { receipt, rawPub };
}

function makeTextResolver(pub) {
return async (_ens, key) => ({
'cl.sig.pub': `ed25519:${pub}`,
'cl.sig.kid': 'vC4WbcNoq2znSCiQ',
'cl.sig.canonical': 'json.sorted_keys.v1',
'cl.receipt.signer': 'runtime.commandlayer.eth',
}[key] || null);
}

test('valid runtime-style receipt verifies', async () => {
const { receipt, rawPub } = await makeRuntimeReceipt();
const out = await verifyReceipt(receipt, { ens: { textResolver: makeTextResolver(rawPub) } });
assert.equal(out.status, 'VERIFIED');
});

test('tampered receipt invalidates', async () => {
const { receipt, rawPub } = await makeRuntimeReceipt();
receipt.output.ok = false;
const out = await verifyReceipt(receipt, { ens: { textResolver: makeTextResolver(rawPub) } });
assert.equal(out.status, 'INVALID');
});

test('missing metadata.proof rejects', async () => {
const { receipt, rawPub } = await makeRuntimeReceipt();
delete receipt.metadata.proof;
const out = await verifyReceipt(receipt, { ens: { textResolver: makeTextResolver(rawPub) } });
assert.equal(out.status, 'INVALID');
});

test('wrong canonicalization rejects', async () => {
const { receipt, rawPub } = await makeRuntimeReceipt();
receipt.metadata.proof.canonicalization = 'json.v1';
const out = await verifyReceipt(receipt, { ens: { textResolver: makeTextResolver(rawPub) } });
assert.equal(out.status, 'INVALID');
});

test('wrong kid rejects', async () => {
const { receipt, rawPub } = await makeRuntimeReceipt();
receipt.metadata.proof.signature.kid = 'wrong';
const out = await verifyReceipt(receipt, { ens: { textResolver: makeTextResolver(rawPub) } });
assert.equal(out.status, 'INVALID');
});

test('legacy top-level proof does not verify', async () => {
const { receipt, rawPub } = await makeRuntimeReceipt();
receipt.signature = { kid: 'vC4WbcNoq2znSCiQ', sig: receipt.metadata.proof.signature.value };
const out = await verifyReceipt(receipt, { ens: { textResolver: makeTextResolver(rawPub) } });
assert.equal(out.status, 'INVALID');
});
Loading