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
62 changes: 62 additions & 0 deletions runtime/tests/runtime-signing.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
39 changes: 39 additions & 0 deletions scripts/print-ens-records.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
38 changes: 36 additions & 2 deletions server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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({
Expand All @@ -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) {
Expand Down Expand Up @@ -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 = [];
}
Expand All @@ -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"
}`
);
Expand Down
Loading