From 579239472a982ebeb0cb4b6c1889f0c47008ee71 Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Thu, 19 Feb 2026 22:40:28 -0500 Subject: [PATCH] Normalize receipt signing config and harden verify/health flows --- README.md | 13 +++ server.mjs | 251 +++++++++++++++++++++++++++++++++++++---------------- 2 files changed, 191 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 97b4ae7..41a659a 100644 --- a/README.md +++ b/README.md @@ -136,3 +136,16 @@ Detailed environment variable documentation lives in [`docs/CONFIGURATION.md`](d See [`docs/OPERATIONS.md`](docs/OPERATIONS.md) for deployment and runbook guidance. + +## Deterministic signing test commands + +```bash +# mint +curl -s -X POST http://localhost:8080/describe/v1.0.0 -H "Content-Type: application/json" -d '{"x402":{"verb":"describe","version":"1.0.0","entry":"x402://describeagent.eth/describe/v1.0.0"},"input":{"subject":"CommandLayer","detail_level":"short"}}' | tee receipt.json | jq '.metadata.proof | {kid, canonical_id, hash_sha256, signature_b64}' + +# verify env +curl -s -X POST http://localhost:8080/verify -H "Content-Type: application/json" --data-binary @receipt.json | jq . + +# verify ens +curl -s -X POST "http://localhost:8080/verify?ens=1" -H "Content-Type: application/json" --data-binary @receipt.json | jq . +``` diff --git a/server.mjs b/server.mjs index 44a6bd5..b02e1bf 100644 --- a/server.mjs +++ b/server.mjs @@ -27,10 +27,9 @@ function envAny(...names) { } return null; } -function requireEnvAny(...names) { - const v = envAny(...names); - if (!v) throw new Error(`Missing env (any of): ${names.join(", ")}`); - return v; + +function envFlag(name) { + return String(process.env[name] || "0") === "1"; } // ----------------------- @@ -77,23 +76,19 @@ const ENABLED_VERBS = ( .map((s) => s.trim()) .filter(Boolean); -// Canonical config (prefer CL_* but accept legacy aliases) -const SIGNER_ID = String( - envAny("CL_RECEIPT_SIGNER", "RECEIPT_SIGNER_ID", "RECEIPT_SIGNER", "receipt_signer") || "" -).trim(); -const SIGNER_KID = String(envAny("CL_KEY_ID", "SIGNER_KID", "signer_kid") || "").trim(); -const CL_CANONICAL_ID = String( - envAny("CL_CANONICAL_ID", "SIGNER_CANONICAL_ID", "SIGNER_CANONICAL", "cl_sig_canonical") || "" -).trim(); - -// Private key: prefer CL_PRIVATE_KEY_PEM (supports \n escapes), accept legacy pem or pem_b64 -const PRIV_PEM_RAW = envAny("CL_PRIVATE_KEY_PEM", "RECEIPT_SIGNING_PRIVATE_KEY_PEM") || ""; -const PRIV_PEM_B64 = envAny("CL_PRIVATE_KEY_PEM_B64", "RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64") || ""; - -// Public key (raw 32-byte base64): prefer CL_PUBLIC_KEY_B64, accept legacy -const PUB_RAW32_B64 = String( - envAny("CL_PUBLIC_KEY_B64", "RECEIPT_SIGNING_PUBLIC_KEY_RAW32_B64", "PUBLIC_KEY_RAW32_B64") || "" -).trim(); +const DEV_AUTO_KEYS = envFlag("DEV_AUTO_KEYS"); +const ALLOW_DEFAULT_KID = envFlag("ALLOW_DEFAULT_KID"); + +// Canonical config (single source of truth) +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") || "").trim(), + privateKeyPem: envAny("CL_PRIVATE_KEY_PEM", "RECEIPT_SIGNING_PRIVATE_KEY_PEM") || "", + privateKeyPemB64: envAny("CL_PRIVATE_KEY_PEM_B64", "RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64") || "", + publicKeyRaw32B64: String(envAny("CL_PUBLIC_KEY_B64", "RECEIPT_SIGNING_PUBLIC_KEY_RAW32_B64") || "").trim(), + ethRpcUrl: String(envAny("ETH_RPC_URL") || "").trim(), +}; // service identity / discovery const SERVICE_NAME = process.env.SERVICE_NAME || "commandlayer-runtime"; @@ -102,7 +97,7 @@ const CANONICAL_BASE = (process.env.CANONICAL_BASE_URL || "https://runtime.comma const API_VERSION = process.env.API_VERSION || "1.0.0"; // ENS verifier config -const ETH_RPC_URL = envAny("ETH_RPC_URL", "ETH_RPC", "RPC_URL") || ""; +const ETH_RPC_URL = runtimeConfig.ethRpcUrl; // TXT keys (defaults match your records) const ENS_SIG_PUB_KEY = process.env.ENS_SIG_PUB_KEY || "cl.sig.pub"; @@ -186,13 +181,13 @@ function normalizePemLoose(input) { function getPrivatePem() { // Accept PEM directly (preferred) - const pem = normalizePemLoose(PRIV_PEM_RAW); + const pem = normalizePemLoose(runtimeConfig.privateKeyPem); if (pem) return pem; // Accept base64 that decodes to PEM text (migration) - if (PRIV_PEM_B64) { + if (runtimeConfig.privateKeyPemB64) { try { - const decoded = Buffer.from(PRIV_PEM_B64, "base64").toString("utf8"); + const decoded = Buffer.from(runtimeConfig.privateKeyPemB64, "base64").toString("utf8"); return normalizePemLoose(decoded); } catch { return null; @@ -230,21 +225,17 @@ function getPublicPemFromRaw32B64(b64) { } function getPublicPemFromEnv() { - return getPublicPemFromRaw32B64(PUB_RAW32_B64); + return getActivePublicPem(); } function countConfiguredPrivateKeys() { // Refuse ambiguous signer configuration. Exactly one source should be set. - const candidates = [ - "CL_PRIVATE_KEY_PEM", - "CL_PRIVATE_KEY_PEM_B64", - "RECEIPT_SIGNING_PRIVATE_KEY_PEM", - "RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64", - ]; - const present = candidates.filter((k) => String(process.env[k] || "").trim().length > 0).length; + 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(PRIV_PEM_RAW || ""); + const raw = String(runtimeConfig.privateKeyPem || ""); const pemBlockCount = (raw.match(/-----BEGIN [^-]*PRIVATE KEY-----/g) || []).length; return Math.max(present, pemBlockCount); @@ -252,26 +243,104 @@ function countConfiguredPrivateKeys() { function assertBootConfigOrThrow() { const missing = []; - if (!SIGNER_ID) missing.push("CL_RECEIPT_SIGNER"); - if (!SIGNER_KID) missing.push("CL_KEY_ID"); - if (!CL_CANONICAL_ID) missing.push("CL_CANONICAL_ID"); - if (!String(PRIV_PEM_RAW || PRIV_PEM_B64 || "").trim()) missing.push("CL_PRIVATE_KEY_PEM"); - if (!PUB_RAW32_B64) missing.push("CL_PUBLIC_KEY_B64"); - - if (missing.length) throw new Error(`Missing required env var(s): ${missing.join(", ")}`); + 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 (CL_CANONICAL_ID !== "json.sorted_keys.v1") { - throw new Error(`CL_CANONICAL_ID must be json.sorted_keys.v1 (got: ${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 (!getPrivatePem()) throw new Error("Invalid private key (supports \\n escapes and PEM_B64)"); - if (!getPublicPemFromEnv()) { - throw new Error("Invalid CL_PUBLIC_KEY_B64 (must be base64 of raw 32-byte Ed25519 public key)"); + if (missing.length) throw new Error(`Missing required env var(s): ${missing.join(", ")}`); +} + + +function publicKeyRaw32FromSpkiDer(der) { + const buf = Buffer.from(der); + if (buf.length < 32) throw new Error("invalid spki der length"); + return buf.subarray(buf.length - 32); +} + + + +function escapedOneLinePem(pem) { + return String(pem || "").replace(/\n/g, "\\n"); +} + +function printEnsTxtValues({ pubRaw32B64, kid, canonicalId, signerId }) { + if (!pubRaw32B64) return; + console.log("ENS TXT values to set"); + console.log(`cl.sig.pub = ed25519:${pubRaw32B64}`); + console.log(`cl.sig.kid = ${kid}`); + console.log(`cl.sig.canonical = ${canonicalId}`); + console.log(`cl.receipt.signer = ${signerId || "runtime.commandlayer.eth"} (optional)`); +} + +const activeSigner = { + privateKeyPem: getPrivatePem(), + publicKeyRaw32B64: runtimeConfig.publicKeyRaw32B64 || "", + publicKeyPem: getPublicPemFromRaw32B64(runtimeConfig.publicKeyRaw32B64), + source: "env", +}; + +function getActivePrivatePem() { + return activeSigner.privateKeyPem || null; +} + +function getActivePublicPem() { + return activeSigner.publicKeyPem || null; +} + +function maybeEnableDevAutoKeys() { + const missingPrivate = !activeSigner.privateKeyPem; + const missingPublic = !activeSigner.publicKeyPem; + if (!DEV_AUTO_KEYS || (!missingPrivate && !missingPublic)) return; + + const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519"); + const privatePem = privateKey.export({ type: "pkcs8", format: "pem" }); + const spkiDer = publicKey.export({ type: "spki", format: "der" }); + const raw32 = publicKeyRaw32FromSpkiDer(spkiDer); + const raw32B64 = raw32.toString("base64"); + + activeSigner.privateKeyPem = String(privatePem); + activeSigner.publicKeyRaw32B64 = raw32B64; + activeSigner.publicKeyPem = spkiDerToPem(spkiDer); + activeSigner.source = "dev-auto"; + + 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}`); + printEnsTxtValues({ + pubRaw32B64: activeSigner.publicKeyRaw32B64, + kid: runtimeConfig.kid, + canonicalId: runtimeConfig.canonicalId, + signerId: runtimeConfig.signerId, + }); +} + +function initializeSignerConfigOrThrow() { + assertBootConfigOrThrow(); + + if (!activeSigner.privateKeyPem || !activeSigner.publicKeyPem) { + maybeEnableDevAutoKeys(); + } + + if (!activeSigner.privateKeyPem || !activeSigner.publicKeyPem) { + throw new Error( + "Invalid signer config: provide valid CL_PRIVATE_KEY_PEM/CL_PRIVATE_KEY_PEM_B64 and CL_PUBLIC_KEY_B64 (or enable DEV_AUTO_KEYS=1 for temp in-memory keys)" + ); } + + printEnsTxtValues({ + pubRaw32B64: activeSigner.publicKeyRaw32B64, + kid: runtimeConfig.kid, + canonicalId: runtimeConfig.canonicalId, + signerId: runtimeConfig.signerId, + }); } function enabled(verb) { @@ -385,7 +454,7 @@ let ensCache = { async function fetchEnsSignerBundle({ signerName, refresh = false } = {}) { const now = Date.now(); - const signerEns = normalizeEnsName(signerName || SIGNER_ID); + const signerEns = normalizeEnsName(signerName || runtimeConfig.signerId); if (!signerEns) { ensCache = { ...ensCache, fetched_at: now, error: "Missing signer ENS name", signer_ens: null }; @@ -626,9 +695,9 @@ function makeReceipt({ x402, trace, result, status = "success", error = null, de metadata: { proof: { alg: "ed25519-sha256", - canonical_id: CL_CANONICAL_ID, - signer_id: SIGNER_ID, - kid: SIGNER_KID, + canonical: runtimeConfig.canonicalId, + signer_id: runtimeConfig.signerId, + kid: runtimeConfig.kid, hash_sha256: null, signature_b64: null, }, @@ -638,21 +707,36 @@ function makeReceipt({ x402, trace, result, status = "success", error = null, de if (actor) receipt.metadata.actor = actor; - const privPem = getPrivatePem(); + const privPem = getActivePrivatePem(); if (!privPem) throw new Error("Missing/invalid private key (CL_PRIVATE_KEY_PEM)"); // runtime-core should populate hash_sha256 + signature_b64 deterministically receipt = signReceiptEd25519Sha256(receipt, { - signer_id: SIGNER_ID, - kid: SIGNER_KID, - canonical_id: CL_CANONICAL_ID, + signer_id: runtimeConfig.signerId, + kid: runtimeConfig.kid, + canonical: runtimeConfig.canonicalId, privateKeyPem: privPem, }); + if (receipt?.metadata?.proof?.canonical && !receipt.metadata.proof.canonical_id) { + receipt.metadata.proof.canonical_id = receipt.metadata.proof.canonical; + } + return receipt; } // ----------------------- + +function normalizeReceiptForRuntimeCoreVerify(receipt) { + const cloned = JSON.parse(JSON.stringify(receipt || {})); + if (cloned?.metadata?.proof && typeof cloned.metadata.proof === "object") { + const proof = cloned.metadata.proof; + if (!proof.canonical && proof.canonical_id) proof.canonical = proof.canonical_id; + delete proof.canonical_id; + } + return cloned; +} + // deterministic verb implementations // ----------------------- async function doFetch(body) { @@ -1022,6 +1106,14 @@ function doClassify(body) { } // Router: dispatch by verb + +function respondSigningError(res, e) { + return res.status(500).json({ + ...makeError(500, "receipt signing failed", { details: String(e?.message || e || "unknown signing error") }), + ...instancePayload(), + }); +} + const handlers = { fetch: doFetch, describe: async (b) => doDescribe(b), @@ -1069,8 +1161,12 @@ async function handleVerb(verb, req, res) { ? { id: String(req.body.x402.tenant), role: "tenant" } : null; - const receipt = makeReceipt({ x402, trace, result, status: "success", actor }); - return res.json(receipt); + try { + const receipt = makeReceipt({ x402, trace, result, status: "success", actor }); + return res.json(receipt); + } catch (signErr) { + return respondSigningError(res, signErr); + } } catch (e) { const x402 = req.body?.x402 || { verb, version: "1.0.0", entry: `x402://${verb}agent.eth/${verb}/v1.0.0` }; @@ -1087,9 +1183,12 @@ async function handleVerb(verb, req, res) { details: { verb }, }; - // Because we enforce signer at boot, this should not throw. - const receipt = makeReceipt({ x402, trace, status: "error", error: err, actor }); - return res.status(500).json(receipt); + try { + const receipt = makeReceipt({ x402, trace, status: "error", error: err, actor }); + return res.status(500).json(receipt); + } catch (signErr) { + return respondSigningError(res, signErr); + } } } @@ -1128,11 +1227,12 @@ app.get("/health", (req, res) => { host: HOST, port: PORT, enabled_verbs: ENABLED_VERBS, - signer_id: SIGNER_ID, - signer_ok: !!getPrivatePem(), - verifier_ok: !!getPublicPemFromEnv(), - kid: SIGNER_KID, - canonical_id: CL_CANONICAL_ID, + signer_id: runtimeConfig.signerId, + signer_ok: !!getActivePrivatePem(), + verifier_ok: !!getActivePublicPem() || hasRpc(), + signer_source: activeSigner.source, + kid: runtimeConfig.kid, + canonical_id: runtimeConfig.canonicalId, time: nowIso(), ...instancePayload(), }) @@ -1147,8 +1247,8 @@ app.get("/healthz", (req, res) => { // debug (gated) // ----------------------- app.get("/debug/env", requireDebug, (req, res) => { - const privPem = getPrivatePem(); - const pubPem = getPublicPemFromEnv(); + const privPem = getActivePrivatePem(); + const pubPem = getActivePublicPem(); res.json({ ok: true, @@ -1157,9 +1257,11 @@ app.get("/debug/env", requireDebug, (req, res) => { port: PORT, service: process.env.RAILWAY_SERVICE_NAME || "runtime", enabled_verbs: ENABLED_VERBS, - signer_id: SIGNER_ID, - signer_kid: SIGNER_KID, + signer_id: runtimeConfig.signerId, + signer_kid: runtimeConfig.kid, signer_ok: !!privPem, + verifier_ok: !!pubPem || hasRpc(), + signer_source: activeSigner.source, env_presence: { CL_RECEIPT_SIGNER: !!process.env.CL_RECEIPT_SIGNER, CL_KEY_ID: !!process.env.CL_KEY_ID, @@ -1174,6 +1276,8 @@ app.get("/debug/env", requireDebug, (req, res) => { public_key: "cl_public_key_b64_raw32", private_key_valid: !!privPem, public_key_valid: !!pubPem, + private_key_chars: privPem ? String(privPem).length : 0, + public_key_raw32_b64_chars: activeSigner.publicKeyRaw32B64 ? activeSigner.publicKeyRaw32B64.length : 0, }, ens_keys: { sig_pub: ENS_SIG_PUB_KEY, @@ -1205,7 +1309,7 @@ app.get("/debug/env", requireDebug, (req, res) => { service_version: SERVICE_VERSION, api_version: API_VERSION, canonical_base_url: CANONICAL_BASE, - canonical_id: CL_CANONICAL_ID, + canonical_id: runtimeConfig.canonicalId, debug: { enable_debug: ENABLE_DEBUG, has_debug_token: !!DEBUG_TOKEN }, }); }); @@ -1278,6 +1382,7 @@ app.post("/verify", async (req, res) => { const proof = receipt?.metadata?.proof; const proofCanonical = String(proof?.canonical_id || proof?.canonical || ""); + const runtimeCoreReceipt = normalizeReceiptForRuntimeCoreVerify(receipt); if (!proof?.signature_b64 || !proof?.hash_sha256) { return res.status(400).json({ ok: false, @@ -1300,7 +1405,7 @@ app.post("/verify", async (req, res) => { let ensExpect = null; if (wantEns) { - const signerForEns = String(proof?.signer_id || SIGNER_ID || "").trim(); + const signerForEns = String(proof?.signer_id || runtimeConfig.signerId || "").trim(); const ensOut = await fetchEnsSignerBundle({ signerName: signerForEns, refresh }); if (!ensOut.ok || !ensOut.pubkey_pem) { return res.status(400).json({ @@ -1372,9 +1477,9 @@ app.post("/verify", async (req, res) => { // 2) verify signature/hash via runtime-core let v; try { - v = verifyReceiptEd25519Sha256(receipt, { + v = verifyReceiptEd25519Sha256(runtimeCoreReceipt, { publicKeyPemOrDer: pubPem, - allowedCanonicals: [CL_CANONICAL_ID], + allowedCanonicals: [runtimeConfig.canonicalId], }); } catch (e) { return res.status(400).json({ @@ -1494,7 +1599,7 @@ app.post("/verify", async (req, res) => { // ----------------------- // boot + listen // ----------------------- -assertBootConfigOrThrow(); +initializeSignerConfigOrThrow(); app.listen(PORT, HOST, () => { console.log(`runtime listening on http://${HOST}:${PORT}`);