Skip to content

Commit 25bc0c8

Browse files
committed
fix: canonicalize Ed25519 algorithm casing
1 parent 2518d4f commit 25bc0c8

6 files changed

Lines changed: 60 additions & 11 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Canonical crypto and receipt verification primitives for CommandLayer CLAS.
99
- `metadata.proof.canonicalization = "json.sorted_keys.v1"`
1010
- `metadata.proof.hash.alg = "SHA-256"`
1111
- `metadata.proof.hash.value = <lowercase hex digest>`
12-
- `metadata.proof.signature.alg = "ed25519"`
12+
- `metadata.proof.signature.alg = "Ed25519"`
1313
- `metadata.proof.signature.value = <base64 signature>`
1414
- `metadata.proof.signature.kid = <required key id>`
1515

src/compat.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export interface CommandLayerReceipt {
3535
export interface CommandLayerProof {
3636
canonicalization: string;
3737
hash: { alg: "SHA-256"; value: string };
38-
signature: { alg: typeof SIGNATURE_ALG; value: string; kid: string };
38+
signature: { alg: typeof SIGNATURE_ALG | "ed25519"; value: string; kid: string };
3939
}
4040

4141
export function buildCanonicalProof(receipt: CommandLayerReceipt): string {
@@ -121,7 +121,10 @@ export function verifyCommandLayerReceipt(
121121
if (proof.hash?.alg && proof.hash.alg !== "SHA-256") {
122122
errors.push("ERR_UNSUPPORTED_HASH_ALG");
123123
}
124-
if (proof.signature?.alg && proof.signature.alg !== SIGNATURE_ALG) {
124+
const signatureAlg = proof.signature?.alg === "ed25519"
125+
? SIGNATURE_ALG
126+
: proof.signature?.alg;
127+
if (signatureAlg && signatureAlg !== SIGNATURE_ALG) {
125128
errors.push("ERR_UNSUPPORTED_SIGNATURE_ALG");
126129
}
127130

src/crypto.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { sign, verify, generateKeyPairSync } from "node:crypto";
1717

1818
export const PROTOCOL_VERSION = "1.1.0" as const;
1919
export const CANONICAL_METHOD = "json.sorted_keys.v1" as const;
20-
export const SIGNATURE_ALG = "ed25519" as const;
20+
export const SIGNATURE_ALG = "Ed25519" as const;
2121

2222
/** The ENS text record key for the signer's public key. */
2323
export const ENS_KEY_PUB = "cl.sig.pub" as const;

src/receipt.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* v1.1.0 signed layered receipt builder and verifier.
55
*
66
* Proof field names (canonical, matches clas schema):
7-
* proof.alg — signature algorithm ("ed25519")
7+
* proof.alg — signature algorithm ("Ed25519")
88
* proof.kid — key identifier (from ENS cl.sig.kid)
99
* proof.signer_id — ENS name of signer (e.g. runtime.commandlayer.eth)
1010
* proof.canonical — canonicalization method ("json.sorted_keys.v1")
@@ -31,8 +31,8 @@ export interface ReceiptPayload {
3131
}
3232

3333
export interface ReceiptProof {
34-
/** Signature algorithm. Always "ed25519". */
35-
alg: typeof SIGNATURE_ALG;
34+
/** Signature algorithm. Canonical "Ed25519" (legacy lowercase accepted in verification). */
35+
alg: typeof SIGNATURE_ALG | "ed25519";
3636
/** Key identifier from ENS cl.sig.kid */
3737
kid: string;
3838
/** ENS name of the signer */
@@ -177,7 +177,8 @@ export function verifyReceipt(
177177
checks.structureValid = true;
178178

179179
// Algorithm check
180-
if (proof.alg !== SIGNATURE_ALG) {
180+
const normalizedAlg = proof.alg === "ed25519" ? SIGNATURE_ALG : proof.alg;
181+
if (normalizedAlg !== SIGNATURE_ALG) {
181182
return {
182183
valid: false,
183184
checks,

test/compat.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ describe("canonical CLAS proof envelope", () => {
2323
assert.equal(proof.canonicalization, "json.sorted_keys.v1");
2424
assert.equal(proof.hash.alg, "SHA-256");
2525
assert.ok(proof.hash.value);
26-
assert.equal(proof.signature.alg, "ed25519");
26+
assert.equal(proof.signature.alg, "Ed25519");
2727
assert.ok(proof.signature.value);
2828
assert.equal(proof.signature.kid, "testKid");
2929

@@ -34,6 +34,38 @@ describe("canonical CLAS proof envelope", () => {
3434
assert.equal(isSignedCommandLayerReceipt(signed), true);
3535
});
3636

37+
38+
39+
test("verifies canonical Ed25519 algorithm without caller normalization", () => {
40+
const signed = signCommandLayerReceipt(baseReceipt, { privateKeyPem: kp.privateKeyPem, kid: "testKid" });
41+
const proof = signed.metadata!.proof!;
42+
43+
const canonical = verifyCommandLayerReceipt(
44+
{ ...signed, metadata: { ...signed.metadata!, proof: { ...proof, signature: { ...proof.signature, alg: "Ed25519" } } } },
45+
{ publicKeyPemOrDer: kp.publicKeyPem }
46+
);
47+
assert.equal(canonical.ok, true);
48+
49+
const legacy = verifyCommandLayerReceipt(
50+
{ ...signed, metadata: { ...signed.metadata!, proof: { ...proof, signature: { ...proof.signature, alg: "ed25519" } } } },
51+
{ publicKeyPemOrDer: kp.publicKeyPem }
52+
);
53+
assert.equal(legacy.ok, true);
54+
});
55+
56+
test("fails on unsupported signature algorithms", () => {
57+
const signed = signCommandLayerReceipt(baseReceipt, { privateKeyPem: kp.privateKeyPem, kid: "testKid" });
58+
const proof = signed.metadata!.proof!;
59+
60+
const bad = verifyCommandLayerReceipt(
61+
{ ...signed, metadata: { ...signed.metadata!, proof: { ...proof, signature: { ...proof.signature, alg: "rsa" as never } } } },
62+
{ publicKeyPemOrDer: kp.publicKeyPem }
63+
);
64+
65+
assert.equal(bad.status, "INVALID");
66+
assert.ok(bad.errors.includes("ERR_UNSUPPORTED_SIGNATURE_ALG"));
67+
});
68+
3769
test("requires signature.kid to be a non-empty string", () => {
3870
const signed = signCommandLayerReceipt(baseReceipt, { privateKeyPem: kp.privateKeyPem, kid: "testKid" });
3971
const p = signed.metadata!.proof!;

test/receipt.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ describe("signReceipt", () => {
3232

3333
const proof = receipt.signature.proof;
3434
// Field names must match protocol spec
35-
assert.strictEqual(proof.alg, "ed25519"); // not signature_alg
35+
assert.strictEqual(proof.alg, "Ed25519"); // not signature_alg
3636
assert.strictEqual(proof.kid, "vC4WbcNoq2znSCiQ"); // not key_id
3737
assert.strictEqual(proof.signer_id, "runtime.commandlayer.eth"); // not signer
3838
assert.strictEqual(proof.canonical, "json.sorted_keys.v1");
@@ -163,6 +163,19 @@ describe("verifyReceipt — full round trip", () => {
163163
assert.strictEqual(result.checks.signatureValid, false);
164164
});
165165

166+
167+
168+
it("accepts legacy lowercase ed25519 for compatibility", () => {
169+
const { privateKeyPem, rawPublicKey } = generateEd25519KeyPair();
170+
const signed = signReceipt(makePayload(), {
171+
privateKeyPem, kid: "kid1", signerEns: "test.eth",
172+
});
173+
(signed.signature.proof as Record<string, unknown>).alg = "ed25519";
174+
175+
const result = verifyReceipt(signed, { rawPublicKey });
176+
assert.strictEqual(result.valid, true);
177+
});
178+
166179
it("rejects unknown algorithm", () => {
167180
const { privateKeyPem, rawPublicKey } = generateEd25519KeyPair();
168181
const signed = signReceipt(makePayload(), {
@@ -203,7 +216,7 @@ describe("isSignedLayeredReceipt", () => {
203216
it("returns false when signature is empty string", () => {
204217
const obj = {
205218
receipt: { verb: "test", version: "1.1.0", agent: "x", timestamp: "t" },
206-
signature: { proof: { alg: "ed25519", signature: "", signer_id: "x", kid: "k", canonical: "json.sorted_keys.v1" } },
219+
signature: { proof: { alg: "Ed25519", signature: "", signer_id: "x", kid: "k", canonical: "json.sorted_keys.v1" } },
207220
};
208221
assert.strictEqual(isSignedLayeredReceipt(obj), false);
209222
});

0 commit comments

Comments
 (0)