From 381da50cf4f117fbff494a17b7e286b518906269 Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Thu, 19 Feb 2026 22:06:16 -0500 Subject: [PATCH] Harden runtime signing config and verification paths --- .env.example | 17 +++ scripts/smoke.mjs | 47 +++++++++ server.mjs | 262 ++++++++++++++++++++-------------------------- 3 files changed, 178 insertions(+), 148 deletions(-) create mode 100644 .env.example create mode 100644 scripts/smoke.mjs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..12b6450 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# 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 + +# Optional (required only for /verify?ens=1) +ETH_RPC_URL= + +HOST=0.0.0.0 +PORT=8080 +ENABLED_VERBS=fetch,describe,format,clean,parse,summarize,convert,explain,analyze,classify diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs new file mode 100644 index 0000000..024acdc --- /dev/null +++ b/scripts/smoke.mjs @@ -0,0 +1,47 @@ +import process from "node:process"; + +const base = process.env.SMOKE_BASE_URL || `http://127.0.0.1:${process.env.PORT || 8080}`; +const input = { + x402: { entry: "x402://describeagent.eth/describe/v1.0.0", verb: "describe", version: "1.0.0" }, + input: { subject: "CommandLayer", detail_level: "short" }, +}; + +function fail(step, details) { + console.error(`[smoke] FAIL: ${step}`); + if (details) console.error(typeof details === "string" ? details : JSON.stringify(details, null, 2)); + process.exit(1); +} + +try { + const healthResp = await fetch(`${base}/health`); + const health = await healthResp.json(); + if (!healthResp.ok) fail("/health http", health); + if (!health.signer_ok || !health.verifier_ok) fail("/health signer/verifier", health); + + const describeResp = await fetch(`${base}/describe/v1.0.0`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }); + const receipt = await describeResp.json(); + if (!describeResp.ok) fail("/describe/v1.0.0 http", receipt); + if (!receipt?.metadata?.proof?.hash_sha256 || !receipt?.metadata?.proof?.signature_b64) { + fail("describe proof fields", receipt?.metadata?.proof || receipt); + } + + const verifyResp = await fetch(`${base}/verify?schema=0&ens=0`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(receipt), + }); + const verify = await verifyResp.json(); + if (!verifyResp.ok) fail("/verify http", verify); + if (!verify.ok || !verify?.checks?.signature_valid || !verify?.checks?.hash_matches) { + fail("/verify checks", verify); + } + + console.log("[smoke] PASS"); + console.log(JSON.stringify({ health, verify }, null, 2)); +} catch (err) { + fail("exception", err?.stack || String(err)); +} diff --git a/server.mjs b/server.mjs index 039e54e..068efe4 100644 --- a/server.mjs +++ b/server.mjs @@ -10,7 +10,6 @@ import net from "net"; import { signReceiptEd25519Sha256, verifyReceiptEd25519Sha256, - CANONICAL_ID_SORTED_KEYS_V1, } from "@commandlayer/runtime-core"; /** @@ -24,10 +23,9 @@ import { // ----------------------- // instance identity + crash logging // ----------------------- -const INSTANCE_ID = crypto.randomUUID(); const BOOTED_AT = Date.now(); const uptimeMs = () => Date.now() - BOOTED_AT; -const instancePayload = () => ({ instance: { id: INSTANCE_ID, uptime_ms: uptimeMs() } }); +const instancePayload = () => ({ instance: { uptime_ms: uptimeMs() } }); process.on("unhandledRejection", (reason) => { console.error("[fatal] unhandledRejection", reason); @@ -66,22 +64,12 @@ const ENABLED_VERBS = ( .filter(Boolean); // Single source of truth for signer id (FIXED: removed duplicate const SIGNER_ID) -const SIGNER_ID = - process.env.RECEIPT_SIGNER_ID || - process.env.ENS_NAME || - process.env.CL_SIGNER_ID || - process.env.CL_RECEIPT_SIGNER || - process.env.SIGNER_ID || - process.env.RUNTIME_SIGNER_ID || - "runtime.commandlayer.eth"; - -const SIGNER_KID = process.env.SIGNER_KID || "v1"; - -// key material (support both raw PEM and base64-PEM) -const PRIV_PEM_RAW = process.env.RECEIPT_SIGNING_PRIVATE_KEY_PEM || ""; -const PUB_PEM_RAW = process.env.RECEIPT_SIGNING_PUBLIC_KEY_PEM || ""; -const PRIV_PEM_B64 = process.env.RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64 || ""; -const PUB_PEM_B64 = process.env.RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64 || ""; +const SIGNER_ID = String(process.env.CL_RECEIPT_SIGNER || "").trim(); +const SIGNER_KID = String(process.env.CL_KEY_ID || "").trim(); +const CL_CANONICAL_ID = String(process.env.CL_CANONICAL_ID || "").trim(); + +const PRIV_PEM_RAW = process.env.CL_PRIVATE_KEY_PEM || ""; +const PUB_RAW32_B64 = String(process.env.CL_PUBLIC_KEY_B64 || "").trim(); // service identity / discovery const SERVICE_NAME = process.env.SERVICE_NAME || "commandlayer-runtime"; @@ -98,17 +86,12 @@ const ETH_RPC_URL = process.env.ETH_RPC_URL || ""; // IMPORTANT: // - This is the "agent/runtime ENS name" you want to verify against. // - Two-hop resolution uses this name -> cl.receipt.signer -> signer name -> cl.sig.pub/kid/canonical. -const VERIFIER_ENS_NAME = process.env.VERIFIER_ENS_NAME || process.env.ENS_NAME || SIGNER_ID || ""; // TXT keys (defaults match your records) -const ENS_SIGNER_POINTER_KEY = process.env.ENS_SIGNER_POINTER_KEY || "cl.receipt.signer"; const ENS_SIG_PUB_KEY = process.env.ENS_SIG_PUB_KEY || "cl.sig.pub"; const ENS_SIG_KID_KEY = process.env.ENS_SIG_KID_KEY || "cl.sig.kid"; const ENS_SIG_CANONICAL_KEY = process.env.ENS_SIG_CANONICAL_KEY || "cl.sig.canonical"; -// Legacy single-hop PEM fallback -const ENS_PUBKEY_PEM_KEY = process.env.ENS_PUBKEY_PEM_KEY || "cl.receipt.pubkey.pem"; - // AJV schema validation host const SCHEMA_HOST = (process.env.SCHEMA_HOST || "https://www.commandlayer.org").replace(/\/+$/, ""); const SCHEMA_FETCH_TIMEOUT_MS = Number(process.env.SCHEMA_FETCH_TIMEOUT_MS || 15000); @@ -164,10 +147,6 @@ function nowIso() { return new Date().toISOString(); } -function randId(prefix = "trace_") { - return prefix + crypto.randomBytes(6).toString("hex"); -} - function normalizePemLoose(input) { if (!input) return null; let s = String(input).replace(/\\n/g, "\n").trim(); @@ -189,22 +168,61 @@ function normalizePemLoose(input) { return `-----BEGIN ${type}-----\n${wrapped}\n-----END ${type}-----`; } -function pemFromB64(b64) { +function getPrivatePem() { + return normalizePemLoose(PRIV_PEM_RAW); +} + +function getPublicPemFromRaw32B64(b64) { if (!b64) return null; try { - const text = Buffer.from(b64, "base64").toString("utf8"); - return normalizePemLoose(text); + const raw = Buffer.from(b64, "base64"); + if (raw.length !== 32) return null; + const normalized = raw.toString("base64"); + if (normalized !== b64.replace(/\s+/g, "")) return null; + return spkiDerToPem(ed25519RawToSpkiDer(raw)); } catch { return null; } } -function getPrivatePem() { - return normalizePemLoose(PRIV_PEM_RAW) || pemFromB64(PRIV_PEM_B64); +function getPublicPemFromEnv() { + return getPublicPemFromRaw32B64(PUB_RAW32_B64); } -function getPublicPemFromEnv() { - return normalizePemLoose(PUB_PEM_RAW) || pemFromB64(PUB_PEM_B64); +function countConfiguredPrivateKeys() { + const envCandidates = [ + "CL_PRIVATE_KEY_PEM", + "RECEIPT_SIGNING_PRIVATE_KEY_PEM", + "RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64", + ]; + const byEnvName = envCandidates.filter((k) => String(process.env[k] || "").trim().length > 0).length; + + const pemBlockCount = (String(PRIV_PEM_RAW || "").match(/-----BEGIN [^-]*PRIVATE KEY-----/g) || []).length; + return byEnvName + Math.max(0, pemBlockCount - 1); +} + +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 || "").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 (CL_CANONICAL_ID !== "json.sorted_keys.v1") { + throw new Error(`CL_CANONICAL_ID must be json.sorted_keys.v1 (got: ${CL_CANONICAL_ID})`); + } + + if (countConfiguredPrivateKeys() > 1) { + throw new Error("Multiple private keys configured; set only CL_PRIVATE_KEY_PEM"); + } + + if (!getPrivatePem()) throw new Error("Invalid CL_PRIVATE_KEY_PEM (supports \\n escapes and multiline PEM)"); + if (!getPublicPemFromEnv()) throw new Error("Invalid CL_PUBLIC_KEY_B64 (must be base64 of raw 32-byte Ed25519 public key)"); } function enabled(verb) { @@ -325,7 +343,6 @@ function normalizeEnsName(name) { let ensCache = { fetched_at: 0, ttl_ms: 10 * 60 * 1000, - agent_ens: null, signer_ens: null, kid: null, canonical: null, @@ -334,100 +351,51 @@ let ensCache = { error: null, }; -async function fetchEnsSignerBundle({ refresh = false } = {}) { +async function fetchEnsSignerBundle({ signerName, refresh = false } = {}) { const now = Date.now(); + const signerEns = normalizeEnsName(signerName || SIGNER_ID); - if ( - !refresh && - ensCache.pubkey_pem && - now - ensCache.fetched_at < ensCache.ttl_ms && - ensCache.agent_ens === VERIFIER_ENS_NAME - ) { - return { ok: true, ...ensCache, cache: { ...ensCache } }; + if (!signerEns) { + ensCache = { ...ensCache, fetched_at: now, error: "Missing signer ENS name", signer_ens: null }; + return { ok: false, ...ensCache, cache: { ...ensCache } }; } - const agentEns = normalizeEnsName(VERIFIER_ENS_NAME); - if (!agentEns) { - ensCache = { ...ensCache, fetched_at: now, error: "Missing VERIFIER_ENS_NAME", agent_ens: null }; - return { ok: false, ...ensCache, cache: { ...ensCache } }; + if (!refresh && ensCache.pubkey_pem && now - ensCache.fetched_at < ensCache.ttl_ms && ensCache.signer_ens === signerEns) { + return { ok: true, ...ensCache, cache: { ...ensCache } }; } if (!ETH_RPC_URL) { - ensCache = { ...ensCache, fetched_at: now, error: "Missing ETH_RPC_URL", agent_ens: agentEns }; + ensCache = { ...ensCache, fetched_at: now, error: "Missing ETH_RPC_URL", signer_ens: signerEns }; return { ok: false, ...ensCache, cache: { ...ensCache } }; } try { const provider = new ethers.JsonRpcProvider(ETH_RPC_URL); - - const agentResolver = await withTimeout(provider.getResolver(agentEns), 6000, "ens_resolver_timeout"); - if (!agentResolver) throw new Error("No resolver for ENS name"); - - // Two-hop: agent -> cl.receipt.signer -> signer ENS - const signerPointer = normalizeEnsName( - await withTimeout(agentResolver.getText(ENS_SIGNER_POINTER_KEY), 6000, "ens_text_timeout") - ); - const signerEns = signerPointer || agentEns; // if pointer missing, fall back to agent - const signerResolver = await withTimeout(provider.getResolver(signerEns), 6000, "ens_signer_resolver_timeout"); - if (!signerResolver) throw new Error("No resolver for signer ENS name"); + if (!signerResolver) throw new Error(`No resolver for signer ENS name: ${signerEns}`); - // Preferred: cl.sig.pub + kid + canonical const [sigPubTxt, kidTxt, canonicalTxt] = await Promise.all([ - withTimeout(signerResolver.getText(ENS_SIG_PUB_KEY), 6000, "ens_sig_pub_timeout").catch(() => ""), - withTimeout(signerResolver.getText(ENS_SIG_KID_KEY), 6000, "ens_sig_kid_timeout").catch(() => ""), - withTimeout(signerResolver.getText(ENS_SIG_CANONICAL_KEY), 6000, "ens_sig_canonical_timeout").catch(() => ""), + withTimeout(signerResolver.getText(ENS_SIG_PUB_KEY), 6000, "ens_sig_pub_timeout"), + withTimeout(signerResolver.getText(ENS_SIG_KID_KEY), 6000, "ens_sig_kid_timeout"), + withTimeout(signerResolver.getText(ENS_SIG_CANONICAL_KEY), 6000, "ens_sig_canonical_timeout"), ]); - let pubkeyPem = null; - let pubkeySource = null; - - // 1) If cl.sig.pub is ed25519:, convert to PEM const raw = parseEd25519Txt(sigPubTxt); - if (raw) { - pubkeyPem = spkiDerToPem(ed25519RawToSpkiDer(raw)); - pubkeySource = `${signerEns}:${ENS_SIG_PUB_KEY}`; - } - - // 2) Legacy fallback: cl.receipt.pubkey.pem (on signer) - if (!pubkeyPem) { - const legacyPemTxt = await withTimeout( - signerResolver.getText(ENS_PUBKEY_PEM_KEY), - 6000, - "ens_legacy_pem_timeout" - ).catch(() => ""); - const pem = normalizePemLoose(legacyPemTxt); - if (pem) { - pubkeyPem = pem; - pubkeySource = `${signerEns}:${ENS_PUBKEY_PEM_KEY}`; - } - } - - // 3) Final fallback: cl.receipt.pubkey.pem (on agent) - if (!pubkeyPem && signerEns !== agentEns) { - const legacyAgentPem = await withTimeout( - agentResolver.getText(ENS_PUBKEY_PEM_KEY), - 6000, - "ens_agent_legacy_pem_timeout" - ).catch(() => ""); - const pem = normalizePemLoose(legacyAgentPem); - if (pem) { - pubkeyPem = pem; - pubkeySource = `${agentEns}:${ENS_PUBKEY_PEM_KEY}`; - } - } + const kid = normalizeEnsName(kidTxt); + const canonical = normalizeEnsName(canonicalTxt); - if (!pubkeyPem) throw new Error("No usable ENS pubkey found (cl.sig.pub or cl.receipt.pubkey.pem)"); + 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}`); + if (!canonical) throw new Error(`Missing ${ENS_SIG_CANONICAL_KEY} on ${signerEns}`); const bundle = { fetched_at: now, ttl_ms: ensCache.ttl_ms, - agent_ens: agentEns, signer_ens: signerEns, - kid: normalizeEnsName(kidTxt), - canonical: normalizeEnsName(canonicalTxt), - pubkey_pem: pubkeyPem, - pubkey_source: pubkeySource, + kid, + canonical, + pubkey_pem: spkiDerToPem(ed25519RawToSpkiDer(raw)), + pubkey_source: `${signerEns}:${ENS_SIG_PUB_KEY}`, error: null, }; @@ -437,12 +405,11 @@ async function fetchEnsSignerBundle({ refresh = false } = {}) { ensCache = { ...ensCache, fetched_at: now, - agent_ens: normalizeEnsName(VERIFIER_ENS_NAME), + signer_ens: signerEns, pubkey_pem: null, pubkey_source: null, kid: null, canonical: null, - signer_ens: null, error: e?.message || "ens fetch failed", }; return { ok: false, ...ensCache, cache: { ...ensCache } }; @@ -657,7 +624,7 @@ function makeReceipt({ metadata: { proof: { alg: "ed25519-sha256", - canonical: CANONICAL_ID_SORTED_KEYS_V1, + canonical: CL_CANONICAL_ID, signer_id: SIGNER_ID, kid: SIGNER_KID, hash_sha256: null, @@ -670,12 +637,12 @@ function makeReceipt({ if (actor) receipt.metadata.actor = actor; const privPem = getPrivatePem(); - if (!privPem) throw new Error("Missing RECEIPT_SIGNING_PRIVATE_KEY_PEM or RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64"); + if (!privPem) throw new Error("Missing CL_PRIVATE_KEY_PEM"); receipt = signReceiptEd25519Sha256(receipt, { signer_id: SIGNER_ID, kid: SIGNER_KID, - canonical: CANONICAL_ID_SORTED_KEYS_V1, + canonical: CL_CANONICAL_ID, privateKeyPem: privPem, }); @@ -1074,18 +1041,12 @@ async function handleVerb(verb, req, res) { } if (!requireBody(req, res)) return; - const started = Date.now(); - const rawParent = req.body?.trace?.parent_trace_id ?? req.body?.x402?.extras?.parent_trace_id ?? null; const parentTraceId = typeof rawParent === "string" && rawParent.trim().length ? rawParent.trim() : null; const trace = { - trace_id: randId("trace_"), - ...(parentTraceId ? { parent_trace_id: parentTraceId } : {}), - started_at: nowIso(), - completed_at: null, - duration_ms: null, provider: process.env.RAILWAY_SERVICE_NAME || "runtime", + ...(parentTraceId ? { parent_trace_id: parentTraceId } : {}), }; try { @@ -1102,8 +1063,6 @@ async function handleVerb(verb, req, res) { ? await Promise.race([work, new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), timeoutMs))]) : await work; - trace.completed_at = nowIso(); - trace.duration_ms = Date.now() - started; const actor = req.body?.actor ? { id: String(req.body.actor), role: "user" } @@ -1114,8 +1073,6 @@ async function handleVerb(verb, req, res) { const receipt = makeReceipt({ x402, trace, result, status: "success", actor }); return res.json(receipt); } catch (e) { - trace.completed_at = nowIso(); - trace.duration_ms = Date.now() - started; const x402 = req.body?.x402 || { verb, version: "1.0.0", entry: `x402://${verb}agent.eth/${verb}/v1.0.0` }; @@ -1174,7 +1131,9 @@ app.get("/health", (req, res) => { enabled_verbs: ENABLED_VERBS, signer_id: SIGNER_ID, signer_ok: !!getPrivatePem(), - verifier_ok: !!(getPublicPemFromEnv() || (ETH_RPC_URL && VERIFIER_ENS_NAME)), + verifier_ok: !!getPublicPemFromEnv(), + kid: SIGNER_KID, + canonical_id: CL_CANONICAL_ID, time: nowIso(), ...instancePayload(), }) @@ -1189,7 +1148,7 @@ app.get("/healthz", (req, res) => { // ----------------------- // debug (gated) // ----------------------- -app.get("/debug/env", requireDebug, (req, res) => { +app.get("/debug/env", (req, res) => { const privPem = getPrivatePem(); const pubPem = getPublicPemFromEnv(); @@ -1203,20 +1162,24 @@ app.get("/debug/env", requireDebug, (req, res) => { signer_id: SIGNER_ID, signer_kid: SIGNER_KID, signer_ok: !!privPem, - has_priv_b64: !!PRIV_PEM_B64, - has_priv_pem: !!normalizePemLoose(PRIV_PEM_RAW), - derived_priv_pem: !!privPem, - has_pub_b64: !!PUB_PEM_B64, - has_pub_pem: !!normalizePemLoose(PUB_PEM_RAW), - derived_pub_pem: !!pubPem, - - verifier_ens_name: VERIFIER_ENS_NAME || null, + env_presence: { + CL_RECEIPT_SIGNER: !!process.env.CL_RECEIPT_SIGNER, + CL_KEY_ID: !!process.env.CL_KEY_ID, + CL_CANONICAL_ID: !!process.env.CL_CANONICAL_ID, + CL_PRIVATE_KEY_PEM: !!process.env.CL_PRIVATE_KEY_PEM, + CL_PUBLIC_KEY_B64: !!process.env.CL_PUBLIC_KEY_B64, + ETH_RPC_URL: !!process.env.ETH_RPC_URL, + }, + key_loading_mode: { + private_key_pem: process.env.CL_PRIVATE_KEY_PEM?.includes("\\n") ? "single-line-escaped" : "multiline-or-raw", + public_key: "cl_public_key_b64_raw32", + private_key_valid: !!privPem, + public_key_valid: !!pubPem, + }, ens_keys: { - signer_pointer: ENS_SIGNER_POINTER_KEY, sig_pub: ENS_SIG_PUB_KEY, sig_kid: ENS_SIG_KID_KEY, sig_canonical: ENS_SIG_CANONICAL_KEY, - legacy_pubkey_pem: ENS_PUBKEY_PEM_KEY, }, has_rpc: hasRpc(), @@ -1244,7 +1207,7 @@ app.get("/debug/env", requireDebug, (req, res) => { service_version: SERVICE_VERSION, api_version: API_VERSION, canonical_base_url: CANONICAL_BASE, - canonical_id: CANONICAL_ID_SORTED_KEYS_V1, + canonical_id: CL_CANONICAL_ID, debug: { enable_debug: ENABLE_DEBUG, has_debug_token: !!DEBUG_TOKEN }, }); }); @@ -1255,7 +1218,6 @@ app.get("/debug/enskey", requireDebug, async (req, res) => { res.json({ ok: !!out.ok, - agent_ens: out.agent_ens || null, signer_ens: out.signer_ens || null, kid: out.kid || null, canonical: out.canonical || null, @@ -1317,6 +1279,7 @@ app.post("/verify", async (req, res) => { const wantSchema = String(req.query.schema || "0") === "1"; const proof = receipt?.metadata?.proof; + const proofCanonical = String(proof?.canonical || proof?.canonical_id || ""); if (!proof?.signature_b64 || !proof?.hash_sha256) { return res.status(400).json({ ok: false, @@ -1333,13 +1296,14 @@ app.post("/verify", async (req, res) => { // 1) pick pubkey (env first, then ENS if requested) let pubPem = getPublicPemFromEnv(); - let pubSrc = pubPem ? (normalizePemLoose(PUB_PEM_RAW) ? "env-pem" : "env-b64") : null; + let pubSrc = pubPem ? "env-cl_public_key_b64" : null; // ENS expectations (kid/canonical/signer) let ensExpect = null; if (wantEns) { - const ensOut = await fetchEnsSignerBundle({ refresh }); + const signerForEns = String(proof?.signer_id || SIGNER_ID || "").trim(); + const ensOut = await fetchEnsSignerBundle({ signerName: signerForEns, refresh }); if (!ensOut.ok || !ensOut.pubkey_pem) { return res.status(400).json({ ok: false, @@ -1350,7 +1314,7 @@ app.post("/verify", async (req, res) => { ens_match: false, }, error: ensOut.error || "ens resolution failed", - values: { agent_ens: VERIFIER_ENS_NAME || null }, + values: { signer_ens: signerForEns || null }, ...instancePayload(), }); } @@ -1359,16 +1323,15 @@ app.post("/verify", async (req, res) => { pubSrc = "ens"; ensExpect = { - agent_ens: ensOut.agent_ens, signer_ens: ensOut.signer_ens, kid: ensOut.kid || null, canonical: ensOut.canonical || null, }; - // Enforce kid/canonical (when present in ENS) + signer binding BEFORE cryptographic verify - const kidOk = ensExpect.kid ? String(proof.kid || "") === ensExpect.kid : true; - const canonicalOk = ensExpect.canonical ? String(proof.canonical || "") === ensExpect.canonical : true; - const signerOk = ensExpect.signer_ens ? String(proof.signer_id || "") === ensExpect.signer_ens : true; + // Enforce kid/canonical/signer binding from ENS BEFORE cryptographic verify + const kidOk = String(proof.kid || "") === ensExpect.kid; + const canonicalOk = proofCanonical === ensExpect.canonical; + const signerOk = String(proof.signer_id || "") === ensExpect.signer_ens; if (!kidOk || !canonicalOk || !signerOk) { return res.status(400).json({ @@ -1384,7 +1347,7 @@ app.post("/verify", async (req, res) => { proof: { signer_id: proof?.signer_id ?? null, kid: proof?.kid ?? null, - canonical: proof?.canonical ?? null, + canonical: proofCanonical || null, }, ens: ensExpect, pubkey_source: pubSrc, @@ -1404,7 +1367,7 @@ app.post("/verify", async (req, res) => { ens_match: wantEns ? false : null, }, error: - "no public key available (set RECEIPT_SIGNING_PUBLIC_KEY_PEM/_B64 or use ens=1 with valid ENS TXT)", + "no public key available (set CL_PUBLIC_KEY_B64 or use ens=1 with valid ENS TXT)", ...instancePayload(), }); } @@ -1414,7 +1377,7 @@ app.post("/verify", async (req, res) => { try { v = verifyReceiptEd25519Sha256(receipt, { publicKeyPemOrDer: pubPem, - allowedCanonicals: [CANONICAL_ID_SORTED_KEYS_V1], + allowedCanonicals: [CL_CANONICAL_ID], }); } catch (e) { return res.status(400).json({ @@ -1512,7 +1475,8 @@ app.post("/verify", async (req, res) => { verb: receipt?.x402?.verb ?? null, signer_id: proof?.signer_id ?? null, alg: proof?.alg ?? null, - canonical: proof?.canonical ?? null, + canonical: proofCanonical || null, + canonical_id: proofCanonical || null, kid: proof?.kid ?? null, claimed_hash: proof?.hash_sha256 ?? null, pubkey_source: pubSrc, @@ -1538,6 +1502,8 @@ app.post("/verify", async (req, res) => { } }); +assertBootConfigOrThrow(); + app.listen(PORT, HOST, () => { console.log(`runtime listening on http://${HOST}:${PORT}`); });