From ab8219b5148aeba94e5753e071807f1b74da56d7 Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Sun, 15 Feb 2026 17:38:06 -0500 Subject: [PATCH] Update ENS signer key resolution to cl.sig pub/kid flow --- typescript-sdk/README.md | 12 +-- typescript-sdk/scripts/unit-tests.mjs | 110 ++++++++++++++++++++++---- typescript-sdk/src/index.ts | 79 +++++++++++------- 3 files changed, 149 insertions(+), 52 deletions(-) diff --git a/typescript-sdk/README.md b/typescript-sdk/README.md index 403cd7d..4359154 100644 --- a/typescript-sdk/README.md +++ b/typescript-sdk/README.md @@ -127,21 +127,21 @@ console.log(result.ok); **Option B — ENS-based Verification** -Resolves the public key from ENS TXT records. +Resolves signer metadata from ENS TXT records. Required ENS records: -- `cl.receipt.pubkey_pem` -- `cl.receipt.signer_id` -- `cl.receipt.alg` -- +- Agent ENS TXT: `cl.receipt.signer` +- Signer ENS TXT: `cl.sig.pub` +- Signer ENS TXT: `cl.sig.kid` + Example: ``` import { verifyReceipt } from "@commandlayer/sdk"; const out = await verifyReceipt(receipt, { ens: { - name: "runtime.commandlayer.eth", + name: "summarizeagent.eth", rpcUrl: process.env.ETH_RPC_URL! } }); diff --git a/typescript-sdk/scripts/unit-tests.mjs b/typescript-sdk/scripts/unit-tests.mjs index 425729a..4ee8b3f 100644 --- a/typescript-sdk/scripts/unit-tests.mjs +++ b/typescript-sdk/scripts/unit-tests.mjs @@ -5,17 +5,7 @@ import { createRequire } from "node:module"; const require = createRequire(import.meta.url); -const { - canonicalizeStableJsonV1, - sha256HexUtf8, - parseEd25519Pubkey, - verifyEd25519SignatureOverUtf8HashString, - recomputeReceiptHashSha256, - verifyReceipt, - CommandLayerError, - CommandLayerClient, -} = require("../dist/index.cjs"); - +const ethers = require("ethers"); const nacl = require("tweetnacl"); let passed = 0; @@ -42,6 +32,64 @@ function assertThrows(fn, name) { } } +async function assertRejects(fn, expected, name) { + try { + await fn(); + failed++; + console.error(`FAIL: ${name} (did not throw)`); + } catch (err) { + const msg = err?.message || String(err); + if (!msg.includes(expected)) { + failed++; + console.error(`FAIL: ${name} (unexpected message: ${msg})`); + return; + } + passed++; + console.log(`PASS: ${name}`); + } +} + +const kp = nacl.sign.keyPair(); +const b64Key = Buffer.from(kp.publicKey).toString("base64"); +const hexKey = Buffer.from(kp.publicKey).toString("hex"); + +const ensFixtures = { + "summarizeagent.eth": { "cl.receipt.signer": "runtime.commandlayer.eth" }, + "runtime.commandlayer.eth": { "cl.sig.pub": `ed25519:${b64Key}`, "cl.sig.kid": "2026-01" }, + "missing-signer.eth": {}, + "missing-pub.eth": { "cl.receipt.signer": "signer-without-pub.eth" }, + "signer-without-pub.eth": { "cl.sig.kid": "2026-01" }, + "malformed-pub.eth": { "cl.receipt.signer": "signer-with-malformed-pub.eth" }, + "signer-with-malformed-pub.eth": { "cl.sig.pub": "ed25519:not-base64", "cl.sig.kid": "2026-01" }, +}; + +class MockResolver { + constructor(name) { + this.name = name; + } + + async getText(key) { + return ensFixtures[this.name]?.[key] ?? ""; + } +} + +ethers.ethers.JsonRpcProvider.prototype.getResolver = async function(name) { + if (!(name in ensFixtures)) return null; + return new MockResolver(name); +}; + +const { + canonicalizeStableJsonV1, + sha256HexUtf8, + parseEd25519Pubkey, + verifyEd25519SignatureOverUtf8HashString, + recomputeReceiptHashSha256, + verifyReceipt, + resolveSignerKey, + CommandLayerError, + CommandLayerClient, +} = require("../dist/index.cjs"); + // ---- Canonicalization ---- assert(canonicalizeStableJsonV1(null) === "null", "canonicalize null"); @@ -84,10 +132,6 @@ assert(sha256HexUtf8("hello") !== sha256HexUtf8("world"), "sha256 differs for di // ---- Ed25519 pubkey parsing ---- -const kp = nacl.sign.keyPair(); -const b64Key = Buffer.from(kp.publicKey).toString("base64"); -const hexKey = Buffer.from(kp.publicKey).toString("hex"); - const pk1 = parseEd25519Pubkey(b64Key); assert(pk1.length === 32, "parse base64 pubkey"); @@ -123,6 +167,31 @@ assert( "wrong key rejects" ); +// ---- ENS signer key resolution ---- + +const signerKey = await resolveSignerKey("summarizeagent.eth", "http://mock-rpc.local"); +assert(signerKey.algorithm === "ed25519", "resolveSignerKey returns algorithm"); +assert(signerKey.kid === "2026-01", "resolveSignerKey returns kid from cl.sig.kid"); +assert(Buffer.from(signerKey.rawPublicKeyBytes).toString("base64") === b64Key, "resolveSignerKey returns public key bytes from cl.sig.pub"); + +await assertRejects( + () => resolveSignerKey("missing-signer.eth", "http://mock-rpc.local"), + "ENS TXT cl.receipt.signer missing", + "resolveSignerKey throws clear error when cl.receipt.signer missing" +); + +await assertRejects( + () => resolveSignerKey("missing-pub.eth", "http://mock-rpc.local"), + "ENS TXT cl.sig.pub missing", + "resolveSignerKey throws clear error when cl.sig.pub missing" +); + +await assertRejects( + () => resolveSignerKey("malformed-pub.eth", "http://mock-rpc.local"), + "ENS TXT cl.sig.pub malformed", + "resolveSignerKey throws clear error when cl.sig.pub malformed" +); + // ---- Receipt verification (end-to-end) ---- const receipt = { @@ -147,11 +216,20 @@ receipt.metadata.proof.signature_b64 = Buffer.from(receiptSig).toString("base64" receipt.metadata.receipt_id = hash_sha256; const vr = await verifyReceipt(receipt, { publicKey: `ed25519:${b64Key}` }); -assert(vr.ok === true, "verifyReceipt ok for valid receipt"); +assert(vr.ok === true, "verifyReceipt ok for valid receipt (explicit key)"); assert(vr.checks.hash_matches === true, "verifyReceipt hash matches"); assert(vr.checks.signature_valid === true, "verifyReceipt signature valid"); assert(vr.checks.receipt_id_matches === true, "verifyReceipt receipt_id matches"); +const vrEns = await verifyReceipt(receipt, { + ens: { + name: "summarizeagent.eth", + rpcUrl: "http://mock-rpc.local" + } +}); +assert(vrEns.ok === true, "verifyReceipt ok with ENS cl.receipt.signer + cl.sig.pub"); +assert(vrEns.values.pubkey_source === "ens", "verifyReceipt reports ENS key source"); + // Tampered receipt const tamperedReceipt = JSON.parse(JSON.stringify(receipt)); tamperedReceipt.result.summary = "tampered"; diff --git a/typescript-sdk/src/index.ts b/typescript-sdk/src/index.ts index b67f913..1e7f924 100644 --- a/typescript-sdk/src/index.ts +++ b/typescript-sdk/src/index.ts @@ -108,19 +108,16 @@ export class CommandLayerError extends Error { } export type EnsVerifyOptions = { - /** ENS name that holds TXT records (commonly runtime.commandlayer.eth) */ + /** Agent ENS name that holds TXT records (e.g. summarizeagent.eth) */ name: string; /** Ethereum RPC URL (required for ENS resolution) */ rpcUrl: string; - /** - * TXT record key that contains an Ed25519 public key (32 bytes). - * Accepts formats: - * - "ed25519:" - * - "" (32 bytes) - * - "0x" / "" (64 hex chars) - * Default: "cl.pubkey" - */ - pubkeyTextKey?: string; +}; + +export type SignerKeyResolution = { + algorithm: "ed25519"; + kid: string; + rawPublicKeyBytes: Uint8Array; }; export type VerifyOptions = { @@ -290,25 +287,47 @@ export function verifyEd25519SignatureOverUtf8HashString( } // ----------------------- -// ENS TXT pubkey resolution (ethers v6) +// ENS TXT signer key resolution (ethers v6) // ----------------------- -export async function resolveEnsEd25519Pubkey( - ens: EnsVerifyOptions -): Promise<{ pubkey: Uint8Array | null; source: "ens" | null; error?: string; txtKey: string; txtValue?: string }> { - const txtKey = ens.pubkeyTextKey || "cl.pubkey"; - try { - const provider = new ethers.JsonRpcProvider(ens.rpcUrl); - const resolver = await provider.getResolver(ens.name); - if (!resolver) return { pubkey: null, source: null, error: "No resolver for ENS name", txtKey }; +export async function resolveSignerKey(name: string, rpcUrl: string): Promise { + const provider = new ethers.JsonRpcProvider(rpcUrl); + const agentResolver = await provider.getResolver(name); + if (!agentResolver) { + throw new Error(`No resolver for agent ENS name: ${name}`); + } + + const signerName = (await agentResolver.getText("cl.receipt.signer"))?.trim(); + if (!signerName) { + throw new Error(`ENS TXT cl.receipt.signer missing for agent ENS name: ${name}`); + } + + const signerResolver = await provider.getResolver(signerName); + if (!signerResolver) { + throw new Error(`No resolver for signer ENS name: ${signerName}`); + } - const txt = (await resolver.getText(txtKey))?.trim(); - if (!txt) return { pubkey: null, source: null, error: `ENS TXT ${txtKey} missing`, txtKey }; + const pubKeyText = (await signerResolver.getText("cl.sig.pub"))?.trim(); + if (!pubKeyText) { + throw new Error(`ENS TXT cl.sig.pub missing for signer ENS name: ${signerName}`); + } + + const kid = (await signerResolver.getText("cl.sig.kid"))?.trim(); + if (!kid) { + throw new Error(`ENS TXT cl.sig.kid missing for signer ENS name: ${signerName}`); + } - const pubkey = parseEd25519Pubkey(txt); - return { pubkey, source: "ens", txtKey, txtValue: txt }; + let rawPublicKeyBytes: Uint8Array; + try { + rawPublicKeyBytes = parseEd25519Pubkey(pubKeyText); } catch (e: any) { - return { pubkey: null, source: null, error: e?.message || "ENS resolution failed", txtKey }; + throw new Error(`ENS TXT cl.sig.pub malformed for signer ENS name: ${signerName}. ${e?.message || String(e)}`); } + + return { + algorithm: "ed25519", + kid, + rawPublicKeyBytes + }; } // ----------------------- @@ -372,13 +391,13 @@ export async function verifyReceipt(receipt: Receipt, opts: VerifyOptions = {}): pubkey = parseEd25519Pubkey(opts.publicKey); pubkey_source = "explicit"; } else if (opts.ens) { - const res = await resolveEnsEd25519Pubkey(opts.ens); - ens_txt_key = res.txtKey; - if (!res.pubkey) { - ens_error = res.error || "ENS pubkey not found"; - } else { - pubkey = res.pubkey; + ens_txt_key = "cl.receipt.signer -> cl.sig.pub, cl.sig.kid"; + try { + const signerKey = await resolveSignerKey(opts.ens.name, opts.ens.rpcUrl); + pubkey = signerKey.rawPublicKeyBytes; pubkey_source = "ens"; + } catch (e: any) { + ens_error = e?.message || "ENS signer key resolution failed"; } }