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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
160 changes: 160 additions & 0 deletions src/compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
/** 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;
Expand All @@ -29,6 +65,7 @@ export interface CommandLayerReceipt {
proof?: CommandLayerProof;
[key: string]: unknown;
};
proofs?: CommandLayerScopedProof[];
[key: string]: unknown;
}

Expand Down Expand Up @@ -114,6 +151,129 @@ export function buildCanonicalProof(receipt: CommandLayerReceipt): string {
return canonicalize(payload);
}

export const SCOPED_PROOF_COVERS: Record<ScopedProofType, readonly string[]> = {
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<CommandLayerScopedProof, "type" | "covers">
): Record<string, unknown> {
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<string, unknown>;
const payload: Record<string, unknown> = {};
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return INVALID when scoped signatures are missing

When a proof object omits signature, result construction calls getScopedProofSigner(proof) before the validation below can add ERR_MISSING_SIGNATURE, and that helper dereferences proof.signature.signer. A malformed scoped proof with no signature therefore throws a TypeError from verifyScopedProofs() instead of failing closed with an INVALID result.

Useful? React with 👍 / 👎.

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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Catch malformed scoped signatures during verification

If the covered-payload hash matches but signature.value is malformed base64 or decodes to the wrong length, verifyCanonical() throws before returning false; this call is not caught, so attacker-supplied malformed proofs can make verifyScopedProofs() throw rather than return INVALID. Wrap this verification path and convert verification errors to ERR_SIGNATURE_INVALID or a malformed-signature error.

Useful? React with 👍 / 👎.

result.signature_valid = true;
} else {
errors.push("ERR_SIGNATURE_INVALID");
}
}

result.ok = errors.length === 0 && result.hash_matches && result.signature_valid;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate scoped proof signers before accepting proofs

For an execution proof whose signature.signer/signer_id does not match the signed receipt.agent identity, this still sets ok from only hash and signature validity, so a receipt can be reported VERIFIED as long as the provided kid/key signs the covered fields even though the declared signer is not the agent. Scoped verification should fail closed on signer mismatch, e.g. by comparing execution proof signer to receipt.agent/receipt.agent.id or accepting an expected signer constraint as the older verifier does.

Useful? React with 👍 / 👎.

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return INVALID for null scoped receipts

When the verifier is called on an untrusted parsed body that is null or undefined, this dereferences receipt.proofs before any structure check, so verifyScopedProofs() throws a TypeError instead of failing closed with an INVALID result. Guard receipt itself before reading proofs so malformed receipt inputs cannot crash callers.

Useful? React with 👍 / 👎.

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");
Comment on lines +269 to +270

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require an execution proof before returning VERIFIED

For a clas.execution.receipt.v1 receipt that contains a valid settlement proof but no execution proof, this code only checks that settlement has an OK proof, so proofs.every(...) can still return VERIFIED while verb, agent, and action are completely unsigned and can be arbitrary or tampered. Since scoped proofs intentionally separate settlement from execution, require at least one OK execution proof before accepting the receipt.

Useful? React with 👍 / 👎.

}

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 }
Expand Down
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ export {
buildCanonicalProof,
signCommandLayerReceipt,
verifyCommandLayerReceipt,
buildCoveredPayload,
verifyScopedProof,
verifyScopedProofs,
SCOPED_PROOF_COVERS,
isSignedCommandLayerReceipt,
isSingleSignature,
isMultiSignature,
Expand All @@ -65,4 +69,9 @@ export {
type CommandLayerProofSignatureWithRole,
type CommandLayerProofSignatureField,
type EnsVerificationRecord,
type CommandLayerScopedProof,
type ScopedProofType,
type VerifyScopedProofResult,
type VerifyScopedProofsResult,
type VerifyScopedProofsOptions,
} from "./compat.js";
136 changes: 136 additions & 0 deletions test/scoped-proofs.test.ts
Original file line number Diff line number Diff line change
@@ -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<CommandLayerScopedProof, "type" | "covers">,
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<string, unknown>).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<string, unknown>).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<string, unknown>).stealth_address, undefined);
assert.equal((receipt.settlement as Record<string, unknown>).tx_hash, undefined);
const result = verifyScopedProofs(receipt, { publicKeysByKid: { "execution-kid": executionKey.publicKeyPem, "settlement-kid": settlementKey.publicKeyPem } });
assert.strictEqual(result.ok, true);
});
});
Loading