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
106 changes: 94 additions & 12 deletions src/compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, unknown>;
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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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 {
Expand All @@ -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;
}
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
77 changes: 77 additions & 0 deletions test/compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
signCommandLayerReceipt,
verifyCommandLayerReceipt,
isSignedCommandLayerReceipt,
isMultiSignature,
} from "../src/compat.js";
import { generateEd25519KeyPair } from "../src/crypto.js";

Expand Down Expand Up @@ -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"));
});
});
Loading