diff --git a/runtime/tests/runtime-signing.test.mjs b/runtime/tests/runtime-signing.test.mjs index df6c058..f63b788 100644 --- a/runtime/tests/runtime-signing.test.mjs +++ b/runtime/tests/runtime-signing.test.mjs @@ -145,6 +145,68 @@ test("boot fails fast without keys unless DEV_AUTO_KEYS=1", async () => { assert.match(`${res.stderr}${res.stdout}`, /fatal signer misconfiguration|Missing required env var/); }); + + +test("same key material derives the same kid across restarts", async () => { + const keys = makeKeys(); + const env = { + RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: keys.privatePemB64, + RECEIPT_SIGNING_PUBLIC_KEY_B64: keys.publicRaw32B64, + RECEIPT_SIGNER_ID: "runtime.commandlayer.eth", + }; + const a = await startServer(env); + const b = await startServer(env); + try { + const ha = await (await fetch(`${a.base}/health`)).json(); + const hb = await (await fetch(`${b.base}/health`)).json(); + assert.equal(ha.kid, keys.kid); + assert.equal(hb.kid, keys.kid); + assert.equal(ha.kid, hb.kid); + } finally { + await stop(a.proc); + await stop(b.proc); + } +}); + +test("boot rejects mismatched public/private signer key material", async () => { + const a = makeKeys(); + const b = makeKeys(); + const res = spawnSync(process.execPath, ["server.mjs"], { + cwd: process.cwd(), + env: { + ...process.env, + HOST: "127.0.0.1", + PORT: "0", + RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: a.privatePemB64, + RECEIPT_SIGNING_PUBLIC_KEY_B64: b.publicRaw32B64, + RECEIPT_SIGNER_ID: "runtime.commandlayer.eth", + DEV_AUTO_KEYS: "0", + }, + encoding: "utf8", + timeout: 4000, + }); + assert.notEqual(res.status, 0); + assert.match(`${res.stderr}${res.stdout}`, /keypair: private\/public key mismatch/); +}); + +test("boot rejects missing signer id in persistent signer mode", async () => { + const keys = makeKeys(); + const res = spawnSync(process.execPath, ["server.mjs"], { + cwd: process.cwd(), + env: { + ...process.env, + HOST: "127.0.0.1", + PORT: "0", + RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: keys.privatePemB64, + RECEIPT_SIGNING_PUBLIC_KEY_B64: keys.publicRaw32B64, + DEV_AUTO_KEYS: "0", + }, + encoding: "utf8", + timeout: 4000, + }); + assert.notEqual(res.status, 0); + assert.match(`${res.stderr}${res.stdout}`, /RECEIPT_SIGNER_ID must be non-empty|Missing required env var/); +}); test("makeReceipt production path emits signed receipts with runtime kid and canonical fields", async () => { const keys = makeKeys(); const srv = await startServer({ diff --git a/scripts/print-ens-records.mjs b/scripts/print-ens-records.mjs new file mode 100644 index 0000000..195f467 --- /dev/null +++ b/scripts/print-ens-records.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +import crypto from "node:crypto"; + +const CANONICAL = "json.sorted_keys.v1"; + +function decodeB64Strict(value) { + const cleaned0 = String(value || "").replace(/\s+/g, ""); + if (!cleaned0) return null; + let cleaned = cleaned0.replace(/-/g, "+").replace(/_/g, "/"); + const pad = cleaned.length % 4; + if (pad) cleaned += "=".repeat(4 - pad); + const out = Buffer.from(cleaned, "base64"); + if (!out.length) return null; + return out; +} + +function deriveKidFromRaw32(raw32) { + return crypto.createHash("sha256").update(raw32).digest("base64url").slice(0, 16); +} + +const pubB64 = String(process.env.RECEIPT_SIGNING_PUBLIC_KEY_B64 || "").trim(); +const signerId = String(process.env.RECEIPT_SIGNER_ID || "").trim(); + +if (!signerId) { + console.error("Missing RECEIPT_SIGNER_ID"); + process.exit(1); +} + +const raw = decodeB64Strict(pubB64); +if (!raw || raw.length !== 32) { + console.error("RECEIPT_SIGNING_PUBLIC_KEY_B64 must decode to 32-byte Ed25519 public key"); + process.exit(1); +} + +const kid = deriveKidFromRaw32(raw); +console.log(`cl.sig.pub=ed25519:${raw.toString("base64")}`); +console.log(`cl.sig.kid=${kid}`); +console.log(`cl.sig.canonical=${CANONICAL}`); +console.log(`cl.receipt.signer=${signerId}`); diff --git a/server.mjs b/server.mjs index b833fee..b68ac6c 100644 --- a/server.mjs +++ b/server.mjs @@ -111,6 +111,7 @@ const ENABLED_VERBS = ( .filter(Boolean); const DEV_AUTO_KEYS = envFlag("DEV_AUTO_KEYS"); +const PERSISTENT_CANONICAL_METHOD = "json.sorted_keys.v1"; function envAnySource(...names) { for (const n of names) { @@ -369,6 +370,22 @@ function assertBootConfigOrThrow() { if (missing.length) throw new Error(`Missing required env var(s): ${missing.join(", ")}`); } + +function requirePersistentSignerInvariantsOrThrow() { + if (runtimeConfig.canonicalMethod !== PERSISTENT_CANONICAL_METHOD) { + throw new Error(`canonicalization must be ${PERSISTENT_CANONICAL_METHOD}`); + } + if (!runtimeConfig.signerId || !String(runtimeConfig.signerId).trim()) { + throw new Error("RECEIPT_SIGNER_ID must be non-empty"); + } +} + +function assertKeypairMatches(privatePem, publicPem) { + const probe = Buffer.from("commandlayer-runtime-signer-probe"); + const sig = crypto.sign(null, probe, crypto.createPrivateKey({ key: privatePem, format: "pem" })); + const ok = crypto.verify(null, probe, crypto.createPublicKey({ key: publicPem, format: "pem" }), sig); + if (!ok) throw new Error("private/public key mismatch"); +} function validatePublicKeyPem(pem) { if (!pem) return { ok: false, reason: "missing public key" }; try { @@ -435,7 +452,7 @@ function maybeEnableDevAutoKeys() { runtimeConfig.kid = deriveKidFromRaw32(raw32); runtimeConfig.signerSource = "dev_auto_keys"; - console.warn("DEV_AUTO_KEYS=1 enabled; generated in-memory temporary Ed25519 keypair."); + console.warn("[boot] DEV MODE: DEV_AUTO_KEYS=1 enabled; generated in-memory temporary Ed25519 keypair."); console.warn(`TEMP PRIVATE KEY PEM (set RECEIPT_SIGNING_PRIVATE_KEY_PEM): ${escapedOneLinePem(activeSigner.privateKeyPem)}`); console.warn(`TEMP PUBLIC RAW32 B64 (set RECEIPT_SIGNING_PUBLIC_KEY_B64): ${activeSigner.publicKeyRaw32B64}`); printEnsTxtValues({ @@ -449,6 +466,14 @@ function maybeEnableDevAutoKeys() { function initializeSignerConfigOrThrow() { signerBootState.errors = []; + if (!DEV_AUTO_KEYS) { + try { + requirePersistentSignerInvariantsOrThrow(); + } catch (e) { + signerBootState.errors.push(String(e?.message || e)); + } + } + try { assertBootConfigOrThrow(); } catch (e) { @@ -479,6 +504,14 @@ function initializeSignerConfigOrThrow() { maybeEnableDevAutoKeys(); } + if (activeSigner.privateKeyPem && activeSigner.publicKeyPem) { + try { + assertKeypairMatches(activeSigner.privateKeyPem, activeSigner.publicKeyPem); + } catch (e) { + signerBootState.errors.push(`keypair: ${e?.message || e}`); + } + } + if (activeSigner.privateKeyPem && activeSigner.publicKeyPem && activeSigner.source === "dev_auto_keys") { signerBootState.errors = []; } @@ -500,8 +533,9 @@ function initializeSignerConfigOrThrow() { } } + const bootMode = DEV_AUTO_KEYS ? "DEV MODE" : "PERSISTENT SIGNER MODE"; console.log( - `[boot] signer_ok=${signerBootState.ok} signer_source=${activeSigner.source} kid=${runtimeConfig.kid} fp=${ + `[boot] ${bootMode} signer_ok=${signerBootState.ok} signer_source=${activeSigner.source} signer_id=${runtimeConfig.signerId || "n/a"} canonicalization=${runtimeConfig.canonicalMethod} kid=${runtimeConfig.kid} fp=${ activeSigner.publicKeyFingerprint || "n/a" }` );