Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=<base64-encoded-pkcs8-pem>

# 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>

Expand Down
6 changes: 5 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`
15 changes: 13 additions & 2 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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`
172 changes: 170 additions & 2 deletions runtime/src/receipt-verification.js
Original file line number Diff line number Diff line change
@@ -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<string> }} resolver
* @returns {Promise<string>} 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<string> }} 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<string> } }} 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}` };
}
}
4 changes: 3 additions & 1 deletion scripts/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 22 additions & 19 deletions server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -827,9 +827,16 @@ function getBuiltinSchema(url) {
}
return null;
}

const validatorCache = new Map(); // verb -> { compiledAt, validate }
const inflightValidator = new Map(); // verb -> Promise<validate>

// -----------------------
// 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();

Expand Down Expand Up @@ -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" });
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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 ? "..." : "");
Expand Down Expand Up @@ -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);

Expand All @@ -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 }));
Expand All @@ -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 = {
Expand All @@ -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);
}

}
}

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1705,6 +1707,7 @@ app.post("/verify", async (req, res) => {
});
}
});

// verb routes
// -----------------------
app.post("/execute", (req, res) => {
Expand Down Expand Up @@ -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}`);
});
Loading