From f6026fcd20743bfe574b328856cac2239963d7eb Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Sat, 23 May 2026 20:32:32 -0400 Subject: [PATCH] Make receipt verifier ENS-first with explicit local fallback --- lib/verifyReceipt.js | 4 ++- tests/api-verify.test.js | 3 +- tests/verifyReceipt-runtime.test.js | 51 +++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/lib/verifyReceipt.js b/lib/verifyReceipt.js index f9acc87..26f87b5 100644 --- a/lib/verifyReceipt.js +++ b/lib/verifyReceipt.js @@ -80,6 +80,7 @@ async function resolveSignerFromEns(signerEnsName, options = {}) { const records = {}; let liveOk = true; + let resolutionError = false; for (const key of requiredKeys) { try { const value = await resolver(signerEnsName, key); @@ -90,6 +91,7 @@ async function resolveSignerFromEns(signerEnsName, options = {}) { records[key] = value; } catch { liveOk = false; + resolutionError = true; break; } } @@ -119,7 +121,7 @@ async function resolveSignerFromEns(signerEnsName, options = {}) { records: {}, ensResolved: false, keySource: 'ens_txt', - errorCode: 'ens_key_unavailable', + errorCode: resolutionError ? 'key_resolution_failed' : 'ens_key_unavailable', }; } diff --git a/tests/api-verify.test.js b/tests/api-verify.test.js index 14b63d6..0109b7f 100644 --- a/tests/api-verify.test.js +++ b/tests/api-verify.test.js @@ -32,7 +32,8 @@ test('POST /api/verify with canonical sample fixture => INVALID', async () => { assert.equal(res.body.ok, false); assert.equal(res.body.status, 'INVALID'); assert.equal(typeof res.body.public_key_source, 'string'); - }); + assert.equal(res.body.public_key_source, 'ens_txt'); +}); diff --git a/tests/verifyReceipt-runtime.test.js b/tests/verifyReceipt-runtime.test.js index 202078e..b91e9c9 100644 --- a/tests/verifyReceipt-runtime.test.js +++ b/tests/verifyReceipt-runtime.test.js @@ -114,6 +114,57 @@ test('allows explicit local fallback for test/demo mode only when enabled', asyn assert.equal(out.reason, 'Receipt is invalid, tampered, or does not match the signer key metadata.'); }); + +test('fails with key_resolution_failed when ENS resolver throws', async () => { + const { receipt } = await makeRuntimeReceipt(); + const out = await verifyReceipt(receipt, { + ens: { + textResolver: async () => { + throw new Error('resolver offline'); + }, + allowLocalFallback: false, + }, + }); + + assert.equal(out.status, 'INVALID'); + assert.equal(out.reason, 'Receipt is invalid, tampered, or does not match the signer key metadata.'); + assert.equal(out.debug.key_resolution_error, 'key_resolution_failed'); + assert.equal(out.public_key_source, 'ens_txt'); +}); + +test('allows env-flag local fallback only when COMMANDLAYER_ALLOW_LOCAL_KEY_FALLBACK=true', async () => { + const previous = process.env.COMMANDLAYER_ALLOW_LOCAL_KEY_FALLBACK; + const previousNodeEnv = process.env.NODE_ENV; + + try { + process.env.NODE_ENV = 'production'; + process.env.COMMANDLAYER_ALLOW_LOCAL_KEY_FALLBACK = 'false'; + + const { receipt } = await makeRuntimeReceipt(); + const withoutFlag = await verifyReceipt(receipt, { + ens: { textResolver: async () => null }, + }); + + assert.equal(withoutFlag.status, 'INVALID'); + assert.equal(withoutFlag.reason, 'ens_key_unavailable'); + assert.equal(withoutFlag.public_key_source, 'ens_txt'); + + process.env.COMMANDLAYER_ALLOW_LOCAL_KEY_FALLBACK = 'true'; + const withFlag = await verifyReceipt(receipt, { + ens: { textResolver: async () => null }, + }); + + assert.equal(withFlag.status, 'INVALID'); + assert.equal(withFlag.public_key_source, 'local_test_fallback'); + } finally { + if (previous === undefined) delete process.env.COMMANDLAYER_ALLOW_LOCAL_KEY_FALLBACK; + else process.env.COMMANDLAYER_ALLOW_LOCAL_KEY_FALLBACK = previous; + + if (previousNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = previousNodeEnv; + } +}); + test('tampered receipt invalidates', async () => { const { receipt, rawPub } = await makeRuntimeReceipt(); receipt.output.ok = false;