From 92d7dbfb4eb8156cb2e141354db7389d2be683be Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Sat, 13 Jun 2026 15:12:55 -0400 Subject: [PATCH] Add scoped proof verification --- README.md | 18 +++++ src/compat.ts | 160 +++++++++++++++++++++++++++++++++++++ src/index.ts | 9 +++ test/scoped-proofs.test.ts | 136 +++++++++++++++++++++++++++++++ 4 files changed, 323 insertions(+) create mode 100644 test/scoped-proofs.test.ts diff --git a/README.md b/README.md index 0e76619..db477c9 100644 --- a/README.md +++ b/README.md @@ -78,3 +78,21 @@ npm run build npm test npm run typecheck ``` + +## CLAS scoped execution and settlement proofs + +`clas.execution.receipt.v1` receipts may contain `proofs[]` with multiple attestations over different receipt slices: + +Private settlement, public accountability. +The agent signs execution. +The settlement rail or payer signs settlement. +The shared receipt_id binds both attestations into one receipt. + +Runtime-core verifies scoped proofs with the existing `json.sorted_keys.v1` canonicalization. `buildCoveredPayload(receipt, proof)` first materializes only the top-level fields listed by `proof.covers[]`, then canonicalizes that covered payload with recursively sorted object keys before SHA-256 hashing and Ed25519 verification. + +Coverage is intentionally exact and ordered to match CLAS examples: + +- execution proofs must use `covers: ["receipt_id", "verb", "agent", "action"]` +- settlement proofs must use `covers: ["receipt_id", "settlement"]` + +A settlement object requires a valid settlement proof. Tampering settlement fields invalidates only the settlement proof, while execution proofs remain scoped to execution fields. Existing `metadata.proof` verification remains available through `verifyCommandLayerReceipt()` for older single-proof receipts. diff --git a/src/compat.ts b/src/compat.ts index 7337e1e..7931119 100644 --- a/src/compat.ts +++ b/src/compat.ts @@ -20,6 +20,42 @@ export interface VerifyCommandLayerReceiptResult { errors: string[]; } +export type ScopedProofType = "execution" | "settlement"; + +export interface CommandLayerScopedProof { + type: string; + covers: string[]; + canonicalization: string; + hash: { alg: "SHA-256"; value: string }; + signature: CommandLayerProofSignature & { signer?: string; signer_id?: string }; +} + +export interface VerifyScopedProofResult { + type: string; + signer?: string; + covered: string[]; + signature_valid: boolean; + hash_matches: boolean; + ok: boolean; + errors: string[]; +} + +export interface VerifyScopedProofsResult { + ok: boolean; + status: "VERIFIED" | "INVALID"; + proofs: VerifyScopedProofResult[]; + errors: string[]; +} + +export interface VerifyScopedProofsOptions { + /** PEM public key used for every proof when proofs share a key. */ + publicKeyPemOrDer?: string; + /** PEM public keys by signature kid for receipts with multiple signers. */ + publicKeysByKid?: Record; + /** Optional public key resolver for custom key lookup. It must not perform network calls in core primitives. */ + resolvePublicKey?: (proof: CommandLayerScopedProof) => string | undefined; +} + export interface CommandLayerReceipt { verb: string; version?: string; @@ -29,6 +65,7 @@ export interface CommandLayerReceipt { proof?: CommandLayerProof; [key: string]: unknown; }; + proofs?: CommandLayerScopedProof[]; [key: string]: unknown; } @@ -114,6 +151,129 @@ export function buildCanonicalProof(receipt: CommandLayerReceipt): string { return canonicalize(payload); } +export const SCOPED_PROOF_COVERS: Record = { + execution: ["receipt_id", "verb", "agent", "action"], + settlement: ["receipt_id", "settlement"], +} as const; + +function isSupportedScopedProofType(type: string): type is ScopedProofType { + return type === "execution" || type === "settlement"; +} + +function arraysEqual(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +export function buildCoveredPayload( + receipt: CommandLayerReceipt, + proof: Pick +): Record { + if (!proof || typeof proof !== "object") throw new Error("ERR_MALFORMED_PROOF"); + if (typeof proof.type !== "string" || !isSupportedScopedProofType(proof.type)) { + throw new Error("ERR_UNSUPPORTED_PROOF_TYPE"); + } + if (!Array.isArray(proof.covers) || !proof.covers.every((field) => typeof field === "string")) { + throw new Error("ERR_MALFORMED_COVERS"); + } + const expected = SCOPED_PROOF_COVERS[proof.type]; + if (!arraysEqual(proof.covers, expected)) { + throw new Error(`ERR_INVALID_${proof.type.toUpperCase()}_COVERS`); + } + + const source = receipt as Record; + const payload: Record = {}; + for (const field of proof.covers) { + if (!(field in source) || source[field] === undefined) { + throw new Error(`ERR_MISSING_COVERED_FIELD:${field}`); + } + payload[field] = source[field]; + } + return payload; +} + +function getScopedProofSigner(proof: CommandLayerScopedProof): string | undefined { + return proof.signature.signer ?? proof.signature.signer_id; +} + +export function verifyScopedProof( + receipt: CommandLayerReceipt, + proof: CommandLayerScopedProof, + opts: VerifyScopedProofsOptions +): VerifyScopedProofResult { + const errors: string[] = []; + const covered = Array.isArray(proof?.covers) ? [...proof.covers] : []; + const result: VerifyScopedProofResult = { + type: typeof proof?.type === "string" ? proof.type : "", + signer: proof ? getScopedProofSigner(proof) : undefined, + covered, + signature_valid: false, + hash_matches: false, + ok: false, + errors, + }; + + if (!proof || typeof proof !== "object") errors.push("ERR_MALFORMED_PROOF"); + if (typeof proof?.canonicalization !== "string" || proof.canonicalization !== CANONICAL_METHOD) errors.push("ERR_UNSUPPORTED_CANONICALIZATION"); + if (proof?.hash?.alg !== "SHA-256") errors.push("ERR_UNSUPPORTED_HASH_ALG"); + if (typeof proof?.hash?.value !== "string" || !/^[0-9a-f]+$/.test(proof.hash.value)) errors.push("ERR_MISSING_HASH_VALUE"); + if (!proof?.signature || typeof proof.signature !== "object") errors.push("ERR_MISSING_SIGNATURE"); + const signatureAlg = proof?.signature?.alg === "ed25519" ? SIGNATURE_ALG : proof?.signature?.alg; + if (signatureAlg !== SIGNATURE_ALG) errors.push("ERR_UNSUPPORTED_SIGNATURE_ALG"); + if (typeof proof?.signature?.value !== "string" || proof.signature.value.length === 0) errors.push("ERR_MISSING_SIGNATURE_VALUE"); + if (typeof proof?.signature?.kid !== "string" || proof.signature.kid.trim().length === 0) errors.push("ERR_MISSING_SIGNATURE_KID"); + + let canonical = ""; + if (errors.length === 0) { + try { + canonical = canonicalize(buildCoveredPayload(receipt, proof)); + } catch (err) { + errors.push((err as Error).message); + } + } + + if (errors.length === 0) { + const recomputed = createHash("sha256").update(canonical, "utf8").digest("hex"); + result.hash_matches = recomputed === proof.hash.value; + if (!result.hash_matches) errors.push("ERR_HASH_MISMATCH"); + } + + if (errors.length === 0) { + const key = opts.resolvePublicKey?.(proof) ?? opts.publicKeysByKid?.[proof.signature.kid] ?? opts.publicKeyPemOrDer; + if (!key) { + errors.push("ERR_MISSING_PUBLIC_KEY"); + } else if (verifyCanonical(canonical, proof.signature.value, key)) { + result.signature_valid = true; + } else { + errors.push("ERR_SIGNATURE_INVALID"); + } + } + + result.ok = errors.length === 0 && result.hash_matches && result.signature_valid; + return result; +} + +export function verifyScopedProofs( + receipt: CommandLayerReceipt, + opts: VerifyScopedProofsOptions +): VerifyScopedProofsResult { + const errors: string[] = []; + if (!opts.publicKeyPemOrDer && !opts.publicKeysByKid && !opts.resolvePublicKey) { + throw new Error("verifyScopedProofs requires publicKeyPemOrDer, publicKeysByKid, or resolvePublicKey"); + } + if (!Array.isArray(receipt.proofs) || receipt.proofs.length === 0) { + errors.push("ERR_MISSING_PROOFS"); + return { ok: false, status: "INVALID", proofs: [], errors }; + } + + const proofs = receipt.proofs.map((proof) => verifyScopedProof(receipt, proof, opts)); + if (receipt.settlement !== undefined && !proofs.some((proof) => proof.type === "settlement" && proof.ok)) { + errors.push("ERR_MISSING_SETTLEMENT_PROOF"); + } + + const ok = proofs.every((proof) => proof.ok) && errors.length === 0; + return { ok, status: ok ? "VERIFIED" : "INVALID", proofs, errors }; +} + export function signCommandLayerReceipt( receipt: CommandLayerReceipt, opts: { privateKeyPem: string; kid: string } diff --git a/src/index.ts b/src/index.ts index ab6f22f..70849ea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,10 @@ export { buildCanonicalProof, signCommandLayerReceipt, verifyCommandLayerReceipt, + buildCoveredPayload, + verifyScopedProof, + verifyScopedProofs, + SCOPED_PROOF_COVERS, isSignedCommandLayerReceipt, isSingleSignature, isMultiSignature, @@ -65,4 +69,9 @@ export { type CommandLayerProofSignatureWithRole, type CommandLayerProofSignatureField, type EnsVerificationRecord, + type CommandLayerScopedProof, + type ScopedProofType, + type VerifyScopedProofResult, + type VerifyScopedProofsResult, + type VerifyScopedProofsOptions, } from "./compat.js"; diff --git a/test/scoped-proofs.test.ts b/test/scoped-proofs.test.ts new file mode 100644 index 0000000..cf48470 --- /dev/null +++ b/test/scoped-proofs.test.ts @@ -0,0 +1,136 @@ +import { createHash } from "node:crypto"; +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; +import { canonicalize } from "../src/canonicalize.js"; +import { generateEd25519KeyPair, signCanonical } from "../src/crypto.js"; +import { + buildCoveredPayload, + verifyScopedProofs, + type CommandLayerReceipt, + type CommandLayerScopedProof, +} from "../src/compat.js"; + +function makeReceipt(): CommandLayerReceipt { + return { + version: "clas.execution.receipt.v1", + receipt_id: "rcpt_123", + verb: "approve", + agent: { id: "acme.approveagent.eth" }, + action: { input_hash: "a".repeat(64), output_hash: "b".repeat(64) }, + settlement: { rail: "x402", payment_ref: "pay_123" }, + }; +} + +function scopedProof( + receipt: CommandLayerReceipt, + proof: Pick, + privateKeyPem: string, + kid: string, + signer: string, +): CommandLayerScopedProof { + const canonical = canonicalize(buildCoveredPayload(receipt, proof)); + return { + ...proof, + canonicalization: "json.sorted_keys.v1", + hash: { alg: "SHA-256", value: createHash("sha256").update(canonical, "utf8").digest("hex") }, + signature: { alg: "Ed25519", value: signCanonical(canonical, privateKeyPem), kid, signer }, + }; +} + +function signReceiptWithProofs() { + const receipt = makeReceipt(); + const executionKey = generateEd25519KeyPair(); + const settlementKey = generateEd25519KeyPair(); + const execution = scopedProof( + receipt, + { type: "execution", covers: ["receipt_id", "verb", "agent", "action"] }, + executionKey.privateKeyPem, + "execution-kid", + "acme.approveagent.eth", + ); + const settlement = scopedProof( + receipt, + { type: "settlement", covers: ["receipt_id", "settlement"] }, + settlementKey.privateKeyPem, + "settlement-kid", + "x402:payer.example", + ); + return { receipt: { ...receipt, proofs: [execution, settlement] }, executionKey, settlementKey }; +} + +describe("CLAS scoped proofs", () => { + it("materializes covered payloads in the exact covers[] order before sorted-key canonicalization", () => { + const receipt = makeReceipt(); + assert.deepStrictEqual( + buildCoveredPayload(receipt, { type: "execution", covers: ["receipt_id", "verb", "agent", "action"] }), + { receipt_id: "rcpt_123", verb: "approve", agent: { id: "acme.approveagent.eth" }, action: { input_hash: "a".repeat(64), output_hash: "b".repeat(64) } }, + ); + assert.deepStrictEqual( + buildCoveredPayload(receipt, { type: "settlement", covers: ["receipt_id", "settlement"] }), + { receipt_id: "rcpt_123", settlement: { rail: "x402", payment_ref: "pay_123" } }, + ); + }); + + it("verifies an execution-only receipt when execution proof signs only receipt_id, verb, agent, action", () => { + const base = makeReceipt(); + delete base.settlement; + const key = generateEd25519KeyPair(); + const proof = scopedProof(base, { type: "execution", covers: ["receipt_id", "verb", "agent", "action"] }, key.privateKeyPem, "execution-kid", "acme.approveagent.eth"); + const result = verifyScopedProofs({ ...base, proofs: [proof] }, { publicKeysByKid: { "execution-kid": key.publicKeyPem } }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.proofs[0].signature_valid, true); + assert.strictEqual(result.proofs[0].hash_matches, true); + }); + + it("verifies execution + settlement receipt with two scoped proofs", () => { + const { receipt, executionKey, settlementKey } = signReceiptWithProofs(); + const result = verifyScopedProofs(receipt, { publicKeysByKid: { "execution-kid": executionKey.publicKeyPem, "settlement-kid": settlementKey.publicKeyPem } }); + assert.strictEqual(result.ok, true); + assert.deepStrictEqual(result.proofs.map((proof) => proof.type), ["execution", "settlement"]); + }); + + it("tampering action.output_hash invalidates execution proof but not settlement proof", () => { + const { receipt, executionKey, settlementKey } = signReceiptWithProofs(); + (receipt.action as Record).output_hash = "c".repeat(64); + const result = verifyScopedProofs(receipt, { publicKeysByKid: { "execution-kid": executionKey.publicKeyPem, "settlement-kid": settlementKey.publicKeyPem } }); + assert.strictEqual(result.ok, false); + assert.strictEqual(result.proofs[0].ok, false); + assert.strictEqual(result.proofs[1].ok, true); + }); + + it("tampering settlement.payment_ref invalidates settlement proof but not execution proof", () => { + const { receipt, executionKey, settlementKey } = signReceiptWithProofs(); + (receipt.settlement as Record).payment_ref = "pay_tampered"; + const result = verifyScopedProofs(receipt, { publicKeysByKid: { "execution-kid": executionKey.publicKeyPem, "settlement-kid": settlementKey.publicKeyPem } }); + assert.strictEqual(result.ok, false); + assert.strictEqual(result.proofs[0].ok, true); + assert.strictEqual(result.proofs[1].ok, false); + }); + + it("rejects execution proof that covers settlement", () => { + const receipt = makeReceipt(); + assert.throws(() => buildCoveredPayload(receipt, { type: "execution", covers: ["receipt_id", "verb", "agent", "action", "settlement"] }), /ERR_INVALID_EXECUTION_COVERS/); + }); + + it("rejects settlement present without settlement proof", () => { + const { receipt, executionKey } = signReceiptWithProofs(); + receipt.proofs = [receipt.proofs![0]]; + const result = verifyScopedProofs(receipt, { publicKeysByKid: { "execution-kid": executionKey.publicKeyPem } }); + assert.strictEqual(result.ok, false); + assert.ok(result.errors.includes("ERR_MISSING_SETTLEMENT_PROOF")); + }); + + it("rejects unknown proof type and malformed covers[]", () => { + const receipt = makeReceipt(); + assert.throws(() => buildCoveredPayload(receipt, { type: "authorization", covers: ["receipt_id"] }), /ERR_UNSUPPORTED_PROOF_TYPE/); + assert.throws(() => buildCoveredPayload(receipt, { type: "execution", covers: "receipt_id" as unknown as string[] }), /ERR_MALFORMED_COVERS/); + }); + + it("does not require raw settlement stealth address or raw transaction hash fields", () => { + const { receipt, executionKey, settlementKey } = signReceiptWithProofs(); + assert.equal((receipt.settlement as Record).stealth_address, undefined); + assert.equal((receipt.settlement as Record).tx_hash, undefined); + const result = verifyScopedProofs(receipt, { publicKeysByKid: { "execution-kid": executionKey.publicKeyPem, "settlement-kid": settlementKey.publicKeyPem } }); + assert.strictEqual(result.ok, true); + }); +});