From 08bb33da8ee66e10f9e6c9bbe8fffc37b06605da Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Sun, 22 Feb 2026 14:47:08 -0500 Subject: [PATCH] Normalize signer envs and enforce signed receipt invariants --- .env.example | 16 +- README.md | 14 +- docs/CONFIGURATION.md | 18 +- runtime/tests/runtime-signing.test.mjs | 154 +++++++++++++++ server.mjs | 262 ++++++++++++++++--------- 5 files changed, 359 insertions(+), 105 deletions(-) create mode 100644 runtime/tests/runtime-signing.test.mjs diff --git a/.env.example b/.env.example index 12b6450..2d05cde 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,7 @@ -# Required runtime signing config -CL_RECEIPT_SIGNER=runtime.commandlayer.eth -CL_KEY_ID=v1 -CL_CANONICAL_ID=json.sorted_keys.v1 - -# Recommended for node --env-file: single-line PEM with literal \n escapes -CL_PRIVATE_KEY_PEM=-----BEGIN PRIVATE KEY-----\nREPLACE_WITH_BASE64_BODY\n-----END PRIVATE KEY----- - -# Base64 of raw 32-byte Ed25519 public key (same bytes as ENS TXT after ed25519:) -CL_PUBLIC_KEY_B64=REPLACE_WITH_32_BYTE_RAW_PUBKEY_BASE64 +# Required runtime signing config (Railway canonical setup) +RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64=REPLACE_WITH_BASE64_OF_PKCS8_PEM_PRIVATE_KEY +RECEIPT_SIGNING_PUBLIC_KEY_B64=hhyCuPNoMk4JtEvGEV8F6nMZ4uDO1EcyizPufmnJTOY= +RECEIPT_SIGNER_ID=runtime.commandlayer.eth # Optional (required only for /verify?ens=1) ETH_RPC_URL= @@ -15,3 +9,5 @@ ETH_RPC_URL= HOST=0.0.0.0 PORT=8080 ENABLED_VERBS=fetch,describe,format,clean,parse,summarize,convert,explain,analyze,classify + +# Optional: DEV_AUTO_KEYS=1 (development only, in-memory ephemeral keypair) diff --git a/README.md b/README.md index 41a659a..c869d66 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,8 @@ openssl genpkey -algorithm Ed25519 -out private.pem openssl pkey -in private.pem -pubout -out public.pem export RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64="$(base64 -w0 < private.pem)" -export RECEIPT_SIGNING_PUBLIC_KEY="ed25519:$(openssl pkey -in public.pem -pubin -outform DER | tail -c 32 | base64 -w0)" -export RECEIPT_SIGNER_ID="runtime.local" +export RECEIPT_SIGNING_PUBLIC_KEY_B64="$(openssl pkey -in public.pem -pubin -outform DER | tail -c 32 | base64 -w0)" +export RECEIPT_SIGNER_ID="runtime.commandlayer.eth" ``` > macOS note: replace `base64 -w0` with `base64 | tr -d '\n'`. @@ -105,7 +105,8 @@ printf '%s' "$RECEIPT" | curl -s -X POST "http://localhost:8080/verify?ens=1" \ `POST /verify` supports query flags: -- `ens=1` — fetch verifier pubkey from ENS TXT records (`VERIFIER_ENS_NAME`, `cl.receipt.signer`, `cl.sig.pub`, `cl.sig.kid`). +- `ens=1` — fetch verifier pubkey from ENS TXT records (`cl.sig.pub`, `cl.sig.canonical`, optional `cl.sig.kid`). +- `strict_kid=1` — when `ens=1` and `cl.sig.kid` exists, require receipt `metadata.proof.kid` to match ENS `cl.sig.kid`. - `refresh=1` — bypass ENS cache and refresh lookup. - `schema=1` — validate receipt against verb schema. @@ -120,6 +121,13 @@ Use `POST /debug/prewarm` and `GET /debug/validators` for schema prewarming work Detailed environment variable documentation lives in [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md). +### ENS TXT format (runtime.commandlayer.eth) + +- `cl.sig.pub = ed25519:` +- `cl.sig.canonical = json.sorted_keys.v1` +- `cl.sig.kid = v1` (optional compatibility marker; runtime derives receipt kid from pubkey fingerprint) +- `cl.receipt.signer = runtime.commandlayer.eth` + ## Security notes - `fetch` only allows `http(s)` URLs. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 9946c1c..4f9c265 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -26,10 +26,21 @@ Comma-separated list of enabled handlers. Disabled verbs return `404`. |---|---|---| | `RECEIPT_SIGNER_ID` | `runtime` (or `ENS_NAME` when set) | Receipt proof signer identifier. | | `RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64` | empty | Required for signing receipts. Base64 of PEM private key. | -| `RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64` | empty | Public key for `/verify` (base64-encoded PEM). | -| `RECEIPT_SIGNING_PUBLIC_KEY_PEM` | empty | Public key for `/verify` (plain PEM text). Either this or the B64 variant is sufficient. | +| `RECEIPT_SIGNING_PUBLIC_KEY_B64` | empty | **Preferred** verifier key input: base64 of raw 32-byte Ed25519 public key. | +| `RECEIPT_SIGNING_PUBLIC_KEY_PEM` | empty | Legacy verifier key input (plain PEM text). | +| `RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64` | empty | Legacy verifier key input (base64-encoded PEM); lower priority than `RECEIPT_SIGNING_PUBLIC_KEY_B64`. | | `ENS_NAME` | empty | Optional identity alias fallback. | +### Env precedence and normalization + +The runtime resolves the first non-empty value from each list: + +- Private key: `CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM` → `RECEIPT_SIGNING_PRIVATE_KEY_PEM` → `CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64` → `RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64` → `CL_RECEIPT_SIGNING_PRIVATE_KEY_B64` → `RECEIPT_SIGNING_PRIVATE_KEY_B64` → `CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM_FILE`. +- Public key: `CL_RECEIPT_SIGNING_PUBLIC_KEY_B64` → `RECEIPT_SIGNING_PUBLIC_KEY_B64` → `CL_RECEIPT_SIGNING_PUBLIC_KEY_PEM` → `RECEIPT_SIGNING_PUBLIC_KEY_PEM` → `CL_RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64` → `RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64` → `CL_RECEIPT_SIGNING_PUBLIC_KEY_PEM_FILE`. +- Signer id: `CL_RECEIPT_SIGNER_ID` → `RECEIPT_SIGNER_ID`. + +`RECEIPT_SIGNING_PUBLIC_KEY_B64` must decode to exactly 32 bytes. + ## ENS-based verification | Variable | Default | Purpose | @@ -39,6 +50,9 @@ Comma-separated list of enabled handlers. Disabled verbs return `404`. | `ENS_SIGNER_TEXT_KEY` | `cl.receipt.signer` | ENS TXT key on verifier name that delegates to signer ENS name. | | `ENS_SIG_PUB_TEXT_KEY` | `cl.sig.pub` | ENS TXT key on signer name containing `ed25519:` public key. | | `ENS_SIG_KID_TEXT_KEY` | `cl.sig.kid` | ENS TXT key on signer name containing key identifier. | +| `ENS_SIG_CANONICAL_KEY` | `cl.sig.canonical` | ENS TXT key on signer name containing canonical mode (e.g. `json.sorted_keys.v1`). | + +`/verify?ens=1` verifies using ENS `cl.sig.pub` key material. `/verify?ens=1&strict_kid=1` additionally enforces `cl.sig.kid` equality when present. ## Schema fetching + validation budgets diff --git a/runtime/tests/runtime-signing.test.mjs b/runtime/tests/runtime-signing.test.mjs new file mode 100644 index 0000000..7b1adec --- /dev/null +++ b/runtime/tests/runtime-signing.test.mjs @@ -0,0 +1,154 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync, spawn } from "node:child_process"; +import net from "node:net"; +import { generateKeyPairSync, createHash } from "node:crypto"; + +function freePort() { + return new Promise((resolve, reject) => { + const s = net.createServer(); + s.on("error", reject); + s.listen(0, "127.0.0.1", () => { + const addr = s.address(); + s.close(() => resolve(addr.port)); + }); + }); +} + +function makeKeys() { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const privatePem = privateKey.export({ type: "pkcs8", format: "pem" }); + const privatePemB64 = Buffer.from(String(privatePem), "utf8").toString("base64"); + const spki = publicKey.export({ type: "spki", format: "der" }); + const raw32 = Buffer.from(spki).subarray(spki.length - 32); + return { + privatePemB64, + publicRaw32B64: raw32.toString("base64"), + kid: createHash("sha256").update(raw32).digest("base64url").slice(0, 16), + }; +} + +async function startServer(extraEnv) { + const port = await freePort(); + const proc = spawn(process.execPath, ["server.mjs"], { + cwd: process.cwd(), + env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), ...extraEnv }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stderr = ""; + proc.stderr.on("data", (d) => (stderr += String(d))); + + const base = `http://127.0.0.1:${port}`; + for (let i = 0; i < 80; i++) { + try { + const r = await fetch(`${base}/health`); + if (r.ok) return { proc, base, stderr: () => stderr }; + } catch {} + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error(`server did not boot: ${stderr}`); +} + +async function stop(proc) { + if (proc.exitCode !== null) return; + proc.kill("SIGTERM"); + await new Promise((r) => setTimeout(r, 200)); + if (proc.exitCode === null) proc.kill("SIGKILL"); +} + +test("boot fails fast without keys unless DEV_AUTO_KEYS=1", async () => { + const res = spawnSync(process.execPath, ["server.mjs"], { + cwd: process.cwd(), + env: { ...process.env, HOST: "127.0.0.1", PORT: "0", RECEIPT_SIGNER_ID: "runtime.commandlayer.eth", DEV_AUTO_KEYS: "0" }, + encoding: "utf8", + timeout: 4000, + }); + assert.notEqual(res.status, 0); + 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 () => { + 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 h = await (await fetch(`${srv.base}/health`)).json(); + assert.equal(h.signer_ok, true); + assert.equal(h.kid, keys.kid); + + const body = { + x402: { verb: "describe", version: "1.0.0", entry: "x402://describeagent.eth/describe/v1.0.0" }, + input: { subject: "t", detail_level: "short" }, + trace: { provider: "test" }, + }; + const receiptResp = await fetch(`${srv.base}/describe/v1.0.0`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + assert.equal(receiptResp.status, 200); + const receipt = await receiptResp.json(); + assert.ok(receipt.metadata?.proof?.signature_b64); + assert.ok(receipt.metadata?.proof?.hash_sha256); + assert.equal(receipt.metadata?.proof?.signer_id, "runtime.commandlayer.eth"); + assert.equal(receipt.metadata?.proof?.canonical, "json.sorted_keys.v1"); + + const verifyResp = await fetch(`${srv.base}/verify`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(receipt), + }); + const verifyJson = await verifyResp.json(); + assert.equal(verifyResp.status, 200); + assert.equal(verifyJson.ok, true); + assert.equal(verifyJson.verified_with, "env"); + } finally { + await stop(srv.proc); + } +}); + +test("/verify?ens=1 passes with mocked ENS TXT response", 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", + }); + 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 body = { + x402: { verb: "describe", version: "1.0.0", entry: "x402://describeagent.eth/describe/v1.0.0" }, + input: { subject: "t", detail_level: "short" }, + trace: { provider: "test" }, + }; + const receipt = await ( + await fetch(`${srv.base}/describe/v1.0.0`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) + ).json(); + + const verifyResp = await fetch(`${srv.base}/verify?ens=1`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(receipt), + }); + const verifyJson = await verifyResp.json(); + assert.equal(verifyResp.status, 200); + assert.equal(verifyJson.ok, true); + assert.equal(verifyJson.verified_with, "ens"); + } finally { + await stop(srv.proc); + } +}); diff --git a/server.mjs b/server.mjs index 8969284..8bf7ac1 100644 --- a/server.mjs +++ b/server.mjs @@ -1,13 +1,18 @@ // server.mjs import express from "express"; import crypto from "crypto"; +import fs from "node:fs"; import Ajv from "ajv"; import addFormats from "ajv-formats"; import { ethers } from "ethers"; import net from "net"; // Runtime-core is the single cryptographic source of truth -import { signReceiptEd25519Sha256, verifyReceiptEd25519Sha256 } from "@commandlayer/runtime-core"; +import { + CANONICAL_ID_SORTED_KEYS_V1, + signReceiptEd25519Sha256, + verifyReceiptEd25519Sha256, +} from "@commandlayer/runtime-core"; /** * Notes @@ -77,17 +82,44 @@ const ENABLED_VERBS = ( .filter(Boolean); const DEV_AUTO_KEYS = envFlag("DEV_AUTO_KEYS"); -const ALLOW_DEFAULT_KID = envFlag("ALLOW_DEFAULT_KID"); -// Canonical config (single source of truth) +function envAnySource(...names) { + for (const n of names) { + const v = process.env[n]; + if (v && String(v).trim()) return { name: n, value: String(v).trim() }; + } + return null; +} + const runtimeConfig = { - signerId: String(envAny("CL_RECEIPT_SIGNER", "RECEIPT_SIGNER_ID") || "").trim(), - kid: String(envAny("CL_KEY_ID", "SIGNER_KID") || "").trim() || (ALLOW_DEFAULT_KID ? "v1" : ""), - canonicalId: String(envAny("CL_CANONICAL_ID", "CANONICAL_ID_SORTED_KEYS_V1") || "").trim(), - privateKeyPem: envAny("CL_PRIVATE_KEY_PEM", "RECEIPT_SIGNING_PRIVATE_KEY_PEM") || "", - privateKeyPemB64: envAny("RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64") || "", - publicKeyRaw32B64: String(envAny("CL_PUBLIC_KEY_B64", "RECEIPT_SIGNING_PUBLIC_KEY_RAW32_B64") || "").trim(), - publicKeyPemB64: String(envAny("RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64") || "").trim(), + signerId: String(envAny("CL_RECEIPT_SIGNER_ID", "RECEIPT_SIGNER_ID", "CL_RECEIPT_SIGNER") || "").trim(), + privateKeySource: envAnySource( + "CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM", + "RECEIPT_SIGNING_PRIVATE_KEY_PEM", + "CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64", + "RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64", + "CL_RECEIPT_SIGNING_PRIVATE_KEY_B64", + "RECEIPT_SIGNING_PRIVATE_KEY_B64", + "CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM_FILE", + "CL_PRIVATE_KEY_PEM", + "CL_PRIVATE_KEY_PEM_B64" + ), + publicKeySource: envAnySource( + "CL_RECEIPT_SIGNING_PUBLIC_KEY_B64", + "RECEIPT_SIGNING_PUBLIC_KEY_B64", + "CL_RECEIPT_SIGNING_PUBLIC_KEY_PEM", + "RECEIPT_SIGNING_PUBLIC_KEY_PEM", + "CL_RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64", + "RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64", + "CL_RECEIPT_SIGNING_PUBLIC_KEY_PEM_FILE", + "CL_PUBLIC_KEY_B64", + "RECEIPT_SIGNING_PUBLIC_KEY_RAW32_B64" + ), + canonicalId: CANONICAL_ID_SORTED_KEYS_V1, + kid: "", + signerSource: "missing", + publicKeyRaw32: null, + publicKeyPemOptional: null, ethRpcUrl: String(envAny("ETH_RPC_URL") || "").trim(), }; @@ -180,21 +212,40 @@ function normalizePemLoose(input) { return `-----BEGIN ${type}-----\n${wrapped}\n-----END ${type}-----`; } -function getPrivatePem() { - // Accept PEM directly (preferred) - const pem = normalizePemLoose(runtimeConfig.privateKeyPem); - if (pem) return pem; +function decodeB64Strict(s) { + try { + const cleaned = String(s || "").replace(/\s+/g, ""); + const out = Buffer.from(cleaned, "base64"); + if (out.length === 0 && cleaned.length) return null; + return out; + } catch { + return null; + } +} - // Accept base64 that decodes to PEM text (migration) - if (runtimeConfig.privateKeyPemB64) { +function getPrivatePemFromEnv() { + const src = runtimeConfig.privateKeySource; + if (!src) return null; + if (src.name.endsWith("_PEM")) return normalizePemLoose(src.value); + if (src.name.endsWith("_PEM_FILE")) { try { - const decoded = Buffer.from(runtimeConfig.privateKeyPemB64, "base64").toString("utf8"); - return normalizePemLoose(decoded); + return normalizePemLoose(fs.readFileSync(src.value, "utf8")); } catch { return null; } } - return null; + const decoded = decodeB64Strict(src.value); + return normalizePemLoose(decoded?.toString("utf8") || ""); +} + +function deriveKidFromRaw32(raw32) { + if (!raw32 || raw32.length !== 32) return ""; + return crypto.createHash("sha256").update(raw32).digest("base64url").slice(0, 16); +} + +function shortFingerprint(raw32) { + if (!raw32 || raw32.length !== 32) return null; + return crypto.createHash("sha256").update(raw32).digest("hex").slice(0, 16); } function isPkcs8PrivatePem(pem) { @@ -204,7 +255,8 @@ function isPkcs8PrivatePem(pem) { function validatePrivateKeyPem(pem) { if (!pem || !isPkcs8PrivatePem(pem)) return { ok: false, reason: "private key must be PKCS8 PEM (BEGIN PRIVATE KEY)" }; try { - crypto.createPrivateKey({ key: pem, format: "pem" }); + const k = crypto.createPrivateKey({ key: pem, format: "pem" }); + if (k.asymmetricKeyType !== "ed25519") return { ok: false, reason: "private key must be Ed25519" }; return { ok: true }; } catch (e) { return { ok: false, reason: e?.message || "invalid private key PEM" }; @@ -240,47 +292,43 @@ function getPublicPemFromRaw32B64(b64) { } function getPublicPemFromEnv() { - const fromRaw32 = getPublicPemFromRaw32B64(runtimeConfig.publicKeyRaw32B64); - if (fromRaw32) return fromRaw32; - - if (runtimeConfig.publicKeyPemB64) { - try { - const decoded = Buffer.from(runtimeConfig.publicKeyPemB64, "base64").toString("utf8"); - return normalizePemLoose(decoded); - } catch { - return null; + const src = runtimeConfig.publicKeySource; + if (!src) return null; + if (src.name.endsWith("_PUBLIC_KEY_B64")) { + const raw = decodeB64Strict(src.value); + if (!raw || raw.length !== 32) return null; + runtimeConfig.publicKeyRaw32 = Buffer.from(raw); + return spkiDerToPem(ed25519RawToSpkiDer(raw)); + } + if (src.name.endsWith("_PEM") || src.name.endsWith("_PEM_FILE") || src.name.endsWith("_PEM_B64")) { + let pem = null; + if (src.name.endsWith("_PEM")) pem = normalizePemLoose(src.value); + else if (src.name.endsWith("_PEM_FILE")) { + try { + pem = normalizePemLoose(fs.readFileSync(src.value, "utf8")); + } catch { + pem = null; + } + } else { + pem = normalizePemLoose(decodeB64Strict(src.value)?.toString("utf8") || ""); } + if (!pem) return null; + const pub = crypto.createPublicKey({ key: pem, format: "pem" }); + if (pub.asymmetricKeyType !== "ed25519") return null; + const der = pub.export({ type: "spki", format: "der" }); + const raw = publicKeyRaw32FromSpkiDer(der); + runtimeConfig.publicKeyRaw32 = Buffer.from(raw); + runtimeConfig.publicKeyPemOptional = pem; + return spkiDerToPem(der); } - return null; } -function countConfiguredPrivateKeys() { - // Refuse ambiguous signer configuration. Exactly one source should be set. - const hasPem = String(runtimeConfig.privateKeyPem || "").trim().length > 0; - const hasPemB64 = String(runtimeConfig.privateKeyPemB64 || "").trim().length > 0; - const present = Number(hasPem) + Number(hasPemB64); - - // If someone accidentally pasted multiple PEM blocks into one var, treat as multiple keys. - const raw = String(runtimeConfig.privateKeyPem || ""); - const pemBlockCount = (raw.match(/-----BEGIN [^-]*PRIVATE KEY-----/g) || []).length; - - return Math.max(present, pemBlockCount); -} - function assertBootConfigOrThrow() { const missing = []; - if (!runtimeConfig.signerId) missing.push("CL_RECEIPT_SIGNER|RECEIPT_SIGNER_ID"); - if (!runtimeConfig.kid) missing.push("CL_KEY_ID|SIGNER_KID"); - if (!runtimeConfig.canonicalId) missing.push("CL_CANONICAL_ID"); - - if (runtimeConfig.canonicalId && runtimeConfig.canonicalId !== "json.sorted_keys.v1") { - throw new Error(`CL_CANONICAL_ID must be json.sorted_keys.v1 (got: ${runtimeConfig.canonicalId})`); - } - - if (countConfiguredPrivateKeys() > 1) { - throw new Error("Multiple private keys configured; set only ONE (prefer CL_PRIVATE_KEY_PEM)"); - } + if (!runtimeConfig.signerId) missing.push("CL_RECEIPT_SIGNER_ID|RECEIPT_SIGNER_ID"); + if (!runtimeConfig.privateKeySource) missing.push("receipt signing private key env var"); + if (!runtimeConfig.publicKeySource) missing.push("receipt signing public key env var"); if (missing.length) throw new Error(`Missing required env var(s): ${missing.join(", ")}`); } @@ -288,7 +336,8 @@ function assertBootConfigOrThrow() { function validatePublicKeyPem(pem) { if (!pem) return { ok: false, reason: "missing public key" }; try { - crypto.createPublicKey({ key: pem, format: "pem" }); + const k = crypto.createPublicKey({ key: pem, format: "pem" }); + if (k.asymmetricKeyType !== "ed25519") return { ok: false, reason: "public key must be Ed25519" }; return { ok: true }; } catch (e) { return { ok: false, reason: e?.message || "invalid public key" }; @@ -319,9 +368,11 @@ function printEnsTxtValues({ pubRaw32B64, kid, canonicalId, signerId }) { const activeSigner = { privateKeyPem: null, - publicKeyRaw32B64: runtimeConfig.publicKeyRaw32B64 || "", + publicKeyRaw32B64: "", publicKeyPem: null, - source: "env", + publicKeyRaw32: null, + publicKeyFingerprint: null, + source: "missing", }; const signerBootState = { @@ -350,12 +401,16 @@ function maybeEnableDevAutoKeys() { activeSigner.privateKeyPem = String(privatePem); activeSigner.publicKeyRaw32B64 = raw32B64; + activeSigner.publicKeyRaw32 = Buffer.from(raw32); activeSigner.publicKeyPem = spkiDerToPem(spkiDer); - activeSigner.source = "dev-auto"; + activeSigner.publicKeyFingerprint = shortFingerprint(raw32); + activeSigner.source = "dev_auto_keys"; + runtimeConfig.kid = deriveKidFromRaw32(raw32); + runtimeConfig.signerSource = "dev_auto_keys"; console.warn("DEV_AUTO_KEYS=1 enabled; generated in-memory temporary Ed25519 keypair."); - console.warn(`TEMP PRIVATE KEY PEM (set CL_PRIVATE_KEY_PEM): ${escapedOneLinePem(activeSigner.privateKeyPem)}`); - console.warn(`TEMP PUBLIC RAW32 B64 (set CL_PUBLIC_KEY_B64): ${activeSigner.publicKeyRaw32B64}`); + console.warn(`TEMP PRIVATE KEY PEM (set RECEIPT_SIGNING_PRIVATE_KEY_PEM): ${escapedOneLinePem(activeSigner.privateKeyPem)}`); + console.warn(`TEMP PUBLIC RAW32 B64 (set RECEIPT_SIGNING_PUBLIC_KEY_B64): ${activeSigner.publicKeyRaw32B64}`); printEnsTxtValues({ pubRaw32B64: activeSigner.publicKeyRaw32B64, kid: runtimeConfig.kid, @@ -373,7 +428,7 @@ function initializeSignerConfigOrThrow() { signerBootState.errors.push(String(e?.message || e)); } - const maybePrivatePem = getPrivatePem(); + const maybePrivatePem = getPrivatePemFromEnv(); const privateCheck = validatePrivateKeyPem(maybePrivatePem); if (!privateCheck.ok) signerBootState.errors.push(`private_key: ${privateCheck.reason}`); else activeSigner.privateKeyPem = maybePrivatePem; @@ -381,7 +436,17 @@ function initializeSignerConfigOrThrow() { const envPublicPem = getPublicPemFromEnv(); const publicCheck = validatePublicKeyPem(envPublicPem); if (!publicCheck.ok) signerBootState.errors.push(`public_key: ${publicCheck.reason}`); - else activeSigner.publicKeyPem = envPublicPem; + else { + activeSigner.publicKeyPem = envPublicPem; + if (runtimeConfig.publicKeyRaw32) { + activeSigner.publicKeyRaw32 = Buffer.from(runtimeConfig.publicKeyRaw32); + activeSigner.publicKeyRaw32B64 = activeSigner.publicKeyRaw32.toString("base64"); + activeSigner.publicKeyFingerprint = shortFingerprint(activeSigner.publicKeyRaw32); + runtimeConfig.kid = deriveKidFromRaw32(activeSigner.publicKeyRaw32); + runtimeConfig.signerSource = "env"; + activeSigner.source = "env"; + } + } if (!activeSigner.privateKeyPem || !activeSigner.publicKeyPem) { maybeEnableDevAutoKeys(); @@ -389,7 +454,7 @@ function initializeSignerConfigOrThrow() { if (!activeSigner.privateKeyPem || !activeSigner.publicKeyPem) { signerBootState.errors.push( - "Invalid signer config: provide CL_PRIVATE_KEY_PEM/RECEIPT_SIGNING_PRIVATE_KEY_PEM/RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64 and CL_PUBLIC_KEY_B64 or RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64" + "Invalid signer config: provide RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64 and RECEIPT_SIGNING_PUBLIC_KEY_B64 (or supported CL_/legacy aliases)" ); } @@ -397,6 +462,11 @@ function initializeSignerConfigOrThrow() { if (!signerBootState.ok) { console.error("[boot] signer config invalid", signerBootState.errors); + if (!DEV_AUTO_KEYS) { + const reason = signerBootState.errors.join("; ") || "invalid signer configuration"; + console.error(`[boot] fatal signer misconfiguration: ${reason}`); + process.exit(1); + } } printEnsTxtValues({ @@ -520,6 +590,31 @@ async function fetchEnsSignerBundle({ signerName, refresh = false } = {}) { const now = Date.now(); const signerEns = normalizeEnsName(signerName || runtimeConfig.signerId); + if (process.env.ENS_MOCK_TXT_JSON) { + try { + const mock = JSON.parse(process.env.ENS_MOCK_TXT_JSON); + const pubTxt = String(mock[ENS_SIG_PUB_KEY] || ""); + const raw = parseEd25519Txt(pubTxt); + const kid = normalizeEnsName(mock[ENS_SIG_KID_KEY]); + const canonical = normalizeEnsName(mock[ENS_SIG_CANONICAL_KEY]); + if (!raw || !canonical) throw new Error("invalid ENS_MOCK_TXT_JSON"); + const bundle = { + fetched_at: now, + ttl_ms: ensCache.ttl_ms, + signer_ens: signerEns, + kid, + canonical, + pubkey_pem: spkiDerToPem(ed25519RawToSpkiDer(raw)), + pubkey_source: "ens_mock", + error: null, + }; + ensCache = { ...ensCache, ...bundle }; + return { ok: true, ...bundle, cache: { ...ensCache } }; + } catch (e) { + return { ok: false, error: e?.message || "ens mock parse failed", cache: { ...ensCache } }; + } + } + if (!signerEns) { ensCache = { ...ensCache, fetched_at: now, error: "Missing signer ENS name", signer_ens: null }; return { ok: false, ...ensCache, cache: { ...ensCache } }; @@ -550,7 +645,7 @@ async function fetchEnsSignerBundle({ signerName, refresh = false } = {}) { const canonical = normalizeEnsName(canonicalTxt); if (!raw) throw new Error(`Missing or invalid ${ENS_SIG_PUB_KEY} on ${signerEns}`); - if (!kid) throw new Error(`Missing ${ENS_SIG_KID_KEY} on ${signerEns}`); + // kid is optional for compatibility; strict checking is handled in /verify?strict_kid=1 if (!canonical) throw new Error(`Missing ${ENS_SIG_CANONICAL_KEY} on ${signerEns}`); const bundle = { @@ -1172,28 +1267,10 @@ function doClassify(body) { // Router: dispatch by verb function respondSigningError(res, e) { - const trace = { provider: process.env.RAILWAY_SERVICE_NAME || "runtime" }; return res.status(500).json({ - status: "error", - x402: { verb: "describe", version: "1.0.0", entry: "x402://describeagent.eth/describe/v1.0.0" }, - trace, - error: { - code: "RECEIPT_SIGNING_FAILED", - message: "receipt signing failed", - retryable: false, - details: String(e?.message || e || "unknown signing error"), - }, - metadata: { - proof: { - alg: "ed25519-sha256", - signer_id: runtimeConfig.signerId, - kid: runtimeConfig.kid, - canonical_id: runtimeConfig.canonicalId, - hash_sha256: null, - signature_b64: null, - }, - receipt_id: "", - }, + ok: false, + code: "SIGNER_MISCONFIGURED", + message: String(e?.message || "signer misconfigured"), ...instancePayload(), }); } @@ -1317,6 +1394,7 @@ app.get("/health", (req, res) => { signer_source: activeSigner.source, kid: runtimeConfig.kid, canonical_id: runtimeConfig.canonicalId, + public_key_fingerprint: activeSigner.publicKeyFingerprint, signer_errors: signerBootState.errors, time: nowIso(), ...instancePayload(), @@ -1462,6 +1540,7 @@ app.post("/verify", async (req, res) => { const receipt = req.body; const wantEns = String(req.query.ens || "0") === "1"; + const strictKid = String(req.query.strict_kid || "0") === "1"; const refresh = String(req.query.refresh || "0") === "1"; const wantSchema = String(req.query.schema || "0") === "1"; @@ -1517,7 +1596,7 @@ app.post("/verify", async (req, res) => { }; // Enforce kid/canonical/signer binding from ENS BEFORE cryptographic verify - const kidOk = String(proof.kid || "") === ensExpect.kid; + const kidOk = strictKid && ensExpect.kid ? String(proof.kid || "") === ensExpect.kid : true; const canonicalOk = proofCanonical === ensExpect.canonical; const signerOk = String(proof.signer_id || "") === ensExpect.signer_ens; @@ -1554,7 +1633,7 @@ app.post("/verify", async (req, res) => { signature_valid: false, ens_match: wantEns ? false : null, }, - error: "no public key available (set CL_PUBLIC_KEY_B64 or use ens=1 with valid ENS TXT)", + error: "no public key available (set RECEIPT_SIGNING_PUBLIC_KEY_B64 or use ens=1 with valid ENS TXT)", ...instancePayload(), }); } @@ -1648,6 +1727,11 @@ app.post("/verify", async (req, res) => { return res.json({ ok: okFinal, + ...(okFinal ? {} : { reason: sigErr || (wantSchema && !schemaOk ? "schema_invalid" : "verify_failed") }), + signer_id: proof?.signer_id ?? null, + kid: proof?.kid ?? null, + hash_sha256: proof?.hash_sha256 ?? null, + verified_with: wantEns ? "ens" : "env", checks: { schema_valid: schemaOk, hash_matches: hashMatches, @@ -1657,10 +1741,8 @@ app.post("/verify", async (req, res) => { values: { verb: receipt?.x402?.verb ?? null, signer_id: proof?.signer_id ?? null, - alg: proof?.alg ?? null, - canonical_id: proofCanonical || null, kid: proof?.kid ?? null, - claimed_hash: proof?.hash_sha256 ?? null, + canonical_id: proofCanonical || null, pubkey_source: pubSrc, ens: ensExpect, },