From c3261e98020275678a9699a807d082cb7e83ec2e Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Tue, 12 May 2026 21:25:50 -0400 Subject: [PATCH] feat: add compat shims, prepare script, v1.1.0 exports - src/compat.ts: CANONICAL_ID_SORTED_KEYS_V1, signReceiptEd25519Sha256, verifyReceiptEd25519Sha256 for runtime/server.mjs backward compat - src/index.ts: export compat shims - package.json: add prepare script so GitHub installs build dist/ - test/compat.test.ts: round-trip sign/verify, tamper detection, idempotency --- package.json | 5 ++ src/compat.ts | 201 ++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 14 +++ test/compat.test.ts | 138 ++++++++++++++++++++++++++++++ 4 files changed, 358 insertions(+) create mode 100644 src/compat.ts create mode 100644 test/compat.test.ts diff --git a/package.json b/package.json index 2d011c1..ba1a860 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,10 @@ "./receipt": { "import": "./dist/receipt.js", "types": "./dist/receipt.d.ts" + }, + "./compat": { + "import": "./dist/compat.js", + "types": "./dist/compat.d.ts" } }, "files": [ @@ -43,6 +47,7 @@ "scripts": { "build": "tsc", "build:watch": "tsc --watch", + "prepare": "npm run build", "test": "node --test --import tsx/esm test/**/*.test.ts", "test:build": "npm run build && node --test dist/test/**/*.test.js", "typecheck": "tsc --noEmit", diff --git a/src/compat.ts b/src/compat.ts new file mode 100644 index 0000000..966f0fd --- /dev/null +++ b/src/compat.ts @@ -0,0 +1,201 @@ +/** + * @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) ──────── + +export interface RuntimeReceipt { + verb: string; + version?: string; + agent?: string; + timestamp?: string; + metadata?: { + proof?: RuntimeProof; + [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; +} + +// ── Sign ────────────────────────────────────────────────────────────────────── + +export interface SignReceiptCompatOptions { + signer_id: string; + kid: string; + canonical_id?: string; + privateKeyPem: string; +} + +export interface SignedRuntimeReceipt extends RuntimeReceipt { + metadata: { + proof: RuntimeProof; + [key: string]: unknown; + }; +} + +/** + * 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 { + // 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, + }, + } 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 }; + + const proof = receipt?.metadata?.proof; + if (!proof) { + return { ok: false, checks, reason: "Missing metadata.proof" }; + } + + const sig = proof.signature || proof.signature_b64; + 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" }; + } + + 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(", "), + }; +} diff --git a/src/index.ts b/src/index.ts index 7100ab4..713fef3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ * - ENS resolution * - Receipt building and verification (v1.1.0) * - Test vectors + * - Backward-compatibility shims (for runtime/server.mjs) */ // Protocol constants @@ -59,3 +60,16 @@ export { type VerifyReceiptResult, type VerifyReceiptOptions, } from "./receipt.js"; + +// Backward-compatibility shims (for runtime/server.mjs) +export { + CANONICAL_ID_SORTED_KEYS_V1, + signReceiptEd25519Sha256, + verifyReceiptEd25519Sha256, + type RuntimeReceipt, + type RuntimeProof, + type SignReceiptCompatOptions, + type SignedRuntimeReceipt, + type VerifyReceiptCompatOptions, + type VerifyReceiptCompatResult, +} from "./compat.js"; diff --git a/test/compat.test.ts b/test/compat.test.ts new file mode 100644 index 0000000..a5984e2 --- /dev/null +++ b/test/compat.test.ts @@ -0,0 +1,138 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { + CANONICAL_ID_SORTED_KEYS_V1, + signReceiptEd25519Sha256, + verifyReceiptEd25519Sha256, +} 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", () => { + const kp = generateEd25519KeyPair(); + + const baseReceipt = { + verb: "test", + version: "1.1.0", + agent: "test.commandlayer.eth", + timestamp: "2026-05-13T00:00:00.000Z", + 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("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("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 }, + }, + }; + + const result = verifyReceiptEd25519Sha256(legacy as typeof signed, { + publicKeyPemOrDer: kp.publicKeyPem, + }); + assert.ok(result.ok, `should verify via signature_b64: ${result.reason}`); + }); +});