Skip to content

Commit 9259871

Browse files
authored
Merge pull request #10 from commandlayer/codex/implement-receipt-signing-and-ens-support
Normalize receipt signing envs and enforce Ed25519-signed receipts with ENS verification
2 parents 8d5f604 + 08bb33d commit 9259871

File tree

5 files changed

+359
-105
lines changed

5 files changed

+359
-105
lines changed

.env.example

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
1-
# Required runtime signing config
2-
CL_RECEIPT_SIGNER=runtime.commandlayer.eth
3-
CL_KEY_ID=v1
4-
CL_CANONICAL_ID=json.sorted_keys.v1
5-
6-
# Recommended for node --env-file: single-line PEM with literal \n escapes
7-
CL_PRIVATE_KEY_PEM=-----BEGIN PRIVATE KEY-----\nREPLACE_WITH_BASE64_BODY\n-----END PRIVATE KEY-----
8-
9-
# Base64 of raw 32-byte Ed25519 public key (same bytes as ENS TXT after ed25519:)
10-
CL_PUBLIC_KEY_B64=REPLACE_WITH_32_BYTE_RAW_PUBKEY_BASE64
1+
# Required runtime signing config (Railway canonical setup)
2+
RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64=REPLACE_WITH_BASE64_OF_PKCS8_PEM_PRIVATE_KEY
3+
RECEIPT_SIGNING_PUBLIC_KEY_B64=hhyCuPNoMk4JtEvGEV8F6nMZ4uDO1EcyizPufmnJTOY=
4+
RECEIPT_SIGNER_ID=runtime.commandlayer.eth
115

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

159
HOST=0.0.0.0
1610
PORT=8080
1711
ENABLED_VERBS=fetch,describe,format,clean,parse,summarize,convert,explain,analyze,classify
12+
13+
# Optional: DEV_AUTO_KEYS=1 (development only, in-memory ephemeral keypair)

README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ openssl genpkey -algorithm Ed25519 -out private.pem
4444
openssl pkey -in private.pem -pubout -out public.pem
4545

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

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

106106
`POST /verify` supports query flags:
107107

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

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

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

124+
### ENS TXT format (runtime.commandlayer.eth)
125+
126+
- `cl.sig.pub = ed25519:<base64-raw32-ed25519-public-key>`
127+
- `cl.sig.canonical = json.sorted_keys.v1`
128+
- `cl.sig.kid = v1` (optional compatibility marker; runtime derives receipt kid from pubkey fingerprint)
129+
- `cl.receipt.signer = runtime.commandlayer.eth`
130+
123131
## Security notes
124132

125133
- `fetch` only allows `http(s)` URLs.

docs/CONFIGURATION.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,21 @@ Comma-separated list of enabled handlers. Disabled verbs return `404`.
2626
|---|---|---|
2727
| `RECEIPT_SIGNER_ID` | `runtime` (or `ENS_NAME` when set) | Receipt proof signer identifier. |
2828
| `RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64` | empty | Required for signing receipts. Base64 of PEM private key. |
29-
| `RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64` | empty | Public key for `/verify` (base64-encoded PEM). |
30-
| `RECEIPT_SIGNING_PUBLIC_KEY_PEM` | empty | Public key for `/verify` (plain PEM text). Either this or the B64 variant is sufficient. |
29+
| `RECEIPT_SIGNING_PUBLIC_KEY_B64` | empty | **Preferred** verifier key input: base64 of raw 32-byte Ed25519 public key. |
30+
| `RECEIPT_SIGNING_PUBLIC_KEY_PEM` | empty | Legacy verifier key input (plain PEM text). |
31+
| `RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64` | empty | Legacy verifier key input (base64-encoded PEM); lower priority than `RECEIPT_SIGNING_PUBLIC_KEY_B64`. |
3132
| `ENS_NAME` | empty | Optional identity alias fallback. |
3233

34+
### Env precedence and normalization
35+
36+
The runtime resolves the first non-empty value from each list:
37+
38+
- 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`.
39+
- 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`.
40+
- Signer id: `CL_RECEIPT_SIGNER_ID``RECEIPT_SIGNER_ID`.
41+
42+
`RECEIPT_SIGNING_PUBLIC_KEY_B64` must decode to exactly 32 bytes.
43+
3344
## ENS-based verification
3445

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

4357
## Schema fetching + validation budgets
4458

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
import { spawnSync, spawn } from "node:child_process";
4+
import net from "node:net";
5+
import { generateKeyPairSync, createHash } from "node:crypto";
6+
7+
function freePort() {
8+
return new Promise((resolve, reject) => {
9+
const s = net.createServer();
10+
s.on("error", reject);
11+
s.listen(0, "127.0.0.1", () => {
12+
const addr = s.address();
13+
s.close(() => resolve(addr.port));
14+
});
15+
});
16+
}
17+
18+
function makeKeys() {
19+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
20+
const privatePem = privateKey.export({ type: "pkcs8", format: "pem" });
21+
const privatePemB64 = Buffer.from(String(privatePem), "utf8").toString("base64");
22+
const spki = publicKey.export({ type: "spki", format: "der" });
23+
const raw32 = Buffer.from(spki).subarray(spki.length - 32);
24+
return {
25+
privatePemB64,
26+
publicRaw32B64: raw32.toString("base64"),
27+
kid: createHash("sha256").update(raw32).digest("base64url").slice(0, 16),
28+
};
29+
}
30+
31+
async function startServer(extraEnv) {
32+
const port = await freePort();
33+
const proc = spawn(process.execPath, ["server.mjs"], {
34+
cwd: process.cwd(),
35+
env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), ...extraEnv },
36+
stdio: ["ignore", "pipe", "pipe"],
37+
});
38+
let stderr = "";
39+
proc.stderr.on("data", (d) => (stderr += String(d)));
40+
41+
const base = `http://127.0.0.1:${port}`;
42+
for (let i = 0; i < 80; i++) {
43+
try {
44+
const r = await fetch(`${base}/health`);
45+
if (r.ok) return { proc, base, stderr: () => stderr };
46+
} catch {}
47+
await new Promise((r) => setTimeout(r, 100));
48+
}
49+
throw new Error(`server did not boot: ${stderr}`);
50+
}
51+
52+
async function stop(proc) {
53+
if (proc.exitCode !== null) return;
54+
proc.kill("SIGTERM");
55+
await new Promise((r) => setTimeout(r, 200));
56+
if (proc.exitCode === null) proc.kill("SIGKILL");
57+
}
58+
59+
test("boot fails fast without keys unless DEV_AUTO_KEYS=1", async () => {
60+
const res = spawnSync(process.execPath, ["server.mjs"], {
61+
cwd: process.cwd(),
62+
env: { ...process.env, HOST: "127.0.0.1", PORT: "0", RECEIPT_SIGNER_ID: "runtime.commandlayer.eth", DEV_AUTO_KEYS: "0" },
63+
encoding: "utf8",
64+
timeout: 4000,
65+
});
66+
assert.notEqual(res.status, 0);
67+
assert.match(`${res.stderr}${res.stdout}`, /fatal signer misconfiguration|Missing required env var/);
68+
});
69+
70+
test("private PEM_B64 + public raw32 b64 path signs and /verify roundtrip works", async () => {
71+
const keys = makeKeys();
72+
const srv = await startServer({
73+
RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: keys.privatePemB64,
74+
RECEIPT_SIGNING_PUBLIC_KEY_B64: keys.publicRaw32B64,
75+
RECEIPT_SIGNER_ID: "runtime.commandlayer.eth",
76+
});
77+
78+
try {
79+
const h = await (await fetch(`${srv.base}/health`)).json();
80+
assert.equal(h.signer_ok, true);
81+
assert.equal(h.kid, keys.kid);
82+
83+
const body = {
84+
x402: { verb: "describe", version: "1.0.0", entry: "x402://describeagent.eth/describe/v1.0.0" },
85+
input: { subject: "t", detail_level: "short" },
86+
trace: { provider: "test" },
87+
};
88+
const receiptResp = await fetch(`${srv.base}/describe/v1.0.0`, {
89+
method: "POST",
90+
headers: { "content-type": "application/json" },
91+
body: JSON.stringify(body),
92+
});
93+
assert.equal(receiptResp.status, 200);
94+
const receipt = await receiptResp.json();
95+
assert.ok(receipt.metadata?.proof?.signature_b64);
96+
assert.ok(receipt.metadata?.proof?.hash_sha256);
97+
assert.equal(receipt.metadata?.proof?.signer_id, "runtime.commandlayer.eth");
98+
assert.equal(receipt.metadata?.proof?.canonical, "json.sorted_keys.v1");
99+
100+
const verifyResp = await fetch(`${srv.base}/verify`, {
101+
method: "POST",
102+
headers: { "content-type": "application/json" },
103+
body: JSON.stringify(receipt),
104+
});
105+
const verifyJson = await verifyResp.json();
106+
assert.equal(verifyResp.status, 200);
107+
assert.equal(verifyJson.ok, true);
108+
assert.equal(verifyJson.verified_with, "env");
109+
} finally {
110+
await stop(srv.proc);
111+
}
112+
});
113+
114+
test("/verify?ens=1 passes with mocked ENS TXT response", async () => {
115+
const keys = makeKeys();
116+
const ensMock = JSON.stringify({
117+
"cl.sig.pub": `ed25519:${keys.publicRaw32B64}`,
118+
"cl.sig.canonical": "json.sorted_keys.v1",
119+
"cl.sig.kid": "v1",
120+
});
121+
const srv = await startServer({
122+
RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: keys.privatePemB64,
123+
RECEIPT_SIGNING_PUBLIC_KEY_B64: keys.publicRaw32B64,
124+
RECEIPT_SIGNER_ID: "runtime.commandlayer.eth",
125+
ENS_MOCK_TXT_JSON: ensMock,
126+
});
127+
128+
try {
129+
const body = {
130+
x402: { verb: "describe", version: "1.0.0", entry: "x402://describeagent.eth/describe/v1.0.0" },
131+
input: { subject: "t", detail_level: "short" },
132+
trace: { provider: "test" },
133+
};
134+
const receipt = await (
135+
await fetch(`${srv.base}/describe/v1.0.0`, {
136+
method: "POST",
137+
headers: { "content-type": "application/json" },
138+
body: JSON.stringify(body),
139+
})
140+
).json();
141+
142+
const verifyResp = await fetch(`${srv.base}/verify?ens=1`, {
143+
method: "POST",
144+
headers: { "content-type": "application/json" },
145+
body: JSON.stringify(receipt),
146+
});
147+
const verifyJson = await verifyResp.json();
148+
assert.equal(verifyResp.status, 200);
149+
assert.equal(verifyJson.ok, true);
150+
assert.equal(verifyJson.verified_with, "ens");
151+
} finally {
152+
await stop(srv.proc);
153+
}
154+
});

0 commit comments

Comments
 (0)