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
112 changes: 112 additions & 0 deletions runtime/tests/runtime-signing.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,72 @@ async function stop(proc) {
if (proc.exitCode === null) proc.kill("SIGKILL");
}

async function startSchemaHost() {
const port = await freePort();
const receiptSchema = {
$id: `http://127.0.0.1:${port}/schemas/v1.1.0/commons/describe/receipts/describe.receipt.schema.json`,
type: "object",
required: ["status", "x402", "result", "metadata"],
properties: {
status: { const: "success" },
x402: {
type: "object",
required: ["verb", "version", "entry"],
properties: {
verb: { const: "describe" },
version: { type: "string" },
entry: { type: "string" },
},
},
result: {
type: "object",
required: ["description", "bullets", "properties"],
properties: {
description: { type: "string" },
bullets: { type: "array" },
properties: { type: "object" },
},
},
metadata: {
type: "object",
required: ["proof", "receipt_id"],
properties: {
receipt_id: { type: "string" },
proof: {
type: "object",
required: ["alg", "signer_id", "kid", "hash_sha256", "signature_b64", "canonical"],
properties: {
alg: { const: "ed25519-sha256" },
signer_id: { type: "string" },
kid: { type: "string" },
hash_sha256: { type: "string" },
signature_b64: { type: "string" },
canonical: { const: "json.sorted_keys.v1" },
},
},
},
},
},
};

const server = http.createServer((req, res) => {
if (req.url === "/schemas/v1.1.0/commons/describe/receipts/describe.receipt.schema.json") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(receiptSchema));
return;
}

res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "not_found" }));
});

await new Promise((resolve, reject) => server.listen(port, "127.0.0.1", (err) => (err ? reject(err) : resolve())));
return {
base: `http://127.0.0.1:${port}`,
close: () => new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))),
};
}

test("boot fails fast without keys unless DEV_AUTO_KEYS=1", async () => {
const res = spawnSync(process.execPath, ["server.mjs"], {
cwd: process.cwd(),
Expand Down Expand Up @@ -148,6 +214,52 @@ test("/verify accepts both wrapped and bare receipts from the production signing
}
});

test("schema validation fails on malformed receipt", async () => {
const keys = makeKeys();
const schemaHost = await startSchemaHost();
const srv = await startServer({
VERIFY_SCHEMA_CACHED_ONLY: "0",
SCHEMA_HOST: schemaHost.base,
RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: keys.privatePemB64,
RECEIPT_SIGNING_PUBLIC_KEY_B64: keys.publicRaw32B64,
RECEIPT_SIGNER_ID: "runtime.commandlayer.eth",
});

try {
const response = await createDescribeReceipt(srv.base);
const { receipt } = unwrapReceiptResponse(response);

const validVerifyResp = await fetch(`${srv.base}/verify?schema=1`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(receipt),
});
const validVerifyJson = await validVerifyResp.json();

assert.equal(validVerifyResp.status, 200);
assert.equal(validVerifyJson.checks?.schema_valid, true);

const malformedReceipt = structuredClone(receipt);

delete malformedReceipt.result;

const verifyResp = await fetch(`${srv.base}/verify?schema=1`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(malformedReceipt),
});

const verifyJson = await verifyResp.json();

assert.equal(verifyResp.status, 200);
assert.equal(verifyJson.checks?.schema_valid, false);
assert.match(JSON.stringify(verifyJson.errors?.schema_errors || []), /required|result/);
} finally {
await stop(srv.proc);
await schemaHost.close();
}
});

test("/verify reports a hash/signature failure when a signed production receipt is tampered", async () => {
const keys = makeKeys();
const srv = await startServer({
Expand Down
10 changes: 6 additions & 4 deletions server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -723,12 +723,14 @@ function cachePrune(map, { ttlMs, maxEntries, tsField } = {}) {
function normalizeSchemaFetchUrl(url) {
if (!url) return url;
let u = String(url);
u = u.replace(/^http:\/\//i, "https://");
u = u.replace(/^https:\/\/commandlayer\.org/i, "https://www.commandlayer.org");

u = u.replace(/^https?:\/\/commandlayer\.org/i, "https://www.commandlayer.org");
u = u.replace(/^https:\/\/www\.commandlayer\.org\/+/, "https://www.commandlayer.org/");
if (SCHEMA_HOST.startsWith("https://www.commandlayer.org")) {
u = u.replace(/^https:\/\/commandlayer\.org/i, "https://www.commandlayer.org");

if (/^http:\/\/(commandlayer\.org|www\.commandlayer\.org)(\/|$)/i.test(u)) {
u = u.replace(/^http:\/\//i, "https://");
}

return u;
}

Expand Down
Loading