-
Notifications
You must be signed in to change notification settings - Fork 0
Add scoped execution and settlement proof verification #28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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<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, | ||
| 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)) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If the covered-payload hash matches but Useful? React with 👍 / 👎. |
||
| result.signature_valid = true; | ||
| } else { | ||
| errors.push("ERR_SIGNATURE_INVALID"); | ||
| } | ||
| } | ||
|
|
||
| result.ok = errors.length === 0 && result.hash_matches && result.signature_valid; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For an execution proof whose 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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the verifier is called on an untrusted parsed body that is 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a 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 } | ||
|
|
||
| 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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a proof object omits
signature, result construction callsgetScopedProofSigner(proof)before the validation below can addERR_MISSING_SIGNATURE, and that helper dereferencesproof.signature.signer. A malformed scoped proof with no signature therefore throws aTypeErrorfromverifyScopedProofs()instead of failing closed with an INVALID result.Useful? React with 👍 / 👎.