Skip to content

Commit ac1b22a

Browse files
committed
fix: verify computes pubkey before runtime-core verification
1 parent 0d639a2 commit ac1b22a

File tree

1 file changed

+57
-76
lines changed

1 file changed

+57
-76
lines changed

server.mjs

Lines changed: 57 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
// server.mjs
22
import express from "express";
3-
import { signReceiptEd25519Sha256, verifyReceiptEd25519Sha256, CANONICAL_ID_SORTED_KEYS_V1 } from "@commandlayer/runtime-core";
43
import crypto from "crypto";
54
import Ajv from "ajv";
65
import addFormats from "ajv-formats";
76
import { ethers } from "ethers";
87
import net from "net";
98

9+
import {
10+
signReceiptEd25519Sha256,
11+
verifyReceiptEd25519Sha256,
12+
CANONICAL_ID_SORTED_KEYS_V1
13+
} from "@commandlayer/runtime-core";
14+
1015
const app = express();
1116
app.use(express.json({ limit: "2mb" }));
1217

@@ -22,12 +27,17 @@ app.use((req, res, next) => {
2227
const PORT = Number(process.env.PORT || 8080);
2328

2429
// ---- runtime config
25-
const ENABLED_VERBS = (process.env.ENABLED_VERBS || "fetch,describe,format,clean,parse,summarize,convert,explain,analyze,classify")
30+
const ENABLED_VERBS = (process.env.ENABLED_VERBS ||
31+
"fetch,describe,format,clean,parse,summarize,convert,explain,analyze,classify")
2632
.split(",")
2733
.map((s) => s.trim())
2834
.filter(Boolean);
2935

30-
const SIGNER_ID = process.env.RECEIPT_SIGNER_ID || process.env.ENS_NAME || "runtime";
36+
const SIGNER_ID = process.env.RECEIPT_SIGNER_ID || process.env.ENS_NAME || "runtime.commandlayer.eth";
37+
const SIGNER_KID = process.env.SIGNER_KID || "v1";
38+
39+
// NOTE: runtime-core expects PEM text, not base64.
40+
// We accept base64 envs (as you already do) and decode to PEM.
3141
const PRIV_PEM_B64 = process.env.RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64 || "";
3242
const PUB_PEM_B64 = process.env.RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64 || "";
3343

@@ -70,7 +80,7 @@ const VERIFY_MAX_MS = Number(process.env.VERIFY_MAX_MS || 30000);
7080
// CRITICAL: edge-safe schema verify behavior
7181
// If true, /verify?schema=1 will NEVER compile or fetch; it will only validate if cached,
7282
// otherwise it returns 202 and queues warm.
73-
// Default true (this is what prevents Railway edge 502s).
83+
// Default true (this is what prevents edge 502s).
7484
const VERIFY_SCHEMA_CACHED_ONLY = String(process.env.VERIFY_SCHEMA_CACHED_ONLY || "1") === "1";
7585

7686
// Prewarm knobs
@@ -86,25 +96,6 @@ function randId(prefix = "trace_") {
8696
return prefix + crypto.randomBytes(6).toString("hex");
8797
}
8898

89-
// Stable stringify (deterministic object key order)
90-
function stableStringify(value) {
91-
const seen = new WeakSet();
92-
const helper = (v) => {
93-
if (v === null || typeof v !== "object") return v;
94-
if (seen.has(v)) return "[Circular]";
95-
seen.add(v);
96-
if (Array.isArray(v)) return v.map(helper);
97-
const out = {};
98-
for (const k of Object.keys(v).sort()) out[k] = helper(v[k]);
99-
return out;
100-
};
101-
return JSON.stringify(helper(value));
102-
}
103-
104-
function sha256Hex(str) {
105-
return crypto.createHash("sha256").update(str).digest("hex");
106-
}
107-
10899
function pemFromB64(b64) {
109100
if (!b64) return null;
110101
const pem = Buffer.from(b64, "base64").toString("utf8");
@@ -117,27 +108,14 @@ function normalizePem(text) {
117108
return pem.includes("BEGIN") ? pem : null;
118109
}
119110

120-
function signEd25519Base64(messageUtf8) {
121-
const pem = pemFromB64(PRIV_PEM_B64);
122-
if (!pem) throw new Error("Missing RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64");
123-
const key = crypto.createPrivateKey(pem);
124-
const sig = crypto.sign(null, Buffer.from(messageUtf8, "utf8"), key);
125-
return sig.toString("base64");
126-
}
127-
128-
function verifyEd25519Base64(messageUtf8, signatureB64, pubPem) {
129-
const key = crypto.createPublicKey(pubPem);
130-
return crypto.verify(null, Buffer.from(messageUtf8, "utf8"), key, Buffer.from(signatureB64, "base64"));
111+
function enabled(verb) {
112+
return ENABLED_VERBS.includes(verb);
131113
}
132114

133115
function makeError(code, message, extra = {}) {
134116
return { status: "error", code, message, ...extra };
135117
}
136118

137-
function enabled(verb) {
138-
return ENABLED_VERBS.includes(verb);
139-
}
140-
141119
function requireBody(req, res) {
142120
if (!req.body || typeof req.body !== "object") {
143121
res.status(400).json(makeError(400, "Invalid JSON body"));
@@ -411,10 +389,10 @@ function startWarmWorker() {
411389
}
412390

413391
// -----------------------
414-
// receipts (receipt_id excluded from canonical hash)
392+
// receipts (runtime-core: single source of truth)
415393
// -----------------------
416394
function makeReceipt({ x402, trace, result, status = "success", error = null, delegation_result = null, actor = null }) {
417-
const receipt = {
395+
let receipt = {
418396
status,
419397
x402,
420398
trace,
@@ -424,8 +402,9 @@ function makeReceipt({ x402, trace, result, status = "success", error = null, de
424402
metadata: {
425403
proof: {
426404
alg: "ed25519-sha256",
427-
canonical: "json-stringify",
405+
canonical: CANONICAL_ID_SORTED_KEYS_V1,
428406
signer_id: SIGNER_ID,
407+
kid: SIGNER_KID,
429408
hash_sha256: null,
430409
signature_b64: null,
431410
},
@@ -435,18 +414,15 @@ function makeReceipt({ x402, trace, result, status = "success", error = null, de
435414

436415
if (actor) receipt.metadata.actor = actor;
437416

438-
const unsigned = structuredClone(receipt);
439-
unsigned.metadata.proof.hash_sha256 = "";
440-
unsigned.metadata.proof.signature_b64 = "";
441-
unsigned.metadata.receipt_id = "";
442-
443-
const canonical = stableStringify(unsigned);
444-
const hash = sha256Hex(canonical);
445-
const sigB64 = signEd25519Base64(hash);
417+
const privPem = pemFromB64(PRIV_PEM_B64);
418+
if (!privPem) throw new Error("Missing RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64");
446419

447-
receipt.metadata.proof.hash_sha256 = hash;
448-
receipt.metadata.proof.signature_b64 = sigB64;
449-
receipt.metadata.receipt_id = hash;
420+
receipt = signReceiptEd25519Sha256(receipt, {
421+
signer_id: SIGNER_ID,
422+
kid: SIGNER_KID,
423+
canonical: CANONICAL_ID_SORTED_KEYS_V1,
424+
privateKeyPem: privPem,
425+
});
450426

451427
return receipt;
452428
}
@@ -628,6 +604,10 @@ function doParse(body) {
628604
return result;
629605
}
630606

607+
function sha256HexUtf8(str) {
608+
return crypto.createHash("sha256").update(String(str), "utf8").digest("hex");
609+
}
610+
631611
function doSummarize(body) {
632612
const input = body?.input || {};
633613
const content = String(input.content ?? "");
@@ -645,7 +625,7 @@ function doSummarize(body) {
645625
}
646626
if (!summary) summary = content.slice(0, 400).trim();
647627

648-
const srcHash = sha256Hex(content);
628+
const srcHash = sha256HexUtf8(content);
649629
const cr = summary.length ? Number((content.length / summary.length).toFixed(3)) : 0;
650630

651631
return { summary, format: format === "markdown" ? "markdown" : "text", compression_ratio: cr, source_hash: srcHash };
@@ -850,7 +830,10 @@ async function handleVerb(verb, req, res) {
850830
const x402 = req.body?.x402 || { verb, version: "1.0.0", entry: `x402://${verb}agent.eth/${verb}/v1.0.0` };
851831

852832
const callerTimeout = Number(req.body?.limits?.timeout_ms || req.body?.limits?.max_latency_ms || 0);
853-
const timeoutMs = Math.min(SERVER_MAX_HANDLER_MS, callerTimeout && callerTimeout > 0 ? callerTimeout : SERVER_MAX_HANDLER_MS);
833+
const timeoutMs = Math.min(
834+
SERVER_MAX_HANDLER_MS,
835+
callerTimeout && callerTimeout > 0 ? callerTimeout : SERVER_MAX_HANDLER_MS
836+
);
854837

855838
const work = Promise.resolve(handlers[verb](req.body));
856839
const result = timeoutMs
@@ -942,6 +925,7 @@ app.get("/debug/env", (req, res) => {
942925
service: process.env.RAILWAY_SERVICE_NAME || "runtime",
943926
enabled_verbs: ENABLED_VERBS,
944927
signer_id: SIGNER_ID,
928+
signer_kid: SIGNER_KID,
945929
signer_ok: !!pemFromB64(PRIV_PEM_B64),
946930
has_priv_b64: !!PRIV_PEM_B64,
947931
has_pub_b64: !!PUB_PEM_B64,
@@ -952,7 +936,6 @@ app.get("/debug/env", (req, res) => {
952936
schema_fetch_timeout_ms: SCHEMA_FETCH_TIMEOUT_MS,
953937
schema_validate_budget_ms: SCHEMA_VALIDATE_BUDGET_MS,
954938
verify_schema_cached_only: VERIFY_SCHEMA_CACHED_ONLY,
955-
956939
enable_ssrf_guard: ENABLE_SSRF_GUARD,
957940
fetch_timeout_ms: FETCH_TIMEOUT_MS,
958941
fetch_max_bytes: FETCH_MAX_BYTES,
@@ -973,6 +956,7 @@ app.get("/debug/env", (req, res) => {
973956
service_version: SERVICE_VERSION,
974957
api_version: API_VERSION,
975958
canonical_base_url: CANONICAL_BASE,
959+
canonical_id: CANONICAL_ID_SORTED_KEYS_V1,
976960
});
977961
});
978962

@@ -1083,14 +1067,7 @@ app.post("/verify", async (req, res) => {
10831067
return fail(400, "missing metadata.proof.signature_b64 or hash_sha256");
10841068
}
10851069

1086-
const v = verifyReceiptEd25519Sha256(receipt, {
1087-
publicKeyPemOrDer: pubPem,
1088-
allowedCanonicals: [CANONICAL_ID_SORTED_KEYS_V1]
1089-
});
1090-
1091-
const hashMatches = v.ok ? true : (v.reason === "bad_signature" ? true : (v.reason === "hash_mismatch" ? false : false));
1092-
const recomputed = hashMatches ? proof.hash_sha256 : null;
1093-
1070+
// 1) pick pubkey (env -> optional ENS)
10941071
let pubPem = pemFromB64(PUB_PEM_B64);
10951072
let pubSrc = pubPem ? "env-b64" : null;
10961073

@@ -1104,18 +1081,25 @@ app.post("/verify", async (req, res) => {
11041081
}
11051082
}
11061083

1107-
let sigOk = false;
1108-
let sigErr = null;
1109-
1084+
// 2) verify (runtime-core canonical + hash + signature)
1085+
let v = { ok: false, reason: "no_public_key" };
11101086
if (pubPem) {
1111-
sigOk = v.ok;
1112-
sigErr = v.ok ? null : (v.reason || "verify failed");
1113-
} else {
1114-
sigOk = false;
1115-
sigErr = "no public key available (set RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64 or pass ens=1 with ETH_RPC_URL)";
1087+
v = verifyReceiptEd25519Sha256(receipt, {
1088+
publicKeyPemOrDer: pubPem,
1089+
allowedCanonicals: [CANONICAL_ID_SORTED_KEYS_V1],
1090+
});
11161091
}
11171092

1118-
// Schema validation (edge-safe)
1093+
const sigOk = !!v.ok;
1094+
const sigErr =
1095+
pubPem ? (sigOk ? null : (v.reason || "verify failed")) :
1096+
"no public key available (set RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64 or pass ens=1 with ETH_RPC_URL)";
1097+
1098+
// Hash match: runtime-core reasons should already reflect canonical/hash mismatch
1099+
const hashMatches = sigOk ? true : (v.reason === "hash_mismatch" ? false : true);
1100+
const recomputed = hashMatches ? (proof.hash_sha256 || null) : null;
1101+
1102+
// 3) schema validation (edge-safe)
11191103
let schemaOk = true;
11201104
let schemaErrors = null;
11211105

@@ -1126,7 +1110,6 @@ app.post("/verify", async (req, res) => {
11261110
if (!verb) {
11271111
schemaErrors = [{ message: "missing receipt.x402.verb" }];
11281112
} else if (VERIFY_SCHEMA_CACHED_ONLY && !hasValidatorCached(verb)) {
1129-
// Do NOT compile/fetch here; queue warm and return 202
11301113
warmQueue.add(verb);
11311114
startWarmWorker();
11321115
schemaErrors = [{ message: "validator_not_warmed_yet" }];
@@ -1148,9 +1131,7 @@ app.post("/verify", async (req, res) => {
11481131
});
11491132
} else {
11501133
try {
1151-
const validate = VERIFY_SCHEMA_CACHED_ONLY
1152-
? validatorCache.get(verb)?.validate
1153-
: await getValidatorForVerb(verb);
1134+
const validate = VERIFY_SCHEMA_CACHED_ONLY ? validatorCache.get(verb)?.validate : await getValidatorForVerb(verb);
11541135

11551136
if (!validate) {
11561137
schemaOk = false;

0 commit comments

Comments
 (0)