From e382a7816a252e344809e9b8efca322b7cdf921a Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Fri, 15 May 2026 03:09:55 -0400 Subject: [PATCH] Add ENS TXT compatibility checks for CLAS proof verification --- src/compat.ts | 25 ++++++++++++++++++++++++- src/index.ts | 1 + test/compat.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ test/ens.test.ts | 15 +++++++++++++++ 4 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/compat.ts b/src/compat.ts index 2fa7317..60f1d97 100644 --- a/src/compat.ts +++ b/src/compat.ts @@ -2,6 +2,12 @@ import { createHash } from "node:crypto"; import { canonicalize } from "./canonicalize.js"; import { signCanonical, verifyCanonical, CANONICAL_METHOD, SIGNATURE_ALG } from "./crypto.js"; +export interface EnsVerificationRecord { + signer: string; + kid: string; + canonical: string; +} + export interface CommandLayerReceipt { verb: string; version?: string; @@ -57,7 +63,11 @@ export function signCommandLayerReceipt( export function verifyCommandLayerReceipt( receipt: CommandLayerReceipt, - opts: { publicKeyPemOrDer: string; allowedCanonicals?: string[] } + opts: { + publicKeyPemOrDer: string; + allowedCanonicals?: string[]; + ensRecord?: EnsVerificationRecord; + } ): { ok: boolean; reason?: string } { const proof = receipt?.metadata?.proof; if (!proof) return { ok: false, reason: "Missing metadata.proof" }; @@ -65,6 +75,19 @@ export function verifyCommandLayerReceipt( if (!proof.hash?.value) return { ok: false, reason: "Missing metadata.proof.hash.value" }; if (!proof.signature?.alg) return { ok: false, reason: "Missing metadata.proof.signature.alg" }; if (!proof.signature?.value) return { ok: false, reason: "Missing metadata.proof.signature.value" }; + if (!proof.signature?.kid) return { ok: false, reason: "Missing metadata.proof.signature.kid" }; + + if (opts.ensRecord) { + if (proof.signature.kid !== opts.ensRecord.kid) { + return { ok: false, reason: "metadata.proof.signature.kid does not match ENS cl.sig.kid" }; + } + if (proof.canonicalization !== opts.ensRecord.canonical) { + return { ok: false, reason: "metadata.proof.canonicalization does not match ENS cl.sig.canonical" }; + } + if (receipt.agent !== opts.ensRecord.signer) { + return { ok: false, reason: "receipt signer identity does not match ENS cl.receipt.signer" }; + } + } const allowed = opts.allowedCanonicals ?? [CANONICAL_METHOD]; if (!allowed.includes(proof.canonicalization)) return { ok: false, reason: "Unsupported canonicalization" }; diff --git a/src/index.ts b/src/index.ts index 43dfb85..22631c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -70,4 +70,5 @@ export { isSignedCommandLayerReceipt, type CommandLayerReceipt, type CommandLayerProof, + type EnsVerificationRecord, } from "./compat.js"; diff --git a/test/compat.test.ts b/test/compat.test.ts index 8eb37a5..25648e0 100644 --- a/test/compat.test.ts +++ b/test/compat.test.ts @@ -39,6 +39,44 @@ describe("canonical CLAS proof envelope", () => { assert.equal(verifyCommandLayerReceipt({ ...signed, metadata: { ...signed.metadata!, proof: { ...p, hash: { ...p.hash, value: "" } } } }, { publicKeyPemOrDer: kp.publicKeyPem }).ok, false); assert.equal(verifyCommandLayerReceipt({ ...signed, metadata: { ...signed.metadata!, proof: { ...p, signature: { ...p.signature, alg: "" as never } } } }, { publicKeyPemOrDer: kp.publicKeyPem }).ok, false); assert.equal(verifyCommandLayerReceipt({ ...signed, metadata: { ...signed.metadata!, proof: { ...p, signature: { ...p.signature, value: "" } } } }, { publicKeyPemOrDer: kp.publicKeyPem }).ok, false); + assert.equal(verifyCommandLayerReceipt({ ...signed, metadata: { ...signed.metadata!, proof: { ...p, signature: { ...p.signature, kid: "" } } } }, { publicKeyPemOrDer: kp.publicKeyPem }).ok, false); + }); + + test("validates ENS-compatible signer constraints when ensRecord is supplied", () => { + const runtimeEnsFixture = { + signer: "runtime.commandlayer.eth", + kid: "vC4WbcNoq2znSCiQ", + canonical: "json.sorted_keys.v1", + }; + + const signed = signCommandLayerReceipt( + { ...baseReceipt, agent: "runtime.commandlayer.eth" }, + { privateKeyPem: kp.privateKeyPem, kid: "vC4WbcNoq2znSCiQ" } + ); + + const ok = verifyCommandLayerReceipt(signed, { + publicKeyPemOrDer: kp.publicKeyPem, + ensRecord: runtimeEnsFixture, + }); + assert.equal(ok.ok, true); + + const badKid = verifyCommandLayerReceipt( + { ...signed, metadata: { ...signed.metadata!, proof: { ...signed.metadata!.proof!, signature: { ...signed.metadata!.proof!.signature, kid: "wrong" } } } }, + { publicKeyPemOrDer: kp.publicKeyPem, ensRecord: runtimeEnsFixture } + ); + assert.equal(badKid.ok, false); + + const badCanonical = verifyCommandLayerReceipt( + { ...signed, metadata: { ...signed.metadata!, proof: { ...signed.metadata!.proof!, canonicalization: "json.unsorted.v1" } } }, + { publicKeyPemOrDer: kp.publicKeyPem, ensRecord: runtimeEnsFixture } + ); + assert.equal(badCanonical.ok, false); + + const badSigner = verifyCommandLayerReceipt( + { ...signed, agent: "other.commandlayer.eth" }, + { publicKeyPemOrDer: kp.publicKeyPem, ensRecord: runtimeEnsFixture } + ); + assert.equal(badSigner.ok, false); }); test("rejects legacy fields as canonical", () => { diff --git a/test/ens.test.ts b/test/ens.test.ts index 2c016ff..7c9edc0 100644 --- a/test/ens.test.ts +++ b/test/ens.test.ts @@ -52,6 +52,12 @@ function makeThrowingProvider(): EnsProvider { const RAW_KEY = new Uint8Array(32).fill(0xab); const PUB_VALUE = encodePublicKey(RAW_KEY); +const RUNTIME_ENS_FIXTURE = { + "cl.sig.kid": "vC4WbcNoq2znSCiQ", + "cl.sig.pub": "ed25519:hhyCuPNoMk4JtEvGEV8F6nMZ4uDO1EcyizPufmnJTOY=", + "cl.sig.canonical": "json.sorted_keys.v1", + "cl.receipt.signer": "runtime.commandlayer.eth", +} as const; const FULL_RECORDS: Record = { "cl.sig.pub": PUB_VALUE, @@ -62,6 +68,15 @@ const FULL_RECORDS: Record = { // ── Tests ───────────────────────────────────────────────────────────────────── describe("resolveSignerFromENS — positional args", () => { + it("parses runtime.commandlayer.eth TXT record shape fixture", async () => { + const provider = makeMockProvider(RUNTIME_ENS_FIXTURE); + const record = await resolveSignerFromENS("runtime.commandlayer.eth", provider); + + assert.strictEqual(record.kid, "vC4WbcNoq2znSCiQ"); + assert.strictEqual(record.canonical, "json.sorted_keys.v1"); + assert.strictEqual(record.rawPublicKey.length, 32); + }); + it("resolves a full signer record", async () => { const provider = makeMockProvider(FULL_RECORDS); const record = await resolveSignerFromENS("test.commandlayer.eth", provider);