Skip to content
Merged
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
111 changes: 98 additions & 13 deletions server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/

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

Expand Down Expand Up @@ -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:<base64>` into a PEM SPKI public key.
// SPKI DER for Ed25519: 302a300506032b6570032100 || <32-byte pubkey>
function ed25519RawToSpkiDer(raw32) {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});

Expand Down Expand Up @@ -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(),
});
}
Expand Down Expand Up @@ -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(),
})
Expand Down
Loading