From 25bc0c8493fc7b590d3037c3fc363bd58ef3d317 Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Sun, 17 May 2026 22:16:58 -0400 Subject: [PATCH] fix: canonicalize Ed25519 algorithm casing --- README.md | 2 +- src/compat.ts | 7 +++++-- src/crypto.ts | 2 +- src/receipt.ts | 9 +++++---- test/compat.test.ts | 34 +++++++++++++++++++++++++++++++++- test/receipt.test.ts | 17 +++++++++++++++-- 6 files changed, 60 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 9469613..85c6248 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Canonical crypto and receipt verification primitives for CommandLayer CLAS. - `metadata.proof.canonicalization = "json.sorted_keys.v1"` - `metadata.proof.hash.alg = "SHA-256"` - `metadata.proof.hash.value = ` -- `metadata.proof.signature.alg = "ed25519"` +- `metadata.proof.signature.alg = "Ed25519"` - `metadata.proof.signature.value = ` - `metadata.proof.signature.kid = ` diff --git a/src/compat.ts b/src/compat.ts index b3ceae3..20a4c28 100644 --- a/src/compat.ts +++ b/src/compat.ts @@ -35,7 +35,7 @@ export interface CommandLayerReceipt { export interface CommandLayerProof { canonicalization: string; hash: { alg: "SHA-256"; value: string }; - signature: { alg: typeof SIGNATURE_ALG; value: string; kid: string }; + signature: { alg: typeof SIGNATURE_ALG | "ed25519"; value: string; kid: string }; } export function buildCanonicalProof(receipt: CommandLayerReceipt): string { @@ -121,7 +121,10 @@ export function verifyCommandLayerReceipt( if (proof.hash?.alg && proof.hash.alg !== "SHA-256") { errors.push("ERR_UNSUPPORTED_HASH_ALG"); } - if (proof.signature?.alg && proof.signature.alg !== SIGNATURE_ALG) { + const signatureAlg = proof.signature?.alg === "ed25519" + ? SIGNATURE_ALG + : proof.signature?.alg; + if (signatureAlg && signatureAlg !== SIGNATURE_ALG) { errors.push("ERR_UNSUPPORTED_SIGNATURE_ALG"); } diff --git a/src/crypto.ts b/src/crypto.ts index 55a9f69..54861c9 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -17,7 +17,7 @@ import { sign, verify, generateKeyPairSync } from "node:crypto"; export const PROTOCOL_VERSION = "1.1.0" as const; export const CANONICAL_METHOD = "json.sorted_keys.v1" as const; -export const SIGNATURE_ALG = "ed25519" as const; +export const SIGNATURE_ALG = "Ed25519" as const; /** The ENS text record key for the signer's public key. */ export const ENS_KEY_PUB = "cl.sig.pub" as const; diff --git a/src/receipt.ts b/src/receipt.ts index 50968f1..946d321 100644 --- a/src/receipt.ts +++ b/src/receipt.ts @@ -4,7 +4,7 @@ * v1.1.0 signed layered receipt builder and verifier. * * Proof field names (canonical, matches clas schema): - * proof.alg — signature algorithm ("ed25519") + * proof.alg — signature algorithm ("Ed25519") * proof.kid — key identifier (from ENS cl.sig.kid) * proof.signer_id — ENS name of signer (e.g. runtime.commandlayer.eth) * proof.canonical — canonicalization method ("json.sorted_keys.v1") @@ -31,8 +31,8 @@ export interface ReceiptPayload { } export interface ReceiptProof { - /** Signature algorithm. Always "ed25519". */ - alg: typeof SIGNATURE_ALG; + /** Signature algorithm. Canonical "Ed25519" (legacy lowercase accepted in verification). */ + alg: typeof SIGNATURE_ALG | "ed25519"; /** Key identifier from ENS cl.sig.kid */ kid: string; /** ENS name of the signer */ @@ -177,7 +177,8 @@ export function verifyReceipt( checks.structureValid = true; // Algorithm check - if (proof.alg !== SIGNATURE_ALG) { + const normalizedAlg = proof.alg === "ed25519" ? SIGNATURE_ALG : proof.alg; + if (normalizedAlg !== SIGNATURE_ALG) { return { valid: false, checks, diff --git a/test/compat.test.ts b/test/compat.test.ts index 6b7e291..fa53a0e 100644 --- a/test/compat.test.ts +++ b/test/compat.test.ts @@ -23,7 +23,7 @@ describe("canonical CLAS proof envelope", () => { assert.equal(proof.canonicalization, "json.sorted_keys.v1"); assert.equal(proof.hash.alg, "SHA-256"); assert.ok(proof.hash.value); - assert.equal(proof.signature.alg, "ed25519"); + assert.equal(proof.signature.alg, "Ed25519"); assert.ok(proof.signature.value); assert.equal(proof.signature.kid, "testKid"); @@ -34,6 +34,38 @@ describe("canonical CLAS proof envelope", () => { assert.equal(isSignedCommandLayerReceipt(signed), true); }); + + + test("verifies canonical Ed25519 algorithm without caller normalization", () => { + const signed = signCommandLayerReceipt(baseReceipt, { privateKeyPem: kp.privateKeyPem, kid: "testKid" }); + const proof = signed.metadata!.proof!; + + const canonical = verifyCommandLayerReceipt( + { ...signed, metadata: { ...signed.metadata!, proof: { ...proof, signature: { ...proof.signature, alg: "Ed25519" } } } }, + { publicKeyPemOrDer: kp.publicKeyPem } + ); + assert.equal(canonical.ok, true); + + const legacy = verifyCommandLayerReceipt( + { ...signed, metadata: { ...signed.metadata!, proof: { ...proof, signature: { ...proof.signature, alg: "ed25519" } } } }, + { publicKeyPemOrDer: kp.publicKeyPem } + ); + assert.equal(legacy.ok, true); + }); + + test("fails on unsupported signature algorithms", () => { + const signed = signCommandLayerReceipt(baseReceipt, { privateKeyPem: kp.privateKeyPem, kid: "testKid" }); + const proof = signed.metadata!.proof!; + + const bad = verifyCommandLayerReceipt( + { ...signed, metadata: { ...signed.metadata!, proof: { ...proof, signature: { ...proof.signature, alg: "rsa" as never } } } }, + { publicKeyPemOrDer: kp.publicKeyPem } + ); + + assert.equal(bad.status, "INVALID"); + assert.ok(bad.errors.includes("ERR_UNSUPPORTED_SIGNATURE_ALG")); + }); + test("requires signature.kid to be a non-empty string", () => { const signed = signCommandLayerReceipt(baseReceipt, { privateKeyPem: kp.privateKeyPem, kid: "testKid" }); const p = signed.metadata!.proof!; diff --git a/test/receipt.test.ts b/test/receipt.test.ts index 48bbc88..9584e00 100644 --- a/test/receipt.test.ts +++ b/test/receipt.test.ts @@ -32,7 +32,7 @@ describe("signReceipt", () => { const proof = receipt.signature.proof; // Field names must match protocol spec - assert.strictEqual(proof.alg, "ed25519"); // not signature_alg + assert.strictEqual(proof.alg, "Ed25519"); // not signature_alg assert.strictEqual(proof.kid, "vC4WbcNoq2znSCiQ"); // not key_id assert.strictEqual(proof.signer_id, "runtime.commandlayer.eth"); // not signer assert.strictEqual(proof.canonical, "json.sorted_keys.v1"); @@ -163,6 +163,19 @@ describe("verifyReceipt — full round trip", () => { assert.strictEqual(result.checks.signatureValid, false); }); + + + it("accepts legacy lowercase ed25519 for compatibility", () => { + const { privateKeyPem, rawPublicKey } = generateEd25519KeyPair(); + const signed = signReceipt(makePayload(), { + privateKeyPem, kid: "kid1", signerEns: "test.eth", + }); + (signed.signature.proof as Record).alg = "ed25519"; + + const result = verifyReceipt(signed, { rawPublicKey }); + assert.strictEqual(result.valid, true); + }); + it("rejects unknown algorithm", () => { const { privateKeyPem, rawPublicKey } = generateEd25519KeyPair(); const signed = signReceipt(makePayload(), { @@ -203,7 +216,7 @@ describe("isSignedLayeredReceipt", () => { it("returns false when signature is empty string", () => { const obj = { receipt: { verb: "test", version: "1.1.0", agent: "x", timestamp: "t" }, - signature: { proof: { alg: "ed25519", signature: "", signer_id: "x", kid: "k", canonical: "json.sorted_keys.v1" } }, + signature: { proof: { alg: "Ed25519", signature: "", signer_id: "x", kid: "k", canonical: "json.sorted_keys.v1" } }, }; assert.strictEqual(isSignedLayeredReceipt(obj), false); });