Add scoped execution receipt helpers for clas.execution.receipt.v1#55
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c24c05baea
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!signatureValid) proofErrors.push("Proof signature is invalid"); | ||
| proofs.push({ type: proof.type, signer: proof.signer, covered_fields: proof.covers, hash_matches: hashMatches, signature_valid: signatureValid, ok: proofErrors.length === 0, errors: proofErrors }); | ||
| } | ||
| return { ok: errors.length === 0 && proofs.length > 0 && proofs.every((p) => p.ok), proofs, errors }; |
There was a problem hiding this comment.
Require an execution proof before returning ok
For a receipt that contains only a valid settlement proof, ok becomes true because this check only requires at least one proof and all present proofs to pass; assertSafeReceipt also only requires a settlement proof when settlement exists. In that scenario the payer/rail key can make arbitrary verb/agent/action fields look verified even though no execution proof covers them. Require at least one valid execution proof before returning success.
Useful? React with 👍 / 👎.
| if (!hashMatches) proofErrors.push("Proof hash does not match covered payload"); | ||
| const publicKey = await resolveKey(proof, options); | ||
| if (!publicKey) proofErrors.push(`No public key available for kid ${proof.signature.kid}`); | ||
| else signatureValid = await subtle.verify("Ed25519", await importPublicKey(publicKey), fromB64(proof.signature.value), new TextEncoder().encode(canonical)); |
There was a problem hiding this comment.
Catch key parsing failures during verification
When a caller or key resolver supplies a malformed PEM/DER value, importPublicKey(publicKey) throws and verifyExecutionReceipt rejects instead of returning { ok: false, errors: ... } like the rest of the verifier path. This is especially visible for malformed/untrusted receipts where structural errors have already been collected but this line still tries crypto verification; wrap key import/verify failures and append them to proofErrors.
Useful? React with 👍 / 👎.
| for (const proof of receipt.proofs) assertProofCovers(proof); | ||
| if (receipt.settlement) { | ||
| if (receipt.settlement.stealth_address !== undefined) throw new Error("settlement.stealth_address must not be published"); | ||
| if (typeof receipt.settlement.payment_ref === "string" && /^0x[a-fA-F0-9]{64}$/.test(receipt.settlement.payment_ref)) throw new Error("settlement.payment_ref must not be a raw 0x transaction hash"); |
There was a problem hiding this comment.
Reject raw tx hashes outside payment_ref
When callers include an allowed extra settlement field such as { tx_hash: "0x...64 hex chars..." }, createExecutionReceipt and verifyExecutionReceipt accept and publish the raw transaction hash because this privacy check only inspects payment_ref. Since ClasExecutionSettlement explicitly allows arbitrary settlement fields, scan/reject raw hash-looking values in those fields or disallow transaction-hash aliases as well.
Useful? React with 👍 / 👎.
| if (!hashMatches) proofErrors.push("Proof hash does not match covered payload"); | ||
| const publicKey = await resolveKey(proof, options); | ||
| if (!publicKey) proofErrors.push(`No public key available for kid ${proof.signature.kid}`); | ||
| else signatureValid = await subtle.verify("Ed25519", await importPublicKey(publicKey), fromB64(proof.signature.value), new TextEncoder().encode(canonical)); |
There was a problem hiding this comment.
Validate proof algorithm metadata
For receipts parsed from JSON, an intermediary can change proof.canonicalization or proof.signature.alg to an unsupported value and this verifier still returns the proof as ok because it always canonicalizes with sorted keys and verifies with Ed25519 without checking those fields. Reject proofs whose advertised canonicalization/signature algorithm is not the expected value before reporting them as valid.
Useful? React with 👍 / 👎.
| } | ||
| export async function verifyExecutionReceipt(receipt: ClasExecutionReceiptV1, options: VerifyExecutionReceiptOptions = {}): Promise<VerifyScopedExecutionReceiptResult> { | ||
| const errors: string[] = []; | ||
| try { assertSafeReceipt(receipt, { requireSettlementProof: options.requireSettlementProof }); } catch (err) { errors.push(err instanceof Error ? err.message : String(err)); } |
There was a problem hiding this comment.
Validate the receipt schema before returning ok
When verifying external JSON, this path never checks that clas is "1.0" or that schema is clas.execution.receipt.v1; a valid signed action can be copied into a receipt whose top-level schema/version was changed and still get ok: true. Since this helper is specifically accepting the new execution receipt shape, reject mismatched top-level identifiers before proof validation succeeds.
Useful? React with 👍 / 👎.
| return Object.fromEntries(covers.map((field) => [field, (receipt as unknown as Record<string, JsonValue | undefined>)[field]])); | ||
| } | ||
| async function createProof(receipt: ClasExecutionReceiptV1, covers: readonly string[], type: ClasProofType, options: SignScopedProofOptions): Promise<ClasScopedProof> { | ||
| const canonical = canonicalize(coveredPayload(receipt, covers) as JsonValue); |
There was a problem hiding this comment.
Bind the proof signer into the signed payload
The signature is computed only over the fields named by covers, so proof.signer can be edited after signing and verifyExecutionReceipt still reports ok: true with the forged signer. This makes the returned signer untrustworthy whenever callers verify with publicKeyPem/publicKeysByKid; include the signer in what is signed or otherwise validate that the proof metadata is bound to the key.
Useful? React with 👍 / 👎.
| } | ||
| export function assertSafeReceipt(receipt: ClasExecutionReceiptV1, options: { requireSettlementProof?: boolean } = {}): void { | ||
| for (const proof of receipt.proofs) assertProofCovers(proof); | ||
| if (receipt.settlement) { |
There was a problem hiding this comment.
Reject settlement proofs when settlement is absent
If a receipt contains a settlement proof but no receipt.settlement, this safety check skips settlement validation entirely; a signer can then create a valid settlement proof over just receipt_id because the missing field is omitted during canonicalization, and the verifier reports that settlement proof as ok. Reject settlement proofs unless the receipt actually contains the settlement data they are supposed to attest.
Useful? React with 👍 / 👎.
Motivation
clas.execution.receipt.v1shape that uses scopedproofs[]so agents can sign execution while settlement is attested separately.Description
src/execution-receipt.tswhich defines types and constants (EXECUTION_RECEIPT_SCHEMA,EXECUTION_PROOF_COVERS,SETTLEMENT_PROOF_COVERS,ClasExecutionReceiptV1,ClasScopedProof, etc.) and implementscreateExecutionReceipt,signExecutionProof,attachSettlementProof,signSettlementProof,verifyExecutionReceipt, andassertSafeReceiptusingjson.sorted_keys.v1, SHA-256, and Ed25519.Codex Task