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
16 changes: 6 additions & 10 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
# Required runtime signing config
CL_RECEIPT_SIGNER=runtime.commandlayer.eth
CL_KEY_ID=v1
CL_CANONICAL_ID=json.sorted_keys.v1

# Recommended for node --env-file: single-line PEM with literal \n escapes
CL_PRIVATE_KEY_PEM=-----BEGIN PRIVATE KEY-----\nREPLACE_WITH_BASE64_BODY\n-----END PRIVATE KEY-----

# Base64 of raw 32-byte Ed25519 public key (same bytes as ENS TXT after ed25519:)
CL_PUBLIC_KEY_B64=REPLACE_WITH_32_BYTE_RAW_PUBKEY_BASE64
# Required runtime signing config (Railway canonical setup)
RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64=REPLACE_WITH_BASE64_OF_PKCS8_PEM_PRIVATE_KEY
RECEIPT_SIGNING_PUBLIC_KEY_B64=hhyCuPNoMk4JtEvGEV8F6nMZ4uDO1EcyizPufmnJTOY=
RECEIPT_SIGNER_ID=runtime.commandlayer.eth

# Optional (required only for /verify?ens=1)
ETH_RPC_URL=

HOST=0.0.0.0
PORT=8080
ENABLED_VERBS=fetch,describe,format,clean,parse,summarize,convert,explain,analyze,classify

# Optional: DEV_AUTO_KEYS=1 (development only, in-memory ephemeral keypair)
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ openssl genpkey -algorithm Ed25519 -out private.pem
openssl pkey -in private.pem -pubout -out public.pem

export RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64="$(base64 -w0 < private.pem)"
export RECEIPT_SIGNING_PUBLIC_KEY="ed25519:$(openssl pkey -in public.pem -pubin -outform DER | tail -c 32 | base64 -w0)"
export RECEIPT_SIGNER_ID="runtime.local"
export RECEIPT_SIGNING_PUBLIC_KEY_B64="$(openssl pkey -in public.pem -pubin -outform DER | tail -c 32 | base64 -w0)"
export RECEIPT_SIGNER_ID="runtime.commandlayer.eth"
```

> macOS note: replace `base64 -w0` with `base64 | tr -d '\n'`.
Expand Down Expand Up @@ -105,7 +105,8 @@ printf '%s' "$RECEIPT" | curl -s -X POST "http://localhost:8080/verify?ens=1" \

`POST /verify` supports query flags:

- `ens=1` — fetch verifier pubkey from ENS TXT records (`VERIFIER_ENS_NAME`, `cl.receipt.signer`, `cl.sig.pub`, `cl.sig.kid`).
- `ens=1` — fetch verifier pubkey from ENS TXT records (`cl.sig.pub`, `cl.sig.canonical`, optional `cl.sig.kid`).
- `strict_kid=1` — when `ens=1` and `cl.sig.kid` exists, require receipt `metadata.proof.kid` to match ENS `cl.sig.kid`.
- `refresh=1` — bypass ENS cache and refresh lookup.
- `schema=1` — validate receipt against verb schema.

Expand All @@ -120,6 +121,13 @@ Use `POST /debug/prewarm` and `GET /debug/validators` for schema prewarming work

Detailed environment variable documentation lives in [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md).

### ENS TXT format (runtime.commandlayer.eth)

- `cl.sig.pub = ed25519:<base64-raw32-ed25519-public-key>`
- `cl.sig.canonical = json.sorted_keys.v1`
- `cl.sig.kid = v1` (optional compatibility marker; runtime derives receipt kid from pubkey fingerprint)
- `cl.receipt.signer = runtime.commandlayer.eth`

## Security notes

- `fetch` only allows `http(s)` URLs.
Expand Down
18 changes: 16 additions & 2 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,21 @@ Comma-separated list of enabled handlers. Disabled verbs return `404`.
|---|---|---|
| `RECEIPT_SIGNER_ID` | `runtime` (or `ENS_NAME` when set) | Receipt proof signer identifier. |
| `RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64` | empty | Required for signing receipts. Base64 of PEM private key. |
| `RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64` | empty | Public key for `/verify` (base64-encoded PEM). |
| `RECEIPT_SIGNING_PUBLIC_KEY_PEM` | empty | Public key for `/verify` (plain PEM text). Either this or the B64 variant is sufficient. |
| `RECEIPT_SIGNING_PUBLIC_KEY_B64` | empty | **Preferred** verifier key input: base64 of raw 32-byte Ed25519 public key. |
| `RECEIPT_SIGNING_PUBLIC_KEY_PEM` | empty | Legacy verifier key input (plain PEM text). |
| `RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64` | empty | Legacy verifier key input (base64-encoded PEM); lower priority than `RECEIPT_SIGNING_PUBLIC_KEY_B64`. |
| `ENS_NAME` | empty | Optional identity alias fallback. |

### Env precedence and normalization

The runtime resolves the first non-empty value from each list:

- Private key: `CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM` → `RECEIPT_SIGNING_PRIVATE_KEY_PEM` → `CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64` → `RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64` → `CL_RECEIPT_SIGNING_PRIVATE_KEY_B64` → `RECEIPT_SIGNING_PRIVATE_KEY_B64` → `CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM_FILE`.
- Public key: `CL_RECEIPT_SIGNING_PUBLIC_KEY_B64` → `RECEIPT_SIGNING_PUBLIC_KEY_B64` → `CL_RECEIPT_SIGNING_PUBLIC_KEY_PEM` → `RECEIPT_SIGNING_PUBLIC_KEY_PEM` → `CL_RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64` → `RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64` → `CL_RECEIPT_SIGNING_PUBLIC_KEY_PEM_FILE`.
- Signer id: `CL_RECEIPT_SIGNER_ID` → `RECEIPT_SIGNER_ID`.

`RECEIPT_SIGNING_PUBLIC_KEY_B64` must decode to exactly 32 bytes.

## ENS-based verification

| Variable | Default | Purpose |
Expand All @@ -39,6 +50,9 @@ Comma-separated list of enabled handlers. Disabled verbs return `404`.
| `ENS_SIGNER_TEXT_KEY` | `cl.receipt.signer` | ENS TXT key on verifier name that delegates to signer ENS name. |
| `ENS_SIG_PUB_TEXT_KEY` | `cl.sig.pub` | ENS TXT key on signer name containing `ed25519:<base64>` public key. |
| `ENS_SIG_KID_TEXT_KEY` | `cl.sig.kid` | ENS TXT key on signer name containing key identifier. |
| `ENS_SIG_CANONICAL_KEY` | `cl.sig.canonical` | ENS TXT key on signer name containing canonical mode (e.g. `json.sorted_keys.v1`). |

`/verify?ens=1` verifies using ENS `cl.sig.pub` key material. `/verify?ens=1&strict_kid=1` additionally enforces `cl.sig.kid` equality when present.

## Schema fetching + validation budgets

Expand Down
154 changes: 154 additions & 0 deletions runtime/tests/runtime-signing.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync, spawn } from "node:child_process";
import net from "node:net";
import { generateKeyPairSync, createHash } from "node:crypto";

function freePort() {
return new Promise((resolve, reject) => {
const s = net.createServer();
s.on("error", reject);
s.listen(0, "127.0.0.1", () => {
const addr = s.address();
s.close(() => resolve(addr.port));
});
});
}

function makeKeys() {
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const privatePem = privateKey.export({ type: "pkcs8", format: "pem" });
const privatePemB64 = Buffer.from(String(privatePem), "utf8").toString("base64");
const spki = publicKey.export({ type: "spki", format: "der" });
const raw32 = Buffer.from(spki).subarray(spki.length - 32);
return {
privatePemB64,
publicRaw32B64: raw32.toString("base64"),
kid: createHash("sha256").update(raw32).digest("base64url").slice(0, 16),
};
}

async function startServer(extraEnv) {
const port = await freePort();
const proc = spawn(process.execPath, ["server.mjs"], {
cwd: process.cwd(),
env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), ...extraEnv },
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
proc.stderr.on("data", (d) => (stderr += String(d)));

const base = `http://127.0.0.1:${port}`;
for (let i = 0; i < 80; i++) {
try {
const r = await fetch(`${base}/health`);
if (r.ok) return { proc, base, stderr: () => stderr };
} catch {}
await new Promise((r) => setTimeout(r, 100));
}
throw new Error(`server did not boot: ${stderr}`);
}

async function stop(proc) {
if (proc.exitCode !== null) return;
proc.kill("SIGTERM");
await new Promise((r) => setTimeout(r, 200));
if (proc.exitCode === null) proc.kill("SIGKILL");
}

test("boot fails fast without keys unless DEV_AUTO_KEYS=1", async () => {
const res = spawnSync(process.execPath, ["server.mjs"], {
cwd: process.cwd(),
env: { ...process.env, HOST: "127.0.0.1", PORT: "0", 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}`, /fatal signer misconfiguration|Missing required env var/);
});

test("private PEM_B64 + public raw32 b64 path signs and /verify roundtrip works", async () => {
const keys = makeKeys();
const srv = await startServer({
RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: keys.privatePemB64,
RECEIPT_SIGNING_PUBLIC_KEY_B64: keys.publicRaw32B64,
RECEIPT_SIGNER_ID: "runtime.commandlayer.eth",
});

try {
const h = await (await fetch(`${srv.base}/health`)).json();
assert.equal(h.signer_ok, true);
assert.equal(h.kid, keys.kid);

const body = {
x402: { verb: "describe", version: "1.0.0", entry: "x402://describeagent.eth/describe/v1.0.0" },
input: { subject: "t", detail_level: "short" },
trace: { provider: "test" },
};
const receiptResp = await fetch(`${srv.base}/describe/v1.0.0`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
assert.equal(receiptResp.status, 200);
const receipt = await receiptResp.json();
assert.ok(receipt.metadata?.proof?.signature_b64);
assert.ok(receipt.metadata?.proof?.hash_sha256);
assert.equal(receipt.metadata?.proof?.signer_id, "runtime.commandlayer.eth");
assert.equal(receipt.metadata?.proof?.canonical, "json.sorted_keys.v1");

const verifyResp = await fetch(`${srv.base}/verify`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(receipt),
});
const verifyJson = await verifyResp.json();
assert.equal(verifyResp.status, 200);
assert.equal(verifyJson.ok, true);
assert.equal(verifyJson.verified_with, "env");
} finally {
await stop(srv.proc);
}
});

test("/verify?ens=1 passes with mocked ENS TXT response", async () => {
const keys = makeKeys();
const ensMock = JSON.stringify({
"cl.sig.pub": `ed25519:${keys.publicRaw32B64}`,
"cl.sig.canonical": "json.sorted_keys.v1",
"cl.sig.kid": "v1",
});
const srv = await startServer({
RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: keys.privatePemB64,
RECEIPT_SIGNING_PUBLIC_KEY_B64: keys.publicRaw32B64,
RECEIPT_SIGNER_ID: "runtime.commandlayer.eth",
ENS_MOCK_TXT_JSON: ensMock,
});

try {
const body = {
x402: { verb: "describe", version: "1.0.0", entry: "x402://describeagent.eth/describe/v1.0.0" },
input: { subject: "t", detail_level: "short" },
trace: { provider: "test" },
};
const receipt = await (
await fetch(`${srv.base}/describe/v1.0.0`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
})
).json();

const verifyResp = await fetch(`${srv.base}/verify?ens=1`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(receipt),
});
const verifyJson = await verifyResp.json();
assert.equal(verifyResp.status, 200);
assert.equal(verifyJson.ok, true);
assert.equal(verifyJson.verified_with, "ens");
} finally {
await stop(srv.proc);
}
});
Loading