diff --git a/README.md b/README.md index bb28043..d34c0b9 100644 --- a/README.md +++ b/README.md @@ -126,18 +126,26 @@ const valid = verifyCanonical(canonical, signature, publicKeyPem); const validFromRaw = verifyCanonicalWithRawKey(canonical, signature, rawPublicKey); ``` -### Legacy compat shims (runtime/server.mjs bridge) +### Canonical CLAS proof envelope -If you need the older `metadata.proof` envelope format, import the compat helpers directly: +Use the canonical metadata proof API: ```ts import { - signReceiptEd25519Sha256, - verifyReceiptEd25519Sha256, + signCommandLayerReceipt, + verifyCommandLayerReceipt, + buildCanonicalProof, + isSignedCommandLayerReceipt, } from '@commandlayer/runtime-core'; ``` -These are explicitly legacy APIs. New integrations should use `signReceipt` / `verifyReceipt`. +Canonical envelope fields: +- `metadata.proof.canonicalization` +- `metadata.proof.hash.alg` +- `metadata.proof.hash.value` +- `metadata.proof.signature.alg` +- `metadata.proof.signature.value` +- `metadata.proof.signature.kid` ### Cross-repo canonicalization alignment diff --git a/package-lock.json b/package-lock.json index 82e0c64..92a6312 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,22 +8,29 @@ "name": "@commandlayer/runtime-core", "version": "1.1.0", "license": "Apache-2.0", - "dependencies": { - "ethers": "^6.13.0" - }, "devDependencies": { "@types/node": "^20.0.0", + "ethers": "^6.13.0", "tsx": "^4.0.0", "typescript": "^5.4.0" }, "engines": { "node": ">=20.0.0" + }, + "peerDependencies": { + "ethers": "^6.13.0" + }, + "peerDependenciesMeta": { + "ethers": { + "optional": true + } } }, "node_modules/@adraffy/ens-normalize": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", + "dev": true, "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { @@ -472,6 +479,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "dev": true, "license": "MIT", "dependencies": { "@noble/hashes": "1.3.2" @@ -484,6 +492,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 16" @@ -506,6 +515,7 @@ "version": "4.0.0-beta.5", "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "dev": true, "license": "MIT" }, "node_modules/esbuild": { @@ -554,6 +564,7 @@ "version": "6.16.0", "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", + "dev": true, "funding": [ { "type": "individual", @@ -582,6 +593,7 @@ "version": "22.7.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.19.2" @@ -591,6 +603,7 @@ "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, "license": "MIT" }, "node_modules/fsevents": { @@ -635,6 +648,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "dev": true, "license": "0BSD" }, "node_modules/tsx": { @@ -682,6 +696,7 @@ "version": "8.17.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/src/canonicalize.ts b/src/canonicalize.ts index c9f77c0..a7d7fc3 100644 --- a/src/canonicalize.ts +++ b/src/canonicalize.ts @@ -181,6 +181,6 @@ export const CANONICAL_TEST_VECTORS = [ input: { verb: "verify", family: "trust", version: "1.0.0" }, expected: '{"family":"trust","verb":"verify","version":"1.0.0"}', // SHA-256 of the canonical string (UTF-8 encoded): - sha256: "3c3e2e6f63b02c1dc4d0dc0f6429bcef5fe27f11059c856218a52a4f43f90e44", + sha256: "7f84cc113290c283fe97e3beb9bd3f65e5de0022e278cad25ef7619c398b1bab", }, ] as const; diff --git a/src/compat.ts b/src/compat.ts index f1b31cf..2fa7317 100644 --- a/src/compat.ts +++ b/src/compat.ts @@ -1,221 +1,88 @@ -/** - * @commandlayer/runtime-core — compat.ts - * - * Backward-compatibility shims for runtime/server.mjs. - * These adapters translate between the runtime's envelope format - * (receipt with metadata.proof) and the core v1.1.0 APIs. - * - * The signing protocol is ALWAYS Ed25519(UTF8(canonical)) — raw bytes. - * The "sha256" in function names is a legacy artifact; these functions - * produce v1.1.0-compliant signatures. - */ - import { createHash } from "node:crypto"; import { canonicalize } from "./canonicalize.js"; -import { signCanonical, verifyCanonical, CANONICAL_METHOD } from "./crypto.js"; - -/** Canonical method identifier constant for import by downstream repos. */ -export const CANONICAL_ID_SORTED_KEYS_V1 = CANONICAL_METHOD; - -// ── Runtime receipt shape (envelope format used by runtime/server.mjs) ──────── +import { signCanonical, verifyCanonical, CANONICAL_METHOD, SIGNATURE_ALG } from "./crypto.js"; -export interface RuntimeReceipt { +export interface CommandLayerReceipt { verb: string; version?: string; agent?: string; timestamp?: string; metadata?: { - proof?: RuntimeProof; + proof?: CommandLayerProof; [key: string]: unknown; }; [key: string]: unknown; } -export interface RuntimeProof { - alg: string; - kid: string; - signer_id: string; - canonical: string; - hash_sha256?: string; - signature?: string; - signature_b64?: string; +export interface CommandLayerProof { + canonicalization: string; + hash: { alg: "sha256"; value: string }; + signature: { alg: typeof SIGNATURE_ALG; value: string; kid: string }; } -// ── Sign ────────────────────────────────────────────────────────────────────── - -export interface SignReceiptCompatOptions { - signer_id: string; - kid: string; - canonical_id?: string; - privateKeyPem: string; +export function buildCanonicalProof(receipt: CommandLayerReceipt): string { + const { metadata: meta = {}, ...rest } = receipt; + const { proof: _proof, ...metaWithoutProof } = meta; + const payload: Record = { ...rest, metadata: metaWithoutProof }; + if (Object.keys(metaWithoutProof).length === 0) delete payload.metadata; + return canonicalize(payload); } -export interface SignedRuntimeReceipt extends RuntimeReceipt { - metadata: { - proof: RuntimeProof; - [key: string]: unknown; - }; -} +export function signCommandLayerReceipt( + receipt: CommandLayerReceipt, + opts: { privateKeyPem: string; kid: string } +): CommandLayerReceipt { + if (!opts.privateKeyPem) throw new Error("privateKeyPem is required"); + if (!opts.kid) throw new Error("kid is required"); -/** - * Sign a runtime-style receipt and embed the proof in metadata.proof. - * - * Signing message: Ed25519(UTF8(canonicalize(receipt_without_proof))) - * The proof block is NOT included in the signed payload. - * - * Returns the receipt with metadata.proof populated. - */ -export function signReceiptEd25519Sha256( - receipt: RuntimeReceipt, - opts: SignReceiptCompatOptions -): SignedRuntimeReceipt { - if (!opts.privateKeyPem || typeof opts.privateKeyPem !== "string") { - throw new Error("signReceiptEd25519Sha256: privateKeyPem is required"); - } - if (!opts.signer_id || typeof opts.signer_id !== "string") { - throw new Error("signReceiptEd25519Sha256: signer_id is required"); - } - if (!opts.kid || typeof opts.kid !== "string") { - throw new Error("signReceiptEd25519Sha256: kid is required"); - } + const canonical = buildCanonicalProof(receipt); + const hash = createHash("sha256").update(canonical, "utf8").digest("hex"); + const sig = signCanonical(canonical, opts.privateKeyPem); - // Strip any existing proof so it's not included in the signed payload const { metadata: meta = {}, ...rest } = receipt; const { proof: _proof, ...metaWithoutProof } = meta; - const payloadToSign: Record = { ...rest, metadata: metaWithoutProof }; - if (Object.keys(metaWithoutProof).length === 0) { - delete payloadToSign.metadata; - } - - const canonical = canonicalize(payloadToSign); - const sha256Hex = createHash("sha256").update(canonical, "utf8").digest("hex"); - const signature = signCanonical(canonical, opts.privateKeyPem); - - const proof: RuntimeProof = { - alg: "ed25519", - kid: opts.kid, - signer_id: opts.signer_id, - canonical: opts.canonical_id ?? CANONICAL_METHOD, - hash_sha256: sha256Hex, - signature_b64: signature, - signature, - }; - return { ...rest, metadata: { ...metaWithoutProof, - proof, + proof: { + canonicalization: CANONICAL_METHOD, + hash: { alg: "sha256", value: hash }, + signature: { alg: SIGNATURE_ALG, value: sig, kid: opts.kid }, + }, }, - } as SignedRuntimeReceipt; -} - -// ── Verify ──────────────────────────────────────────────────────────────────── - -export interface VerifyReceiptCompatOptions { - publicKeyPemOrDer: string; - allowedCanonicals?: string[]; -} - -export interface VerifyReceiptCompatResult { - ok: boolean; - checks: { - signature_valid: boolean; - hash_matches: boolean; }; - reason?: string; } -/** - * Verify a runtime-style receipt (with metadata.proof). - * - * Reconstructs the signed payload by stripping metadata.proof, - * then verifies the Ed25519 signature over the canonical bytes. - * - * Also recomputes sha256 and checks hash_sha256 if present (legacy compat). - */ -export function verifyReceiptEd25519Sha256( - receipt: RuntimeReceipt, - opts: VerifyReceiptCompatOptions -): VerifyReceiptCompatResult { - const checks = { signature_valid: false, hash_matches: false }; - - if (!opts.publicKeyPemOrDer || typeof opts.publicKeyPemOrDer !== "string") { - return { ok: false, checks, reason: "publicKeyPemOrDer is required" }; - } - +export function verifyCommandLayerReceipt( + receipt: CommandLayerReceipt, + opts: { publicKeyPemOrDer: string; allowedCanonicals?: string[] } +): { ok: boolean; reason?: string } { const proof = receipt?.metadata?.proof; - if (!proof) { - return { ok: false, checks, reason: "Missing metadata.proof" }; - } - - // Accept signature from either field; require non-empty string - const sig = (proof.signature && proof.signature.length > 0) - ? proof.signature - : (proof.signature_b64 && proof.signature_b64.length > 0) - ? proof.signature_b64 - : null; - - if (!sig) { - return { ok: false, checks, reason: "Missing proof.signature" }; - } - - const allowedCanonicals = opts.allowedCanonicals ?? [CANONICAL_METHOD]; - if (!allowedCanonicals.includes(proof.canonical)) { - return { - ok: false, - checks, - reason: `Unsupported canonicalization method: ${proof.canonical}`, - }; - } - - // Reconstruct the signed payload (receipt without the proof block) - const { metadata: meta = {}, ...rest } = receipt; - const { proof: _proof, ...metaWithoutProof } = meta; - - const payloadToVerify: Record = { ...rest, metadata: metaWithoutProof }; - if (Object.keys(metaWithoutProof).length === 0) { - delete payloadToVerify.metadata; - } - - let canonical: string; - try { - canonical = canonicalize(payloadToVerify); - } catch (err) { - return { - ok: false, - checks, - reason: `Canonicalization failed: ${(err as Error).message}`, - }; - } - - // Verify sha256 hash if present (legacy field, not required for v1.1.0) - if (proof.hash_sha256) { - const recomputed = createHash("sha256").update(canonical, "utf8").digest("hex"); - checks.hash_matches = recomputed === proof.hash_sha256; - } else { - checks.hash_matches = true; - } - - // Verify signature over raw canonical bytes (v1.1.0 protocol) - try { - checks.signature_valid = verifyCanonical(canonical, sig, opts.publicKeyPemOrDer); - } catch { - return { ok: false, checks, reason: "Signature verification threw an error" }; - } + if (!proof) return { ok: false, reason: "Missing metadata.proof" }; + if (!proof.hash?.alg) return { ok: false, reason: "Missing metadata.proof.hash.alg" }; + if (!proof.hash?.value) return { ok: false, reason: "Missing metadata.proof.hash.value" }; + if (!proof.signature?.alg) return { ok: false, reason: "Missing metadata.proof.signature.alg" }; + if (!proof.signature?.value) return { ok: false, reason: "Missing metadata.proof.signature.value" }; + + const allowed = opts.allowedCanonicals ?? [CANONICAL_METHOD]; + if (!allowed.includes(proof.canonicalization)) return { ok: false, reason: "Unsupported canonicalization" }; + if (proof.hash.alg !== "sha256") return { ok: false, reason: "Unsupported hash algorithm" }; + if (proof.signature.alg !== SIGNATURE_ALG) return { ok: false, reason: "Unsupported signature algorithm" }; + + const canonical = buildCanonicalProof(receipt); + const recomputed = createHash("sha256").update(canonical, "utf8").digest("hex"); + if (recomputed !== proof.hash.value) return { ok: false, reason: "Hash mismatch" }; + + const ok = verifyCanonical(canonical, proof.signature.value, opts.publicKeyPemOrDer); + return ok ? { ok: true } : { ok: false, reason: "signature invalid" }; +} - const ok = checks.signature_valid && checks.hash_matches; - return { - ok, - checks, - reason: ok - ? undefined - : [ - !checks.signature_valid ? "signature invalid" : null, - !checks.hash_matches ? "hash mismatch" : null, - ] - .filter(Boolean) - .join(", "), - }; +export function isSignedCommandLayerReceipt(value: unknown): value is CommandLayerReceipt { + const v = value as CommandLayerReceipt; + return !!v?.metadata?.proof?.hash?.alg + && !!v?.metadata?.proof?.hash?.value + && !!v?.metadata?.proof?.signature?.alg + && !!v?.metadata?.proof?.signature?.value; } diff --git a/src/ens.ts b/src/ens.ts index 0e7b399..0f3e4e0 100644 --- a/src/ens.ts +++ b/src/ens.ts @@ -98,6 +98,9 @@ export async function resolveSignerFromENS( } else { ensName = ensNameOrOpts.ensName; provider = ensNameOrOpts.provider; + if (!provider) { + throw new Error("resolveSignerFromENS: provider is required in options object."); + } } if (!ensName || typeof ensName !== "string") { diff --git a/src/index.ts b/src/index.ts index 40c4cce..43dfb85 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,15 +62,12 @@ export { type VerifyReceiptOptions, } from "./receipt.js"; -// Backward-compatibility shims (for runtime/server.mjs) +// CommandLayer canonical proof envelope APIs export { - CANONICAL_ID_SORTED_KEYS_V1, - signReceiptEd25519Sha256, - verifyReceiptEd25519Sha256, - type RuntimeReceipt, - type RuntimeProof, - type SignReceiptCompatOptions, - type SignedRuntimeReceipt, - type VerifyReceiptCompatOptions, - type VerifyReceiptCompatResult, + buildCanonicalProof, + signCommandLayerReceipt, + verifyCommandLayerReceipt, + isSignedCommandLayerReceipt, + type CommandLayerReceipt, + type CommandLayerProof, } from "./compat.js"; diff --git a/test/canonicalize.test.ts b/test/canonicalize.test.ts index 930df1e..39d8b0f 100644 --- a/test/canonicalize.test.ts +++ b/test/canonicalize.test.ts @@ -74,7 +74,7 @@ describe("canonicalize — SHA-256 audit test vector", () => { * is computed and verified against a known value. * * Canonical form (keys sorted): {"family":"trust","verb":"verify","version":"1.0.0"} - * SHA-256 (hex): 3c3e2e6f63b02c1dc4d0dc0f6429bcef5fe27f11059c856218a52a4f43f90e44 + * SHA-256 (hex): 7f84cc113290c283fe97e3beb9bd3f65e5de0022e278cad25ef7619c398b1bab * * This test locks the canonicalization algorithm to a concrete byte-level * output and proves the SHA-256 is deterministic across Node.js versions. @@ -99,7 +99,7 @@ describe("canonicalize — SHA-256 audit test vector", () => { // If this fails, canonicalization has changed and protocol version must bump. assert.strictEqual( digest, - "3c3e2e6f63b02c1dc4d0dc0f6429bcef5fe27f11059c856218a52a4f43f90e44", + "7f84cc113290c283fe97e3beb9bd3f65e5de0022e278cad25ef7619c398b1bab", "SHA-256 of canonical audit vector must match the known protocol digest" ); }); diff --git a/test/compat.test.ts b/test/compat.test.ts index a5984e2..8eb37a5 100644 --- a/test/compat.test.ts +++ b/test/compat.test.ts @@ -1,21 +1,14 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { - CANONICAL_ID_SORTED_KEYS_V1, - signReceiptEd25519Sha256, - verifyReceiptEd25519Sha256, + signCommandLayerReceipt, + verifyCommandLayerReceipt, + isSignedCommandLayerReceipt, } from "../src/compat.js"; import { generateEd25519KeyPair } from "../src/crypto.js"; -describe("CANONICAL_ID_SORTED_KEYS_V1", () => { - test("equals json.sorted_keys.v1", () => { - assert.equal(CANONICAL_ID_SORTED_KEYS_V1, "json.sorted_keys.v1"); - }); -}); - -describe("signReceiptEd25519Sha256 / verifyReceiptEd25519Sha256", () => { +describe("canonical CLAS proof envelope", () => { const kp = generateEd25519KeyPair(); - const baseReceipt = { verb: "test", version: "1.1.0", @@ -24,115 +17,36 @@ describe("signReceiptEd25519Sha256 / verifyReceiptEd25519Sha256", () => { metadata: { session_id: "abc123" }, }; - test("signs and verifies a receipt", () => { - const signed = signReceiptEd25519Sha256(baseReceipt, { - signer_id: "test.commandlayer.eth", - kid: "testKid", - privateKeyPem: kp.privateKeyPem, - }); - - assert.ok(signed.metadata?.proof, "should have metadata.proof"); - const proof = signed.metadata.proof; - assert.equal(proof.alg, "ed25519"); - assert.equal(proof.kid, "testKid"); - assert.equal(proof.signer_id, "test.commandlayer.eth"); - assert.equal(proof.canonical, "json.sorted_keys.v1"); - assert.ok(proof.signature, "should have signature"); - assert.ok(proof.signature_b64, "should have signature_b64"); - assert.equal(proof.signature, proof.signature_b64, "signature and signature_b64 should match"); - assert.ok(proof.hash_sha256, "should have hash_sha256"); - - const result = verifyReceiptEd25519Sha256(signed, { - publicKeyPemOrDer: kp.publicKeyPem, - }); - - assert.ok(result.ok, `verify should succeed: ${result.reason}`); - assert.ok(result.checks.signature_valid); - assert.ok(result.checks.hash_matches); - }); - - test("verify fails with wrong public key", () => { - const signed = signReceiptEd25519Sha256(baseReceipt, { - signer_id: "test.commandlayer.eth", - kid: "testKid", - privateKeyPem: kp.privateKeyPem, - }); - - const wrongKp = generateEd25519KeyPair(); - const result = verifyReceiptEd25519Sha256(signed, { - publicKeyPemOrDer: wrongKp.publicKeyPem, - }); - - assert.ok(!result.ok, "verify should fail with wrong key"); - assert.ok(!result.checks.signature_valid); + test("signs and verifies with canonical metadata.proof envelope", () => { + const signed = signCommandLayerReceipt(baseReceipt, { privateKeyPem: kp.privateKeyPem, kid: "testKid" }); + const proof = signed.metadata!.proof!; + assert.equal(proof.canonicalization, "json.sorted_keys.v1"); + assert.equal(proof.hash.alg, "sha256"); + assert.ok(proof.hash.value); + assert.equal(proof.signature.alg, "ed25519"); + assert.ok(proof.signature.value); + assert.equal(proof.signature.kid, "testKid"); + + const result = verifyCommandLayerReceipt(signed, { publicKeyPemOrDer: kp.publicKeyPem }); + assert.ok(result.ok, result.reason); + assert.equal(isSignedCommandLayerReceipt(signed), true); }); - test("verify fails if receipt tampered after signing", () => { - const signed = signReceiptEd25519Sha256(baseReceipt, { - signer_id: "test.commandlayer.eth", - kid: "testKid", - privateKeyPem: kp.privateKeyPem, - }); - - const tampered = { ...signed, verb: "tampered" }; - const result = verifyReceiptEd25519Sha256(tampered, { - publicKeyPemOrDer: kp.publicKeyPem, - }); - - assert.ok(!result.ok, "verify should fail for tampered receipt"); - }); - - test("verify fails with missing proof", () => { - const result = verifyReceiptEd25519Sha256(baseReceipt, { - publicKeyPemOrDer: kp.publicKeyPem, - }); - assert.ok(!result.ok); - assert.match(result.reason ?? "", /Missing metadata\.proof/); + test("rejects required canonical fields when missing", () => { + const signed = signCommandLayerReceipt(baseReceipt, { privateKeyPem: kp.privateKeyPem, kid: "testKid" }); + const p = signed.metadata!.proof!; + assert.equal(verifyCommandLayerReceipt({ ...signed, metadata: { ...signed.metadata!, proof: { ...p, hash: { ...p.hash, alg: "" as never } } } }, { publicKeyPemOrDer: kp.publicKeyPem }).ok, false); + assert.equal(verifyCommandLayerReceipt({ ...signed, metadata: { ...signed.metadata!, proof: { ...p, hash: { ...p.hash, value: "" } } } }, { publicKeyPemOrDer: kp.publicKeyPem }).ok, false); + assert.equal(verifyCommandLayerReceipt({ ...signed, metadata: { ...signed.metadata!, proof: { ...p, signature: { ...p.signature, alg: "" as never } } } }, { publicKeyPemOrDer: kp.publicKeyPem }).ok, false); + assert.equal(verifyCommandLayerReceipt({ ...signed, metadata: { ...signed.metadata!, proof: { ...p, signature: { ...p.signature, value: "" } } } }, { publicKeyPemOrDer: kp.publicKeyPem }).ok, false); }); - test("proof block excluded from signed payload — signing is idempotent", () => { - const signed = signReceiptEd25519Sha256(baseReceipt, { - signer_id: "test.commandlayer.eth", - kid: "testKid", - privateKeyPem: kp.privateKeyPem, - }); - - // Re-signing an already-signed receipt should produce the same signature - const signed2 = signReceiptEd25519Sha256(signed, { - signer_id: "test.commandlayer.eth", - kid: "testKid", - privateKeyPem: kp.privateKeyPem, - }); - - assert.equal( - signed.metadata.proof.signature, - signed2.metadata.proof.signature, - "proof block must not be included in signed payload" - ); - - const r2 = verifyReceiptEd25519Sha256(signed2, { publicKeyPemOrDer: kp.publicKeyPem }); - assert.ok(r2.ok); - }); - - test("supports signature via signature_b64 field", () => { - const signed = signReceiptEd25519Sha256(baseReceipt, { - signer_id: "test.commandlayer.eth", - kid: "testKid", - privateKeyPem: kp.privateKeyPem, - }); - - // Simulate a legacy receipt that only has signature_b64 - const legacy = { - ...signed, - metadata: { - ...signed.metadata, - proof: { ...signed.metadata.proof, signature: undefined }, - }, + test("rejects legacy fields as canonical", () => { + const legacyLike = { + ...baseReceipt, + metadata: { proof: { signature_b64: "abc", hash_sha256: "def", canonicalization: "json.sorted_keys.v1" } }, }; - - const result = verifyReceiptEd25519Sha256(legacy as typeof signed, { - publicKeyPemOrDer: kp.publicKeyPem, - }); - assert.ok(result.ok, `should verify via signature_b64: ${result.reason}`); + const result = verifyCommandLayerReceipt(legacyLike as never, { publicKeyPemOrDer: kp.publicKeyPem }); + assert.equal(result.ok, false); }); });