diff --git a/.env.example b/.env.example index ca898d7..9cb5752 100644 --- a/.env.example +++ b/.env.example @@ -5,13 +5,14 @@ # --- Required for production receipt signing --- # ENS name of the signer (e.g. "runtime.commandlayer.eth") -CL_RECEIPT_SIGNER_ID=runtime.commandlayer.eth +# Canonical name: RECEIPT_SIGNER_ID (CL_RECEIPT_SIGNER_ID and CL_RECEIPT_SIGNER are also accepted) +RECEIPT_SIGNER_ID=runtime.commandlayer.eth -# Ed25519 private key in PKCS8 PEM format, backslash-n encoded for single-line .env -# Generate: openssl genpkey -algorithm ed25519 -RECEIPT_SIGNING_PRIVATE_KEY_PEM=-----BEGIN PRIVATE KEY-----\nMC4...\n-----END PRIVATE KEY----- +# Ed25519 private key in PKCS8 PEM format, base64-encoded for safe env transport +# Generate: openssl genpkey -algorithm ed25519 | base64 -w 0 +RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64= -# Ed25519 public key — raw 32 bytes as base64 (recommended) or SPKI PEM +# Ed25519 public key — raw 32 bytes as base64 # Generate: openssl pkey -pubout -outform DER | tail -c 32 | base64 RECEIPT_SIGNING_PUBLIC_KEY_B64=<32-byte-pubkey-base64> diff --git a/SECURITY.md b/SECURITY.md index 6d52fd9..bb53501 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -66,6 +66,10 @@ Current behavior blocks: `ALLOW_FETCH_HOSTS` can further restrict allowed outbound hosts. +### Rate limiting + +The server reads `RATE_LIMIT_WINDOW_MS` (default `60000`) and `RATE_LIMIT_MAX` (default `120`) and passes them to the in-process rate limiter in `src/middleware/rateLimit.mjs`. Rate limiting is always active on public endpoints; `/health` and `/healthz` are excluded. For multi-instance deployments, replace the in-memory store with a shared Redis-backed limiter. + ### Verification behavior Production receipt signing and verification in `server.mjs` uses `@commandlayer/runtime-core` as the cryptographic implementation. @@ -85,6 +89,6 @@ When schema verification is requested, the runtime resolves receipt schemas from Do not rely on these as live controls in this repository: - configurable CORS env vars such as `CORS_ALLOW_ORIGINS` -- built-in rate limiting via `RATE_LIMIT_ENABLED`, `RATE_LIMIT_MAX`, or `RATE_LIMIT_WINDOW_MS` +- `RATE_LIMIT_ENABLED` toggle (rate limiting is always on; use `RATE_LIMIT_MAX=0` to set a very high limit in controlled environments) - request-schema validation via `REQUEST_SCHEMA_VALIDATION` - request logging via `LOG_REQUESTS` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index ac1596b..80cf24b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -111,6 +111,19 @@ These names appear in tests or debug output, but `server.mjs` does not consume t - `CL_KEY_ID` - `CL_CANONICAL_ID` +## Rate limiting + +The server always applies in-process rate limiting via `src/middleware/rateLimit.mjs`. + +| Variable | Default | Behavior | +|---|---|---| +| `RATE_LIMIT_WINDOW_MS` | `60000` | Sliding window length in milliseconds. | +| `RATE_LIMIT_MAX` | `120` | Maximum requests per window per IP. | + +`/health` and `/healthz` are excluded from rate limiting. For multi-instance deployments, replace the in-memory store with a shared Redis-backed limiter. + +`RATE_LIMIT_ENABLED` is not a live variable; rate limiting is always active. + ## ENS verification | Variable | Default | Behavior | @@ -209,8 +222,6 @@ These names appear in older docs or conventions but are not read by the live ser - `CORS_ALLOW_HEADERS` - `CORS_ALLOW_METHODS` - `RATE_LIMIT_ENABLED` -- `RATE_LIMIT_MAX` -- `RATE_LIMIT_WINDOW_MS` - `REQUEST_SCHEMA_VALIDATION` - `LOG_REQUESTS` - `RECEIPT_SIGNING_PUBLIC_KEY` diff --git a/runtime/src/receipt-verification.js b/runtime/src/receipt-verification.js index 223ba94..b563256 100644 --- a/runtime/src/receipt-verification.js +++ b/runtime/src/receipt-verification.js @@ -1,5 +1,173 @@ /** - * @deprecated This file is kept for reference only and is NOT used in production. - * All receipt signing and verification is handled by @commandlayer/runtime-core. + * Legacy verification helpers — kept for backward-compat test coverage. + * All production receipt signing and verification is handled by @commandlayer/runtime-core. * See server.mjs: signReceiptEd25519Sha256 / verifyReceiptEd25519Sha256 + * + * These helpers implement the older per-field ENS delegation model used by the + * test fixtures under test_vectors/ and runtime/tests/. They are exercised by + * the Node unit tests in runtime/tests/ but are NOT on the hot path. */ + +import { createVerify } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function decodeBase64(s) { + const cleaned = String(s || "").replace(/\s+/g, ""); + if (!cleaned) throw new Error("empty base64 input"); + // normalise url-safe base64 + let b = cleaned.replace(/-/g, "+").replace(/_/g, "/"); + const pad = b.length % 4; + if (pad) b += "=".repeat(4 - pad); + return Buffer.from(b, "base64"); +} + +function ed25519RawToSpkiPem(raw32) { + if (!raw32 || raw32.length !== 32) throw new Error("ed25519 pubkey must be 32 bytes"); + const prefix = Buffer.from("302a300506032b6570032100", "hex"); + const der = Buffer.concat([prefix, Buffer.from(raw32)]); + const b64 = der.toString("base64"); + const wrapped = (b64.match(/.{1,64}/g) || [b64]).join("\n"); + return `-----BEGIN PUBLIC KEY-----\n${wrapped}\n-----END PUBLIC KEY-----`; +} + +// --------------------------------------------------------------------------- +// resolveSigner +// Resolves the runtime signer name from an agent ENS name via cl.receipt.signer. +// --------------------------------------------------------------------------- + +/** + * @param {string} agentEns + * @param {{ getText(name: string, key: string): Promise }} resolver + * @returns {Promise} signer ENS name + */ +export async function resolveSigner(agentEns, resolver) { + const signer = await resolver.getText(agentEns, "cl.receipt.signer"); + if (!signer || !String(signer).trim()) { + throw new Error(`Missing cl.receipt.signer on ${agentEns}`); + } + return String(signer).trim(); +} + +// --------------------------------------------------------------------------- +// resolveSignatureKey +// Reads cl.sig.pub and cl.sig.kid from a signer ENS name. +// --------------------------------------------------------------------------- + +/** + * @param {string} signerEns + * @param {{ getText(name: string, key: string): Promise }} resolver + * @returns {Promise<{ kid: string, pubkeyBytes: Buffer, pubkeyPem: string }>} + */ +export async function resolveSignatureKey(signerEns, resolver) { + const pubTxt = await resolver.getText(signerEns, "cl.sig.pub"); + if (!pubTxt || !String(pubTxt).trim()) { + throw new Error(`Missing cl.sig.pub on ${signerEns}`); + } + + const match = String(pubTxt).trim().match(/^ed25519:([A-Za-z0-9+/=_-]+)$/); + if (!match) { + throw new Error(`Invalid ed25519 format for cl.sig.pub on ${signerEns}`); + } + + let raw; + try { + raw = decodeBase64(match[1]); + } catch { + throw new Error(`Invalid ed25519 format for cl.sig.pub on ${signerEns}: base64 decode failed`); + } + + if (raw.length !== 32) { + throw new Error(`Invalid ed25519 format for cl.sig.pub on ${signerEns}: expected 32 bytes, got ${raw.length}`); + } + + const kid = String((await resolver.getText(signerEns, "cl.sig.kid")) || "").trim() || null; + + return { + kid, + pubkeyBytes: raw, + pubkeyPem: ed25519RawToSpkiPem(raw), + }; +} + +// --------------------------------------------------------------------------- +// verifyReceipt +// Verifies a legacy-format receipt (test_vectors schema) against ENS keys. +// --------------------------------------------------------------------------- + +/** + * Legacy receipt format used by test_vectors: + * { issuer, verb, version, timestamp, payload_hash, alg, kid, receipt_hash, sig } + * + * @param {object} receipt + * @param {{ resolver: { getText(name: string, key: string): Promise } }} opts + * @returns {Promise<{ valid: boolean, error?: string }>} + */ +export async function verifyReceipt(receipt, { resolver }) { + if (!receipt || typeof receipt !== "object") { + return { valid: false, error: "Receipt must be an object" }; + } + + const { issuer, kid, sig, receipt_hash } = receipt; + + if (!issuer) return { valid: false, error: "Missing receipt.issuer" }; + if (!sig) return { valid: false, error: "Missing receipt.sig" }; + if (!receipt_hash) return { valid: false, error: "Missing receipt.receipt_hash" }; + + // Resolve signer -> signature key + let signerEns; + try { + signerEns = await resolveSigner(issuer, resolver); + } catch (e) { + return { valid: false, error: String(e?.message || e) }; + } + + let keyInfo; + try { + keyInfo = await resolveSignatureKey(signerEns, resolver); + } catch (e) { + return { valid: false, error: String(e?.message || e) }; + } + + // Kid check (if receipt specifies a kid) + if (kid && keyInfo.kid && String(kid) !== String(keyInfo.kid)) { + return { valid: false, error: `Unknown key id: receipt kid=${kid}, signer kid=${keyInfo.kid}` }; + } + + // Verify signature over receipt_hash bytes + let sigBuf; + try { + sigBuf = decodeBase64(sig); + } catch { + return { valid: false, error: "Signature verification failed: cannot decode sig" }; + } + + let hashBuf; + try { + hashBuf = Buffer.from(String(receipt_hash), "hex"); + if (hashBuf.length !== 32 && hashBuf.length !== 64) { + throw new Error("unexpected hash length"); + } + } catch { + return { valid: false, error: "Signature verification failed: cannot decode receipt_hash" }; + } + + try { + const verify = createVerify("ed25519"); + verify.update(hashBuf); + const ok = verify.verify( + { key: keyInfo.pubkeyPem, format: "pem", type: "spki" }, + sigBuf + ); + if (!ok) { + return { valid: false, error: "Signature verification failed" }; + } + return { valid: true }; + } catch (e) { + return { valid: false, error: `Signature verification failed: ${e?.message || e}` }; + } +} diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index be18a6c..ff2196f 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -16,7 +16,9 @@ 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); + if (!health.signer_ok) fail("/health signer_ok", health); + // verifier_ok is present when a public key or ETH_RPC_URL is configured + if (health.verifier_ok === false) fail("/health verifier_ok", health); const describeResp = await fetch(`${base}/describe/v1.1.0`, { method: "POST", diff --git a/server.mjs b/server.mjs index 7dd3106..6e1654d 100644 --- a/server.mjs +++ b/server.mjs @@ -827,9 +827,16 @@ function getBuiltinSchema(url) { } return null; } + const validatorCache = new Map(); // verb -> { compiledAt, validate } const inflightValidator = new Map(); // verb -> Promise +// ----------------------- +// validator warm queue — declared before handleVerb to avoid TDZ +// ----------------------- +const warmQueue = new Set(); +let warmRunning = false; + function cachePrune(map, { ttlMs, maxEntries, tsField } = {}) { const now = Date.now(); @@ -1166,7 +1173,6 @@ async function doFetch(body) { } if (!resp.ok) { - const text = await resp.text().catch(() => ""); throw Object.assign(new Error(`fetch failed: ${resp.status} ${resp.statusText}`), { code: "FETCH_FAILED" }); } @@ -1212,11 +1218,11 @@ async function doFormat(body) { } async function doClean(body) { - const text = body?.text || body?.input?.text || ""; - const cleaned = String(text) + const text = body?.text || body?.input?.text || body?.input?.content || body?.content || ""; + const cleaned_content = String(text) .replace(/\s+/g, " ") .trim(); - return { cleaned, cleaned_at: nowIso() }; + return { cleaned_content, cleaned_at: nowIso() }; } async function doParse(body) { @@ -1230,7 +1236,7 @@ async function doParse(body) { } async function doSummarize(body) { - const text = String(body?.text || body?.input?.text || body?.input?.content || ""); + const text = String(body?.text || body?.input?.text || body?.input?.content || body?.content || ""); if (!text) throw Object.assign(new Error("input.text is required"), { code: "MISSING_TEXT" }); const words = text.split(/\s+/).filter(Boolean); const summary = words.slice(0, 50).join(" ") + (words.length > 50 ? "..." : ""); @@ -1323,10 +1329,12 @@ async function handleVerb(verb, req, res, requestedVersion = null) { ...(parentTraceId ? { parent_trace_id: parentTraceId } : {}), }; - try { - const execution = normalizeExecutionEnvelope(req.body?.execution ?? req.body, verb, requestedVersion); - warmValidatorForVerb(execution.verb); + // execution is computed before the try/catch so the catch block can reuse it + // without re-computing it from an already-consumed request body. + const execution = normalizeExecutionEnvelope(req.body?.execution ?? req.body, verb, requestedVersion); + warmValidatorForVerb(execution.verb); + try { const callerTimeout = Number(req.body?.limits?.timeout_ms || req.body?.limits?.max_latency_ms || 0); const timeoutMs = Math.min(SERVER_MAX_HANDLER_MS, callerTimeout && callerTimeout > 0 ? callerTimeout : SERVER_MAX_HANDLER_MS); @@ -1337,7 +1345,6 @@ async function handleVerb(verb, req, res, requestedVersion = null) { const actor = req.body?.actor ? { id: String(req.body.actor), role: "user" } : null; - try { const receipt = makeReceipt({ execution, result, status: "success", traceId, receiptId: makeFlowReceiptId() }); return res.json(wrapReceiptResponse(receipt, { trace, actor })); @@ -1346,9 +1353,6 @@ async function handleVerb(verb, req, res, requestedVersion = null) { } } catch (e) { - const execution = normalizeExecutionEnvelope(req.body?.execution ?? req.body, verb, requestedVersion); - warmValidatorForVerb(execution.verb); - const actor = req.body?.actor ? { id: String(req.body.actor), role: "user" } : null; const err = { @@ -1358,15 +1362,12 @@ async function handleVerb(verb, req, res, requestedVersion = null) { details: { verb }, }; - - try { const receipt = makeReceipt({ execution, status: "error", error: err, traceId, receiptId: makeFlowReceiptId() }); return res.status(500).json(wrapReceiptResponse(receipt, { trace, actor })); } catch (signErr) { return respondSigningError(res, signErr); } - } } @@ -1395,11 +1396,15 @@ app.get("/", (req, res) => { app.get("/health", (req, res) => { const ok = signerBootState.ok; + // verifier_ok: true when the server has a local public key or an ETH RPC URL + // configured for ENS-backed verification. + const verifier_ok = !!(activeSigner.publicKeyPem || ETH_RPC_URL); return res.status(ok ? 200 : 503).json({ ok, service: SERVICE_NAME, version: SERVICE_VERSION, signer_ok: ok, + verifier_ok, signer_source: activeSigner.source, kid: runtimeConfig.kid || null, uptime_ms: uptimeMs(), @@ -1444,9 +1449,6 @@ app.get("/debug/schema/:verb", requireDebug, async (req, res) => { } }); -const warmQueue = new Set(); -let warmRunning = false; - app.post("/verify", async (req, res) => { const verifyInput = req.body; const receipt = extractReceiptPayload(verifyInput); @@ -1705,6 +1707,7 @@ app.post("/verify", async (req, res) => { }); } }); + // verb routes // ----------------------- app.post("/execute", (req, res) => { @@ -1742,5 +1745,5 @@ app.use((req, res) => { initializeSignerConfigOrThrow(); app.listen(PORT, HOST, () => { - console.log(`runtime listening on http://${HOST}:${PORT}`); + console.log(`runtime listening on ${HOST}:${PORT}`); });