From 8ad88840f818466d22864a3cc758a176792de137 Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Fri, 22 May 2026 13:20:42 -0400 Subject: [PATCH] Add CLAS-compatible proof signature union handling --- src/compat.ts | 106 +++++++++++++++++++++++++++++++++++++++----- src/index.ts | 7 +++ test/compat.test.ts | 77 ++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 12 deletions(-) diff --git a/src/compat.ts b/src/compat.ts index 20a4c28..44d57c3 100644 --- a/src/compat.ts +++ b/src/compat.ts @@ -35,7 +35,75 @@ export interface CommandLayerReceipt { export interface CommandLayerProof { canonicalization: string; hash: { alg: "SHA-256"; value: string }; - signature: { alg: typeof SIGNATURE_ALG | "ed25519"; value: string; kid: string }; + signature: CommandLayerProofSignatureField; +} + +export type CommandLayerProofSignature = { + alg: typeof SIGNATURE_ALG | "ed25519"; + value: string; + kid: string; +}; + +export type CommandLayerProofSignatureRole = + | "user" + | "solver" + | "relayer" + | "agent" + | "runtime" + | "verifier"; + +export type CommandLayerProofSignatureWithRole = CommandLayerProofSignature & { + role: CommandLayerProofSignatureRole; +}; + +export type CommandLayerProofSignatureField = + | CommandLayerProofSignature + | CommandLayerProofSignatureWithRole[]; + +function isSignatureRole(value: unknown): value is CommandLayerProofSignatureRole { + return value === "user" + || value === "solver" + || value === "relayer" + || value === "agent" + || value === "runtime" + || value === "verifier"; +} + +export function isSingleSignature(signature: unknown): signature is CommandLayerProofSignature { + if (!signature || typeof signature !== "object" || Array.isArray(signature)) return false; + const s = signature as Record; + return typeof s.alg === "string" && typeof s.value === "string" && typeof s.kid === "string"; +} + +export function isMultiSignature(signature: unknown): signature is CommandLayerProofSignatureWithRole[] { + if (!Array.isArray(signature) || signature.length === 0) return false; + return signature.every((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false; + const s = entry as Record; + return typeof s.alg === "string" + && typeof s.value === "string" + && typeof s.kid === "string" + && isSignatureRole(s.role); + }); +} + +export function getPrimarySignature( + proof: CommandLayerProof, + preferredRole?: CommandLayerProofSignatureRole +): { signature?: CommandLayerProofSignature; error?: string } { + if (isSingleSignature(proof.signature)) return { signature: proof.signature }; + if (!Array.isArray(proof.signature)) return { error: "ERR_MALFORMED_SIGNATURE" }; + if (!isMultiSignature(proof.signature)) return { error: "ERR_MALFORMED_SIGNATURE_ARRAY" }; + + const priority: CommandLayerProofSignatureRole[] = preferredRole + ? [preferredRole, "runtime", "agent", "verifier"] + : ["runtime", "agent", "verifier"]; + + for (const role of priority) { + const match = proof.signature.find((s) => s.role === role); + if (match) return { signature: match }; + } + return { signature: proof.signature[0] }; } export function buildCanonicalProof(receipt: CommandLayerReceipt): string { @@ -104,32 +172,41 @@ export function verifyCommandLayerReceipt( if (typeof proof.hash?.value !== "string" || proof.hash.value.length === 0) { errors.push("ERR_MISSING_HASH_VALUE"); } - if (typeof proof.signature?.alg !== "string" || proof.signature.alg.length === 0) { + const primarySignatureResult = getPrimarySignature(proof as CommandLayerProof); + const selectedSignature = primarySignatureResult.signature; + + if (primarySignatureResult.error) { + errors.push(primarySignatureResult.error); + } + if (!selectedSignature) { + errors.push("ERR_MISSING_SIGNATURE"); + } + if (typeof selectedSignature?.alg !== "string" || selectedSignature.alg.length === 0) { errors.push("ERR_MISSING_SIGNATURE_ALG"); } - if (typeof proof.signature?.value !== "string" || proof.signature.value.length === 0) { + if (typeof selectedSignature?.value !== "string" || selectedSignature.value.length === 0) { errors.push("ERR_MISSING_SIGNATURE_VALUE"); } - if (typeof proof.signature?.kid !== "string" || proof.signature.kid.trim().length === 0) { + if (typeof selectedSignature?.kid !== "string" || selectedSignature.kid.trim().length === 0) { errors.push("ERR_MISSING_SIGNATURE_KID"); } - const allowed = opts.allowedCanonicals ?? [CANONICAL_METHOD]; + const allowed = opts.allowedCanonicals ?? [CANONICAL_METHOD, "erc8211.merkle.v1"]; if (typeof proof.canonicalization === "string" && !allowed.includes(proof.canonicalization)) { errors.push("ERR_UNSUPPORTED_CANONICALIZATION"); } if (proof.hash?.alg && proof.hash.alg !== "SHA-256") { errors.push("ERR_UNSUPPORTED_HASH_ALG"); } - const signatureAlg = proof.signature?.alg === "ed25519" + const signatureAlg = selectedSignature?.alg === "ed25519" ? SIGNATURE_ALG - : proof.signature?.alg; + : selectedSignature?.alg; if (signatureAlg && signatureAlg !== SIGNATURE_ALG) { errors.push("ERR_UNSUPPORTED_SIGNATURE_ALG"); } if (opts.ensRecord) { - if (proof.signature?.kid !== opts.ensRecord.kid) { + if (selectedSignature?.kid !== opts.ensRecord.kid) { errors.push("ERR_ENS_KID_MISMATCH"); } if (proof.canonicalization !== opts.ensRecord.canonical) { @@ -144,6 +221,10 @@ export function verifyCommandLayerReceipt( let canonical = ""; if (checks.schema) { + if (proof.canonicalization === "erc8211.merkle.v1") { + errors.push("ERR_UNSUPPORTED_MERKLE_VERIFICATION"); + return { ok: false, status: "INVALID", checks, errors }; + } canonical = buildCanonicalProof(receipt); const recomputed = createHash("sha256").update(canonical, "utf8").digest("hex"); if (recomputed === proof.hash.value) { @@ -152,7 +233,7 @@ export function verifyCommandLayerReceipt( errors.push("ERR_HASH_MISMATCH"); } - const sigOk = verifyCanonical(canonical, proof.signature.value, opts.publicKeyPemOrDer); + const sigOk = verifyCanonical(canonical, selectedSignature!.value, opts.publicKeyPemOrDer); if (sigOk) { checks.signature = true; } else { @@ -175,9 +256,10 @@ export function verifyCommandLayerReceipt( export function isSignedCommandLayerReceipt(value: unknown): value is CommandLayerReceipt { const v = value as CommandLayerReceipt; + const signature = v?.metadata?.proof?.signature; + const hasValidSignature = isSingleSignature(signature) + || (Array.isArray(signature) && isMultiSignature(signature)); return !!v?.metadata?.proof?.hash?.alg && !!v?.metadata?.proof?.hash?.value - && !!v?.metadata?.proof?.signature?.alg - && !!v?.metadata?.proof?.signature?.value - && !!v?.metadata?.proof?.signature?.kid; + && hasValidSignature; } diff --git a/src/index.ts b/src/index.ts index 75a3227..ab6f22f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,7 +55,14 @@ export { signCommandLayerReceipt, verifyCommandLayerReceipt, isSignedCommandLayerReceipt, + isSingleSignature, + isMultiSignature, + getPrimarySignature, type CommandLayerReceipt, type CommandLayerProof, + type CommandLayerProofSignature, + type CommandLayerProofSignatureRole, + type CommandLayerProofSignatureWithRole, + type CommandLayerProofSignatureField, type EnsVerificationRecord, } from "./compat.js"; diff --git a/test/compat.test.ts b/test/compat.test.ts index fa53a0e..97558c3 100644 --- a/test/compat.test.ts +++ b/test/compat.test.ts @@ -4,6 +4,7 @@ import { signCommandLayerReceipt, verifyCommandLayerReceipt, isSignedCommandLayerReceipt, + isMultiSignature, } from "../src/compat.js"; import { generateEd25519KeyPair } from "../src/crypto.js"; @@ -111,4 +112,80 @@ describe("canonical CLAS proof envelope", () => { assert.equal(ok.ok, true); assert.equal(ok.checks.signer, true); }); + + test("accepts multi-signature array shape and signed receipt guard", () => { + const signed = signCommandLayerReceipt(baseReceipt, { privateKeyPem: kp.privateKeyPem, kid: "testKid" }); + const sig = signed.metadata!.proof!.signature; + const multiSig = [ + { ...sig, role: "agent" as const }, + { ...sig, role: "runtime" as const }, + ]; + const multi = { ...signed, metadata: { ...signed.metadata!, proof: { ...signed.metadata!.proof!, signature: multiSig } } }; + assert.equal(isMultiSignature(multiSig), true); + assert.equal(isSignedCommandLayerReceipt(multi), true); + }); + + test("multi-signature verify path does not throw and verifies selected signature", () => { + const signed = signCommandLayerReceipt(baseReceipt, { privateKeyPem: kp.privateKeyPem, kid: "testKid" }); + const sig = signed.metadata!.proof!.signature; + const multi = { + ...signed, + metadata: { + ...signed.metadata!, + proof: { + ...signed.metadata!.proof!, + signature: [ + { ...sig, role: "user" as const }, + { ...sig, role: "runtime" as const }, + ], + }, + }, + }; + const result = verifyCommandLayerReceipt(multi, { publicKeyPemOrDer: kp.publicKeyPem }); + assert.equal(result.status, "VERIFIED"); + }); + + test("metadata.trace is ignored safely", () => { + const withTrace = { + ...baseReceipt, + metadata: { ...baseReceipt.metadata, trace: [{ step: "signed" }] }, + }; + const signed = signCommandLayerReceipt(withTrace, { privateKeyPem: kp.privateKeyPem, kid: "testKid" }); + const result = verifyCommandLayerReceipt(signed, { publicKeyPemOrDer: kp.publicKeyPem }); + assert.equal(result.status, "VERIFIED"); + }); + + test("malformed signature arrays return INVALID result without throwing", () => { + const signed = signCommandLayerReceipt(baseReceipt, { privateKeyPem: kp.privateKeyPem, kid: "testKid" }); + const malformed = { + ...signed, + metadata: { + ...signed.metadata!, + proof: { + ...signed.metadata!.proof!, + signature: [{ alg: "Ed25519", value: "abc", kid: "kid-without-role" }], + }, + }, + }; + const result = verifyCommandLayerReceipt(malformed, { publicKeyPemOrDer: kp.publicKeyPem }); + assert.equal(result.status, "INVALID"); + assert.ok(result.errors.includes("ERR_MALFORMED_SIGNATURE_ARRAY")); + }); + + test("erc8211 canonicalization is recognized but not falsely verified", () => { + const signed = signCommandLayerReceipt(baseReceipt, { privateKeyPem: kp.privateKeyPem, kid: "testKid" }); + const recognized = verifyCommandLayerReceipt( + { ...signed, metadata: { ...signed.metadata!, proof: { ...signed.metadata!.proof!, canonicalization: "erc8211.merkle.v1" } } }, + { publicKeyPemOrDer: kp.publicKeyPem } + ); + assert.equal(recognized.status, "INVALID"); + assert.ok(recognized.errors.includes("ERR_UNSUPPORTED_MERKLE_VERIFICATION")); + assert.ok(!recognized.errors.includes("ERR_UNSUPPORTED_CANONICALIZATION")); + + const unsupported = verifyCommandLayerReceipt( + { ...signed, metadata: { ...signed.metadata!, proof: { ...signed.metadata!.proof!, canonicalization: "random.unsupported.v1" } } }, + { publicKeyPemOrDer: kp.publicKeyPem } + ); + assert.ok(unsupported.errors.includes("ERR_UNSUPPORTED_CANONICALIZATION")); + }); });