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
12 changes: 6 additions & 6 deletions typescript-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
}
});
Expand Down
110 changes: 94 additions & 16 deletions typescript-sdk/scripts/unit-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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");
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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 = {
Expand All @@ -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";
Expand Down
79 changes: 49 additions & 30 deletions typescript-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<base64>"
* - "<base64>" (32 bytes)
* - "0x<hex>" / "<hex>" (64 hex chars)
* Default: "cl.pubkey"
*/
pubkeyTextKey?: string;
};

export type SignerKeyResolution = {
algorithm: "ed25519";
kid: string;
rawPublicKeyBytes: Uint8Array;
};

export type VerifyOptions = {
Expand Down Expand Up @@ -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<SignerKeyResolution> {
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
};
}

// -----------------------
Expand Down Expand Up @@ -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";
}
}

Expand Down