From 30d6a26e23e271118b3c0495f6e93b3da3a20dbf Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Fri, 20 Feb 2026 15:42:21 -0500 Subject: [PATCH] Normalize signer envs and make receipt signing resilient --- server.mjs | 111 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 98 insertions(+), 13 deletions(-) diff --git a/server.mjs b/server.mjs index b02e1bf..8969284 100644 --- a/server.mjs +++ b/server.mjs @@ -13,7 +13,7 @@ import { signReceiptEd25519Sha256, verifyReceiptEd25519Sha256 } from "@commandla * Notes * - Node 18+ provides global fetch. Node 22 definitely does. * - This runtime is deterministic (non-LLM reference verbs). - * - Receipt signing is required for verb routes (server refuses to boot without signer). + * - Receipt signing is attempted for verb routes when signer config is valid. * - Receipt verification can use env pubkey OR ENS (optional). */ @@ -83,10 +83,11 @@ const ALLOW_DEFAULT_KID = envFlag("ALLOW_DEFAULT_KID"); 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(), + 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("CL_PRIVATE_KEY_PEM_B64", "RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64") || "", + 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(), ethRpcUrl: String(envAny("ETH_RPC_URL") || "").trim(), }; @@ -196,6 +197,20 @@ function getPrivatePem() { return null; } +function isPkcs8PrivatePem(pem) { + return /-----BEGIN PRIVATE KEY-----/.test(String(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" }); + return { ok: true }; + } catch (e) { + return { ok: false, reason: e?.message || "invalid private key PEM" }; + } +} + // Convert `ed25519:` into a PEM SPKI public key. // SPKI DER for Ed25519: 302a300506032b6570032100 || <32-byte pubkey> function ed25519RawToSpkiDer(raw32) { @@ -225,7 +240,19 @@ function getPublicPemFromRaw32B64(b64) { } function getPublicPemFromEnv() { - return getActivePublicPem(); + 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; + } + } + + return null; } function countConfiguredPrivateKeys() { @@ -258,6 +285,16 @@ function assertBootConfigOrThrow() { if (missing.length) throw new Error(`Missing required env var(s): ${missing.join(", ")}`); } +function validatePublicKeyPem(pem) { + if (!pem) return { ok: false, reason: "missing public key" }; + try { + crypto.createPublicKey({ key: pem, format: "pem" }); + return { ok: true }; + } catch (e) { + return { ok: false, reason: e?.message || "invalid public key" }; + } +} + function publicKeyRaw32FromSpkiDer(der) { const buf = Buffer.from(der); @@ -281,12 +318,17 @@ function printEnsTxtValues({ pubRaw32B64, kid, canonicalId, signerId }) { } const activeSigner = { - privateKeyPem: getPrivatePem(), + privateKeyPem: null, publicKeyRaw32B64: runtimeConfig.publicKeyRaw32B64 || "", - publicKeyPem: getPublicPemFromRaw32B64(runtimeConfig.publicKeyRaw32B64), + publicKeyPem: null, source: "env", }; +const signerBootState = { + ok: false, + errors: [], +}; + function getActivePrivatePem() { return activeSigner.privateKeyPem || null; } @@ -323,18 +365,40 @@ function maybeEnableDevAutoKeys() { } function initializeSignerConfigOrThrow() { - assertBootConfigOrThrow(); + signerBootState.errors = []; + + try { + assertBootConfigOrThrow(); + } catch (e) { + signerBootState.errors.push(String(e?.message || e)); + } + + const maybePrivatePem = getPrivatePem(); + const privateCheck = validatePrivateKeyPem(maybePrivatePem); + if (!privateCheck.ok) signerBootState.errors.push(`private_key: ${privateCheck.reason}`); + else activeSigner.privateKeyPem = maybePrivatePem; + + const envPublicPem = getPublicPemFromEnv(); + const publicCheck = validatePublicKeyPem(envPublicPem); + if (!publicCheck.ok) signerBootState.errors.push(`public_key: ${publicCheck.reason}`); + else activeSigner.publicKeyPem = envPublicPem; 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)" + 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" ); } + signerBootState.ok = !!activeSigner.privateKeyPem && !!activeSigner.publicKeyPem && signerBootState.errors.length === 0; + + if (!signerBootState.ok) { + console.error("[boot] signer config invalid", signerBootState.errors); + } + printEnsTxtValues({ pubRaw32B64: activeSigner.publicKeyRaw32B64, kid: runtimeConfig.kid, @@ -708,13 +772,13 @@ function makeReceipt({ x402, trace, result, status = "success", error = null, de if (actor) receipt.metadata.actor = actor; const privPem = getActivePrivatePem(); - if (!privPem) throw new Error("Missing/invalid private key (CL_PRIVATE_KEY_PEM)"); + if (!privPem) throw new Error("Missing/invalid private key"); // runtime-core should populate hash_sha256 + signature_b64 deterministically receipt = signReceiptEd25519Sha256(receipt, { signer_id: runtimeConfig.signerId, kid: runtimeConfig.kid, - canonical: runtimeConfig.canonicalId, + canonical_id: runtimeConfig.canonicalId, privateKeyPem: privPem, }); @@ -1108,8 +1172,28 @@ 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({ - ...makeError(500, "receipt signing failed", { details: String(e?.message || e || "unknown signing error") }), + 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: "", + }, ...instancePayload(), }); } @@ -1228,11 +1312,12 @@ app.get("/health", (req, res) => { port: PORT, enabled_verbs: ENABLED_VERBS, signer_id: runtimeConfig.signerId, - signer_ok: !!getActivePrivatePem(), + signer_ok: signerBootState.ok, verifier_ok: !!getActivePublicPem() || hasRpc(), signer_source: activeSigner.source, kid: runtimeConfig.kid, canonical_id: runtimeConfig.canonicalId, + signer_errors: signerBootState.errors, time: nowIso(), ...instancePayload(), })