From 6d6545c8161b64ea13b1e3bb991fc53bf6764bcb Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Sat, 23 May 2026 19:48:59 -0400 Subject: [PATCH] Make receipt verification ENS-first with explicit local fallback --- README.md | 7 +++++++ api/agents/verifyagent.js | 1 + lib/verifyReceipt.js | 17 +++++++++++----- tests/api-agents-verifyagent.test.js | 3 ++- tests/api-verify.test.js | 1 + tests/verifyReceipt-runtime.test.js | 29 ++++++++++++++++++++++++++++ 6 files changed, 52 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8185971..38492b7 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,13 @@ Tampered receipt: - `hash_matches = false` - `signature_valid = false` + +## Key resolution policy + +- Production verification resolves signer keys from ENS TXT records: `cl.sig.pub`, `cl.sig.kid`, `cl.sig.canonical`, and `cl.receipt.signer`. +- Local key fallback is test/demo only and must be explicitly enabled with `COMMANDLAYER_ALLOW_LOCAL_KEY_FALLBACK=true` (or test mode). +- Verifier responses expose `public_key_source` as `ens_txt` or `local_test_fallback`. + ## Trust boundaries - Runtime signs. diff --git a/api/agents/verifyagent.js b/api/agents/verifyagent.js index 054c1c6..1879567 100644 --- a/api/agents/verifyagent.js +++ b/api/agents/verifyagent.js @@ -87,6 +87,7 @@ module.exports = async function handler(req, res) { signature_valid: verification.signature_valid, ens_resolved: verification.ens_resolved, key_id: verification.key_id, + public_key_source: verification.public_key_source, }, }); } catch (error) { diff --git a/lib/verifyReceipt.js b/lib/verifyReceipt.js index 2f26fb9..f9acc87 100644 --- a/lib/verifyReceipt.js +++ b/lib/verifyReceipt.js @@ -75,6 +75,7 @@ async function defaultTextResolver() { async function resolveSignerFromEns(signerEnsName, options = {}) { const resolver = options.textResolver || defaultTextResolver; + const allowLocalFallback = options.allowLocalFallback === true || process.env.COMMANDLAYER_ALLOW_LOCAL_KEY_FALLBACK === 'true' || process.env.NODE_ENV === 'test'; const requiredKeys = ['cl.sig.pub', 'cl.sig.kid', 'cl.sig.canonical', 'cl.receipt.signer']; const records = {}; @@ -98,16 +99,18 @@ async function resolveSignerFromEns(signerEnsName, options = {}) { signer: signerEnsName, records, ensResolved: true, - keySource: 'live ENS text record', + keySource: 'ens_txt', + errorCode: null, }; } - if (signerEnsName === FALLBACK_SIGNER) { + if (allowLocalFallback && signerEnsName === FALLBACK_SIGNER) { return { signer: signerEnsName, records: { ...FALLBACK_RECORDS }, ensResolved: true, - keySource: 'local demo fallback (runtime.commandlayer.eth only)', + keySource: 'local_test_fallback', + errorCode: null, }; } @@ -115,7 +118,8 @@ async function resolveSignerFromEns(signerEnsName, options = {}) { signer: signerEnsName || 'unknown', records: {}, ensResolved: false, - keySource: 'not resolved', + keySource: 'ens_txt', + errorCode: 'ens_key_unavailable', }; } @@ -215,7 +219,9 @@ async function verifyReceipt(receiptInput, options = {}) { return { ok, status: ok ? 'VERIFIED' : 'INVALID', - reason: ok ? 'Receipt verification passed.' : 'Receipt is invalid, tampered, or does not match the signer key metadata.', + reason: ok + ? 'Receipt verification passed.' + : (ens.errorCode === 'ens_key_unavailable' ? 'ens_key_unavailable' : 'Receipt is invalid, tampered, or does not match the signer key metadata.'), signer: receipt?.signer || null, verb: receipt?.verb || null, hash: recomputedHash, @@ -232,6 +238,7 @@ async function verifyReceipt(receiptInput, options = {}) { key_id_matched: keyIdMatched, canonicalization_matched: canonicalizationOk, signer_matched: signerMatched, + key_resolution_error: ens.errorCode, }, }; } diff --git a/tests/api-agents-verifyagent.test.js b/tests/api-agents-verifyagent.test.js index 5711a3b..ff3890e 100644 --- a/tests/api-agents-verifyagent.test.js +++ b/tests/api-agents-verifyagent.test.js @@ -33,7 +33,8 @@ test('POST /api/agents/verifyagent with canonical sample fixture => INVALID', as assert.equal(res.body.action, 'verify_receipt'); 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.'); + assert.equal(res.body.result.reason, 'ens_key_unavailable'); + assert.equal(typeof res.body.result.public_key_source, 'string'); }); test('POST /api/agents/verifyagent with tampered receipt => INVALID', async () => { diff --git a/tests/api-verify.test.js b/tests/api-verify.test.js index 6221d11..14b63d6 100644 --- a/tests/api-verify.test.js +++ b/tests/api-verify.test.js @@ -31,6 +31,7 @@ test('POST /api/verify with canonical sample fixture => INVALID', async () => { assert.equal(res.statusCode, 200); assert.equal(res.body.ok, false); assert.equal(res.body.status, 'INVALID'); + assert.equal(typeof res.body.public_key_source, 'string'); }); diff --git a/tests/verifyReceipt-runtime.test.js b/tests/verifyReceipt-runtime.test.js index 7e19569..202078e 100644 --- a/tests/verifyReceipt-runtime.test.js +++ b/tests/verifyReceipt-runtime.test.js @@ -83,6 +83,35 @@ 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'); + assert.equal(out.public_key_source, 'ens_txt'); +}); + +test('fails when ENS key is unavailable and fallback is disabled', async () => { + const { receipt } = await makeRuntimeReceipt(); + const out = await verifyReceipt(receipt, { + ens: { + textResolver: async () => null, + allowLocalFallback: false, + }, + }); + + assert.equal(out.status, 'INVALID'); + assert.equal(out.reason, 'ens_key_unavailable'); + assert.equal(out.public_key_source, 'ens_txt'); +}); + +test('allows explicit local fallback for test/demo mode only when enabled', async () => { + const { receipt } = await makeRuntimeReceipt(); + const out = await verifyReceipt(receipt, { + ens: { + textResolver: async () => null, + allowLocalFallback: true, + }, + }); + + assert.equal(out.status, 'INVALID'); + assert.equal(out.public_key_source, 'local_test_fallback'); + assert.equal(out.reason, 'Receipt is invalid, tampered, or does not match the signer key metadata.'); }); test('tampered receipt invalidates', async () => {