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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <lowercase hex digest>`
- `metadata.proof.signature.alg = "ed25519"`
- `metadata.proof.signature.alg = "Ed25519"`
- `metadata.proof.signature.value = <base64 signature>`
- `metadata.proof.signature.kid = <required key id>`

Expand Down
7 changes: 5 additions & 2 deletions src/compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
}

Expand Down
2 changes: 1 addition & 1 deletion src/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 5 additions & 4 deletions src/receipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 */
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 33 additions & 1 deletion test/compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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!;
Expand Down
17 changes: 15 additions & 2 deletions test/receipt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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<string, unknown>).alg = "ed25519";

const result = verifyReceipt(signed, { rawPublicKey });
assert.strictEqual(result.valid, true);
});

it("rejects unknown algorithm", () => {
const { privateKeyPem, rawPublicKey } = generateEd25519KeyPair();
const signed = signReceipt(makePayload(), {
Expand Down Expand Up @@ -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);
});
Expand Down
Loading