From 805399c6666dcc68a20bf4878d28bf22419cbeb6 Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Fri, 20 Mar 2026 13:54:57 -0400 Subject: [PATCH] [runtime] tighten runtime-only verify coverage and repo scope Why: add production-surface receipt/verify tests while removing the in-repo SDK subtree so this repo stays focused on the runtime service layer.\nContract impact: none --- CHANGELOG.md | 14 ++ README.md | 6 +- package.json | 2 +- runtime/src/receipt-verification.js | 2 +- runtime/tests/runtime-signing.test.mjs | 164 +++++++++++++----- .../tests/canonicalization.test.mjs | 11 -- .../tests/ens-delegation.test.mjs | 11 -- .../tests/security-cases.test.mjs | 17 -- 8 files changed, 137 insertions(+), 90 deletions(-) create mode 100644 CHANGELOG.md delete mode 100644 sdk/typescript-sdk/tests/canonicalization.test.mjs delete mode 100644 sdk/typescript-sdk/tests/ens-delegation.test.mjs delete mode 100644 sdk/typescript-sdk/tests/security-cases.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ccbf969 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this runtime repository will be documented in this file. + +## Unreleased + +- Adds production-surface tests for receipt signing and `POST /verify` behavior in `server.mjs`. +- Removes the in-repo `sdk/` subtree so this repository stays scoped to the runtime service layer. + +## v1.0.0 + +- Establishes the Node.js CommandLayer runtime service for versioned verb execution and signed receipts. +- Exposes the runtime health surface (`GET /health` and `GET /healthz`) and receipt verification via `POST /verify`. +- Includes repo-local configuration, smoke coverage, and operational documentation for running the runtime as a service. diff --git a/README.md b/README.md index 651b2a7..5018f3c 100644 --- a/README.md +++ b/README.md @@ -170,11 +170,11 @@ This repository's GitHub Actions workflows currently run: `npm test` runs Node unit tests plus `tests/smoke.mjs`. -## Legacy verification code +## Verification coverage -This repository still contains `runtime/src/receipt-verification.js` and related tests under `runtime/tests/` and `sdk/typescript-sdk/tests/`. +The production verification path is `server.mjs`, which signs receipts and verifies them via `@commandlayer/runtime-core`. -That file is explicitly legacy compatibility code for older fixture-based verification coverage in this repository. It is not the production verification path used by `server.mjs`, which imports signing and verification from `@commandlayer/runtime-core`. +Repo-local test coverage now includes runtime service tests that exercise the receipt production path and `POST /verify` behavior directly, alongside the remaining legacy helper coverage under `runtime/tests/`. ## Configuration and operations diff --git a/package.json b/package.json index bbe7515..8db0cfa 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "check": "node --check server.mjs", "test": "npm run test:unit && node tests/smoke.mjs", "ci": "npm run check && npm test", - "test:unit": "node --test runtime/tests/*.test.mjs sdk/typescript-sdk/tests/*.test.mjs" + "test:unit": "node --test runtime/tests/*.test.mjs" }, "dependencies": { "@commandlayer/runtime-core": "github:commandlayer/runtime-core#main", diff --git a/runtime/src/receipt-verification.js b/runtime/src/receipt-verification.js index ce285c1..ddd719c 100644 --- a/runtime/src/receipt-verification.js +++ b/runtime/src/receipt-verification.js @@ -1,7 +1,7 @@ import crypto from "node:crypto"; /** - * Legacy compatibility verifier used only by repo-local fixtures and SDK parity tests. + * Legacy compatibility verifier used only by repo-local fixtures and compatibility tests. * * Production runtime verification flows through @commandlayer/runtime-core via server.mjs. * Keep this file only for older compatibility material in this repository; do not treat it diff --git a/runtime/tests/runtime-signing.test.mjs b/runtime/tests/runtime-signing.test.mjs index 148c31a..ecbb2fb 100644 --- a/runtime/tests/runtime-signing.test.mjs +++ b/runtime/tests/runtime-signing.test.mjs @@ -49,12 +49,24 @@ async function startServer(extraEnv) { throw new Error(`server did not boot: ${stderr}`); } - function unwrapReceiptResponse(payload) { return { receipt: payload?.receipt || payload, runtimeMetadata: payload?.runtime_metadata || null }; } - +async function createDescribeReceipt(base) { + const body = { + x402: { verb: "describe", version: "1.1.0", entry: "x402://describeagent.eth/describe/v1.1.0" }, + input: { subject: "t", detail_level: "short" }, + trace: { provider: "test" }, + }; + const receiptResp = await fetch(`${base}/describe/v1.1.0`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + assert.equal(receiptResp.status, 200); + return receiptResp.json(); +} async function stop(proc) { if (proc.exitCode !== null) return; @@ -74,7 +86,7 @@ test("boot fails fast without keys unless DEV_AUTO_KEYS=1", async () => { assert.match(`${res.stderr}${res.stdout}`, /fatal signer misconfiguration|Missing required env var/); }); -test("private PEM_B64 + public raw32 b64 path signs and /verify roundtrip works", async () => { +test("makeReceipt production path emits signed receipts with runtime kid and canonical fields", async () => { const keys = makeKeys(); const srv = await startServer({ RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: keys.privatePemB64, @@ -87,55 +99,91 @@ test("private PEM_B64 + public raw32 b64 path signs and /verify roundtrip works" assert.equal(h.signer_ok, true); assert.equal(h.kid, keys.kid); - const body = { - x402: { verb: "describe", version: "1.1.0", entry: "x402://describeagent.eth/describe/v1.1.0" }, - input: { subject: "t", detail_level: "short" }, - trace: { provider: "test" }, - }; - const receiptResp = await fetch(`${srv.base}/describe/v1.1.0`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - assert.equal(receiptResp.status, 200); - const response = await receiptResp.json(); + const response = await createDescribeReceipt(srv.base); const { receipt, runtimeMetadata } = unwrapReceiptResponse(response); + assert.equal(runtimeMetadata?.trace?.provider, "runtime"); - assert.ok(receipt.metadata?.proof?.signature_b64); - assert.ok(receipt.metadata?.proof?.hash_sha256); + assert.equal(receipt.metadata?.proof?.alg, "ed25519-sha256"); assert.equal(receipt.metadata?.proof?.signer_id, "runtime.commandlayer.eth"); + assert.equal(receipt.metadata?.proof?.kid, keys.kid); assert.equal(receipt.metadata?.proof?.canonical, "json.sorted_keys.v1"); + assert.equal(receipt.metadata?.proof?.canonical_id, "json.sorted_keys.v1"); + assert.match(receipt.metadata?.proof?.hash_sha256, /^[a-f0-9]{64}$/); + assert.ok(receipt.metadata?.proof?.signature_b64); + } finally { + await stop(srv.proc); + } +}); + +test("/verify accepts both wrapped and bare receipts from the production signing path", async () => { + const keys = makeKeys(); + const srv = await startServer({ + RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: keys.privatePemB64, + RECEIPT_SIGNING_PUBLIC_KEY_B64: keys.publicRaw32B64, + RECEIPT_SIGNER_ID: "runtime.commandlayer.eth", + }); + + try { + const response = await createDescribeReceipt(srv.base); + const { receipt } = unwrapReceiptResponse(response); + + for (const payload of [response, receipt]) { + const verifyResp = await fetch(`${srv.base}/verify`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + const verifyJson = await verifyResp.json(); + assert.equal(verifyResp.status, 200); + assert.equal(verifyJson.ok, true); + assert.equal(verifyJson.verified_with, "env"); + assert.equal(verifyJson.checks?.hash_matches, true); + assert.equal(verifyJson.checks?.signature_valid, true); + assert.equal(verifyJson.values?.kid, keys.kid); + assert.equal(verifyJson.values?.canonical_id, "json.sorted_keys.v1"); + } + } finally { + await stop(srv.proc); + } +}); + +test("/verify reports a hash/signature failure when a signed production receipt is tampered", async () => { + const keys = makeKeys(); + const srv = await startServer({ + RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: keys.privatePemB64, + RECEIPT_SIGNING_PUBLIC_KEY_B64: keys.publicRaw32B64, + RECEIPT_SIGNER_ID: "runtime.commandlayer.eth", + }); + + try { + const response = await createDescribeReceipt(srv.base); + const { receipt } = unwrapReceiptResponse(response); + const tampered = structuredClone(receipt); + tampered.result.summary = "tampered after signing"; const verifyResp = await fetch(`${srv.base}/verify`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify(response), + body: JSON.stringify(tampered), }); const verifyJson = await verifyResp.json(); - assert.equal(verifyResp.status, 200); - assert.equal(verifyJson.ok, true); - assert.equal(verifyJson.verified_with, "env"); - const verifyBareResp = await fetch(`${srv.base}/verify`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(receipt), - }); - const verifyBareJson = await verifyBareResp.json(); - assert.equal(verifyBareResp.status, 200); - assert.equal(verifyBareJson.ok, true); - assert.equal(verifyBareJson.verified_with, "env"); + assert.equal(verifyResp.status, 200); + assert.equal(verifyJson.ok, false); + assert.equal(verifyJson.checks?.hash_matches, false); + assert.equal(verifyJson.checks?.signature_valid, false); + assert.match(String(verifyJson.errors?.signature_error), /hash_mismatch|verify failed/); } finally { await stop(srv.proc); } }); -test("/verify?ens=1 passes with mocked ENS TXT response", async () => { +test("/verify?ens=1 passes with mocked ENS TXT response and preserves current kid behavior", async () => { const keys = makeKeys(); const ensMock = JSON.stringify({ "cl.sig.pub": `ed25519:${keys.publicRaw32B64}`, "cl.sig.canonical": "json.sorted_keys.v1", - "cl.sig.kid": "v1", + "cl.sig.kid": keys.kid, }); const srv = await startServer({ RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: keys.privatePemB64, @@ -145,21 +193,9 @@ test("/verify?ens=1 passes with mocked ENS TXT response", async () => { }); try { - const body = { - x402: { verb: "describe", version: "1.1.0", entry: "x402://describeagent.eth/describe/v1.1.0" }, - input: { subject: "t", detail_level: "short" }, - trace: { provider: "test" }, - }; - const response = await ( - await fetch(`${srv.base}/describe/v1.1.0`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }) - ).json(); - const { receipt } = unwrapReceiptResponse(response); + const response = await createDescribeReceipt(srv.base); - const verifyResp = await fetch(`${srv.base}/verify?ens=1`, { + const verifyResp = await fetch(`${srv.base}/verify?ens=1&strict_kid=1`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(response), @@ -168,11 +204,47 @@ test("/verify?ens=1 passes with mocked ENS TXT response", async () => { assert.equal(verifyResp.status, 200); assert.equal(verifyJson.ok, true); assert.equal(verifyJson.verified_with, "ens"); + assert.equal(verifyJson.checks?.ens_match, true); + assert.equal(verifyJson.values?.ens?.kid, keys.kid); } finally { await stop(srv.proc); } }); +test("/verify?ens=1&strict_kid=1 rejects a receipt when ENS publishes a different kid", async () => { + const keys = makeKeys(); + const ensMock = JSON.stringify({ + "cl.sig.pub": `ed25519:${keys.publicRaw32B64}`, + "cl.sig.canonical": "json.sorted_keys.v1", + "cl.sig.kid": "rotated-kid", + }); + const srv = await startServer({ + RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: keys.privatePemB64, + RECEIPT_SIGNING_PUBLIC_KEY_B64: keys.publicRaw32B64, + RECEIPT_SIGNER_ID: "runtime.commandlayer.eth", + ENS_MOCK_TXT_JSON: ensMock, + }); + + try { + const response = await createDescribeReceipt(srv.base); + + const verifyResp = await fetch(`${srv.base}/verify?ens=1&strict_kid=1`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(response), + }); + const verifyJson = await verifyResp.json(); + + assert.equal(verifyResp.status, 400); + assert.equal(verifyJson.ok, false); + assert.equal(verifyJson.checks?.ens_match, false); + assert.match(String(verifyJson.error), /does not match ENS signer records/); + assert.equal(verifyJson.values?.proof?.kid, keys.kid); + assert.equal(verifyJson.values?.ens?.kid, "rotated-kid"); + } finally { + await stop(srv.proc); + } +}); test("fabricated x402 defaults use v1.1.0", async () => { const keys = makeKeys(); diff --git a/sdk/typescript-sdk/tests/canonicalization.test.mjs b/sdk/typescript-sdk/tests/canonicalization.test.mjs deleted file mode 100644 index 9f34f55..0000000 --- a/sdk/typescript-sdk/tests/canonicalization.test.mjs +++ /dev/null @@ -1,11 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { computeReceiptHash } from "../../../runtime/src/receipt-verification.js"; -import { loadFixture } from "../../../runtime/tests/helpers.mjs"; - -test("Legacy verification compatibility: stable JSON produces deterministic hash", () => { - const receipt = loadFixture("receipt_valid.json"); - const hash1 = computeReceiptHash(receipt); - const hash2 = computeReceiptHash(JSON.parse(JSON.stringify(receipt))); - assert.equal(hash1, hash2); -}); diff --git a/sdk/typescript-sdk/tests/ens-delegation.test.mjs b/sdk/typescript-sdk/tests/ens-delegation.test.mjs deleted file mode 100644 index e0e7c3f..0000000 --- a/sdk/typescript-sdk/tests/ens-delegation.test.mjs +++ /dev/null @@ -1,11 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { resolveSignerKey } from "../../../runtime/src/receipt-verification.js"; -import { buildResolver } from "../../../runtime/tests/helpers.mjs"; - -test("Legacy verification compatibility: agent delegates to runtime signer", async () => { - const { algorithm, kid, rawPublicKeyBytes } = await resolveSignerKey("parseagent.eth", buildResolver()); - assert.equal(algorithm, "ed25519"); - assert.equal(kid, "v1"); - assert.equal(rawPublicKeyBytes.length, 32); -}); diff --git a/sdk/typescript-sdk/tests/security-cases.test.mjs b/sdk/typescript-sdk/tests/security-cases.test.mjs deleted file mode 100644 index 01d0e36..0000000 --- a/sdk/typescript-sdk/tests/security-cases.test.mjs +++ /dev/null @@ -1,17 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { verifyReceipt } from "../../../runtime/src/receipt-verification.js"; -import { buildResolver, loadFixture } from "../../../runtime/tests/helpers.mjs"; - -test("Legacy verification compatibility: fails if receipt.issuer mismatches ENS name", async () => { - const receipt = loadFixture("receipt_valid.json"); - receipt.issuer = "evil.eth"; - await assert.rejects(() => verifyReceipt(receipt, { resolver: buildResolver(), expectedIssuer: "parseagent.eth" }), /Issuer mismatch/); -}); - -test("Legacy verification compatibility: fails on tampered payload_hash", async () => { - const receipt = loadFixture("receipt_valid.json"); - receipt.payload_hash = "fakehash"; - const result = await verifyReceipt(receipt, { resolver: buildResolver() }); - assert.equal(result.valid, false); -});