Skip to content

Commit d49730d

Browse files
committed
Switch ENS signature key resolution to cl.sig.* records
1 parent 0636e7c commit d49730d

File tree

5 files changed

+128
-45
lines changed

5 files changed

+128
-45
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ 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_PEM_B64="$(base64 -w0 < public.pem)"
47+
export RECEIPT_SIGNING_PUBLIC_KEY="ed25519:$(openssl pkey -in public.pem -pubin -outform DER | tail -c 32 | base64 -w0)"
4848
export RECEIPT_SIGNER_ID="runtime.local"
4949
```
5050

@@ -105,7 +105,7 @@ 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 record (`VERIFIER_ENS_NAME`, `ENS_PUBKEY_TEXT_KEY`).
108+
- `ens=1` — fetch verifier pubkey from ENS TXT records (`VERIFIER_ENS_NAME`, `cl.receipt.signer`, `cl.sig.pub`, `cl.sig.kid`).
109109
- `refresh=1` — bypass ENS cache and refresh lookup.
110110
- `schema=1` — validate receipt against verb schema.
111111

docs/CONFIGURATION.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ 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 | Optional local pubkey for `/verify` signature checks. |
29+
| `RECEIPT_SIGNING_PUBLIC_KEY` | empty | Optional local verifier pubkey text in `ed25519:<base64>` format for `/verify`. |
3030
| `ENS_NAME` | empty | Optional identity alias fallback. |
3131

3232
## ENS-based verification
@@ -35,7 +35,9 @@ Comma-separated list of enabled handlers. Disabled verbs return `404`.
3535
|---|---|---|
3636
| `ETH_RPC_URL` | empty | Ethereum RPC endpoint for ENS resolver lookups. |
3737
| `VERIFIER_ENS_NAME` | `ENS_NAME` / `RECEIPT_SIGNER_ID` fallback | ENS name queried for TXT pubkey value. |
38-
| `ENS_PUBKEY_TEXT_KEY` | `cl.receipt.pubkey.pem` | ENS TXT key containing PEM-formatted public key. |
38+
| `ENS_SIGNER_TEXT_KEY` | `cl.receipt.signer` | ENS TXT key on verifier name that delegates to signer ENS name. |
39+
| `ENS_SIG_PUB_TEXT_KEY` | `cl.sig.pub` | ENS TXT key on signer name containing `ed25519:<base64>` public key. |
40+
| `ENS_SIG_KID_TEXT_KEY` | `cl.sig.kid` | ENS TXT key on signer name containing key identifier. |
3941

4042
## Schema fetching + validation budgets
4143

docs/OPERATIONS.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@
44

55
1. Set signing keys:
66
- `RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64`
7-
- `RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64`
7+
- `RECEIPT_SIGNING_PUBLIC_KEY`
88
2. Set identity metadata:
99
- `RECEIPT_SIGNER_ID`
1010
- `SERVICE_NAME`, `SERVICE_VERSION`
1111
3. If using ENS verification:
1212
- `ETH_RPC_URL`
1313
- `VERIFIER_ENS_NAME`
14-
- `ENS_PUBKEY_TEXT_KEY`
14+
- `ENS_SIGNER_TEXT_KEY`
15+
- `ENS_SIG_PUB_TEXT_KEY`
16+
- `ENS_SIG_KID_TEXT_KEY`
1517
4. Set safety limits (`FETCH_TIMEOUT_MS`, `FETCH_MAX_BYTES`, `VERIFY_MAX_MS`).
1618
5. Restrict outbound domains with `ALLOW_FETCH_HOSTS` where possible.
1719

@@ -44,10 +46,10 @@ Repeat validator polling until required verbs appear under `cached`.
4446

4547
### `no public key available`
4648

47-
- Set `RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64` **or** use ENS verification with:
49+
- Set `RECEIPT_SIGNING_PUBLIC_KEY` (`ed25519:<base64>`) **or** use ENS verification with:
4850
- `ETH_RPC_URL`
4951
- `VERIFIER_ENS_NAME`
50-
- valid PEM at ENS TXT key.
52+
- valid `cl.sig.pub` and `cl.sig.kid` TXT values on signer ENS name.
5153

5254
### `validator_not_warmed_yet` with HTTP 202
5355

server.mjs

Lines changed: 107 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ const ENABLED_VERBS = (process.env.ENABLED_VERBS || "fetch,describe,format,clean
5353

5454
const SIGNER_ID = process.env.RECEIPT_SIGNER_ID || process.env.ENS_NAME || "runtime";
5555
const PRIV_PEM_B64 = process.env.RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64 || "";
56-
const PUB_PEM_B64 = process.env.RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64 || "";
56+
const PUB_KEY_TEXT = process.env.RECEIPT_SIGNING_PUBLIC_KEY || "";
5757

5858
// ---- service identity / discovery
5959
const SERVICE_NAME = process.env.SERVICE_NAME || "commandlayer-runtime";
@@ -64,7 +64,9 @@ const API_VERSION = process.env.API_VERSION || "1.0.0";
6464
// ENS verifier config
6565
const ETH_RPC_URL = process.env.ETH_RPC_URL || "";
6666
const VERIFIER_ENS_NAME = process.env.VERIFIER_ENS_NAME || process.env.ENS_NAME || SIGNER_ID || "";
67-
const ENS_PUBKEY_TEXT_KEY = process.env.ENS_PUBKEY_TEXT_KEY || "cl.receipt.pubkey.pem";
67+
const ENS_SIG_PUB_TEXT_KEY = process.env.ENS_SIG_PUB_TEXT_KEY || "cl.sig.pub";
68+
const ENS_SIG_KID_TEXT_KEY = process.env.ENS_SIG_KID_TEXT_KEY || "cl.sig.kid";
69+
const ENS_SIGNER_TEXT_KEY = process.env.ENS_SIGNER_TEXT_KEY || "cl.receipt.signer";
6870

6971
// IMPORTANT: AJV should fetch schemas from www, but schemas' $id/refs may be commandlayer.org.
7072
// We normalize fetch URLs to https://www.commandlayer.org to avoid redirect/host mismatches.
@@ -148,6 +150,38 @@ function normalizePem(text) {
148150
return pem.includes("BEGIN") ? pem : null;
149151
}
150152

153+
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
154+
155+
function parseEd25519PublicKeyText(text) {
156+
if (typeof text !== "string") throw new Error("public key must be a string");
157+
const trimmed = text.trim();
158+
const idx = trimmed.indexOf(":");
159+
if (idx <= 0) throw new Error("invalid ed25519 public key format (expected ed25519:<base64>)");
160+
const alg = trimmed.slice(0, idx).toLowerCase();
161+
const payload = trimmed.slice(idx + 1).trim();
162+
if (alg !== "ed25519" || !payload) throw new Error("invalid ed25519 public key format (expected ed25519:<base64>)");
163+
164+
let bytes;
165+
try {
166+
bytes = Buffer.from(payload, "base64");
167+
} catch {
168+
throw new Error("invalid base64 in ed25519 public key");
169+
}
170+
if (!bytes.length || bytes.toString("base64") !== payload) {
171+
throw new Error("invalid base64 in ed25519 public key");
172+
}
173+
if (bytes.length !== 32) throw new Error("invalid ed25519 public key length (expected 32 bytes)");
174+
return bytes;
175+
}
176+
177+
function ed25519PublicKeyObject(pubkeyBytes) {
178+
if (!Buffer.isBuffer(pubkeyBytes) || pubkeyBytes.length !== 32) {
179+
throw new Error("invalid ed25519 public key bytes");
180+
}
181+
const spki = Buffer.concat([ED25519_SPKI_PREFIX, pubkeyBytes]);
182+
return crypto.createPublicKey({ key: spki, format: "der", type: "spki" });
183+
}
184+
151185
function signEd25519Base64(messageUtf8) {
152186
const pem = pemFromB64(PRIV_PEM_B64);
153187
if (!pem) throw new Error("Missing RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64");
@@ -156,8 +190,8 @@ function signEd25519Base64(messageUtf8) {
156190
return sig.toString("base64");
157191
}
158192

159-
function verifyEd25519Base64(messageUtf8, signatureB64, pubPem) {
160-
const key = crypto.createPublicKey(pubPem);
193+
function verifyEd25519Base64(messageUtf8, signatureB64, pubkeyBytes) {
194+
const key = ed25519PublicKeyObject(pubkeyBytes);
161195
return crypto.verify(null, Buffer.from(messageUtf8, "utf8"), key, Buffer.from(signatureB64, "base64"));
162196
}
163197

@@ -255,7 +289,9 @@ async function ssrfGuardOrThrow(urlStr) {
255289
let ensCache = {
256290
fetched_at: 0,
257291
ttl_ms: 10 * 60 * 1000,
258-
pem: null,
292+
pubkeyBytes: null,
293+
kid: null,
294+
signer: null,
259295
error: null,
260296
source: null,
261297
};
@@ -269,31 +305,55 @@ async function withTimeout(promise, ms, label = "timeout") {
269305
return await Promise.race([promise, new Promise((_, rej) => setTimeout(() => rej(new Error(label)), ms))]);
270306
}
271307

272-
async function fetchEnsPubkeyPem({ refresh = false } = {}) {
308+
async function resolveSignatureKey(name, { refresh = false } = {}) {
273309
const now = Date.now();
274-
if (!refresh && ensCache.pem && now - ensCache.fetched_at < ensCache.ttl_ms) {
275-
return { ok: true, pem: ensCache.pem, source: ensCache.source, cache: { ...ensCache } };
310+
if (!refresh && ensCache.pubkeyBytes && now - ensCache.fetched_at < ensCache.ttl_ms) {
311+
return {
312+
ok: true,
313+
pubkeyBytes: ensCache.pubkeyBytes,
314+
kid: ensCache.kid,
315+
signer: ensCache.signer,
316+
source: ensCache.source,
317+
cache: { ...ensCache },
318+
};
276319
}
277-
if (!VERIFIER_ENS_NAME) {
278-
ensCache = { ...ensCache, fetched_at: now, pem: null, error: "Missing VERIFIER_ENS_NAME", source: null };
279-
return { ok: false, pem: null, source: null, error: ensCache.error, cache: { ...ensCache } };
320+
if (!name) {
321+
ensCache = { ...ensCache, fetched_at: now, pubkeyBytes: null, kid: null, signer: null, error: "Missing ENS name", source: null };
322+
return { ok: false, pubkeyBytes: null, kid: null, signer: null, source: null, error: ensCache.error, cache: { ...ensCache } };
280323
}
281324
if (!ETH_RPC_URL) {
282-
ensCache = { ...ensCache, fetched_at: now, pem: null, error: "Missing ETH_RPC_URL", source: null };
283-
return { ok: false, pem: null, source: null, error: ensCache.error, cache: { ...ensCache } };
325+
ensCache = {
326+
...ensCache,
327+
fetched_at: now,
328+
pubkeyBytes: null,
329+
kid: null,
330+
signer: null,
331+
error: "Missing ETH_RPC_URL",
332+
source: null,
333+
};
334+
return { ok: false, pubkeyBytes: null, kid: null, signer: null, source: null, error: ensCache.error, cache: { ...ensCache } };
284335
}
285336
try {
286337
const provider = new ethers.JsonRpcProvider(ETH_RPC_URL);
287-
const resolver = await withTimeout(provider.getResolver(VERIFIER_ENS_NAME), 6000, "ens_resolver_timeout");
338+
const resolver = await withTimeout(provider.getResolver(name), 6000, "ens_resolver_timeout");
288339
if (!resolver) throw new Error("No resolver for ENS name");
289-
const txt = await withTimeout(resolver.getText(ENS_PUBKEY_TEXT_KEY), 6000, "ens_text_timeout");
290-
const pem = normalizePem(txt);
291-
if (!pem) throw new Error(`ENS text ${ENS_PUBKEY_TEXT_KEY} missing/invalid PEM`);
292-
ensCache = { ...ensCache, fetched_at: now, pem, error: null, source: "ens" };
293-
return { ok: true, pem, source: "ens", cache: { ...ensCache } };
340+
341+
const signerTxt = await withTimeout(resolver.getText(ENS_SIGNER_TEXT_KEY), 6000, "ens_signer_text_timeout");
342+
const signer = String(signerTxt || "").trim() || name;
343+
const signerResolver = signer === name ? resolver : await withTimeout(provider.getResolver(signer), 6000, "ens_signer_resolver_timeout");
344+
if (!signerResolver) throw new Error("No resolver for signer ENS name");
345+
346+
const pubTxt = await withTimeout(signerResolver.getText(ENS_SIG_PUB_TEXT_KEY), 6000, "ens_pub_text_timeout");
347+
const kidTxt = await withTimeout(signerResolver.getText(ENS_SIG_KID_TEXT_KEY), 6000, "ens_kid_text_timeout");
348+
const pubkeyBytes = parseEd25519PublicKeyText(String(pubTxt || ""));
349+
const kid = String(kidTxt || "").trim();
350+
if (!kid) throw new Error(`ENS text ${ENS_SIG_KID_TEXT_KEY} missing/invalid`);
351+
352+
ensCache = { ...ensCache, fetched_at: now, pubkeyBytes, kid, signer, error: null, source: "ens" };
353+
return { ok: true, pubkeyBytes, kid, signer, source: "ens", cache: { ...ensCache } };
294354
} catch (e) {
295-
ensCache = { ...ensCache, fetched_at: now, pem: null, error: e?.message || "ens fetch failed", source: null };
296-
return { ok: false, pem: null, source: null, error: ensCache.error, cache: { ...ensCache } };
355+
ensCache = { ...ensCache, fetched_at: now, pubkeyBytes: null, kid: null, signer: null, error: e?.message || "ens fetch failed", source: null };
356+
return { ok: false, pubkeyBytes: null, kid: null, signer: null, source: null, error: ensCache.error, cache: { ...ensCache } };
297357
}
298358
}
299359

@@ -1071,9 +1131,11 @@ app.get("/debug/env", (req, res) => {
10711131
signer_id: SIGNER_ID,
10721132
signer_ok: !!pemFromB64(PRIV_PEM_B64),
10731133
has_priv_b64: !!PRIV_PEM_B64,
1074-
has_pub_b64: !!PUB_PEM_B64,
1134+
has_pubkey_text: !!PUB_KEY_TEXT,
10751135
verifier_ens_name: VERIFIER_ENS_NAME || null,
1076-
ens_pubkey_text_key: ENS_PUBKEY_TEXT_KEY,
1136+
ens_sig_pub_text_key: ENS_SIG_PUB_TEXT_KEY,
1137+
ens_sig_kid_text_key: ENS_SIG_KID_TEXT_KEY,
1138+
ens_signer_text_key: ENS_SIGNER_TEXT_KEY,
10771139
has_rpc: hasRpc(),
10781140
schema_host: SCHEMA_HOST,
10791141
schema_fetch_timeout_ms: SCHEMA_FETCH_TIMEOUT_MS,
@@ -1115,14 +1177,20 @@ app.get("/debug/env", (req, res) => {
11151177
app.get("/debug/enskey", async (req, res) => {
11161178
if (!requireDebugAccess(req, res)) return;
11171179
const refresh = String(req.query.refresh || "0") === "1";
1118-
const out = await fetchEnsPubkeyPem({ refresh });
1180+
const out = await resolveSignatureKey(VERIFIER_ENS_NAME, { refresh });
11191181
res.json({
11201182
ok: !!out.ok,
11211183
pubkey_source: out.source || null,
11221184
ens_name: VERIFIER_ENS_NAME || null,
1123-
txt_key: ENS_PUBKEY_TEXT_KEY,
1185+
signer: out.signer || null,
1186+
txt_keys: {
1187+
signer: ENS_SIGNER_TEXT_KEY,
1188+
pub: ENS_SIG_PUB_TEXT_KEY,
1189+
kid: ENS_SIG_KID_TEXT_KEY,
1190+
},
1191+
kid: out.kid || null,
11241192
cache: out.cache ? { fetched_at: new Date(out.cache.fetched_at).toISOString(), ttl_ms: out.cache.ttl_ms } : null,
1125-
preview: out.pem ? out.pem.slice(0, 80) + "..." : null,
1193+
preview: out.pubkeyBytes ? `ed25519:${out.pubkeyBytes.toString("base64").slice(0, 24)}...` : null,
11261194
error: out.error || null,
11271195
});
11281196
});
@@ -1259,32 +1327,34 @@ app.post("/verify", async (req, res) => {
12591327

12601328
const hashMatches = recomputed === proof.hash_sha256;
12611329

1262-
let pubPem = pemFromB64(PUB_PEM_B64);
1263-
let pubSrc = pubPem ? "env-b64" : null;
1330+
let pubkeyBytes = PUB_KEY_TEXT ? parseEd25519PublicKeyText(PUB_KEY_TEXT) : null;
1331+
let pubSrc = pubkeyBytes ? "env" : null;
1332+
let resolvedKid = null;
12641333

12651334
if (wantEns) {
1266-
const ensOut = await fetchEnsPubkeyPem({ refresh });
1267-
if (ensOut.ok && ensOut.pem) {
1268-
pubPem = ensOut.pem;
1335+
const ensOut = await resolveSignatureKey(VERIFIER_ENS_NAME, { refresh });
1336+
if (ensOut.ok && ensOut.pubkeyBytes) {
1337+
pubkeyBytes = ensOut.pubkeyBytes;
1338+
resolvedKid = ensOut.kid;
12691339
pubSrc = "ens";
1270-
} else if (!pubPem) {
1340+
} else if (!pubkeyBytes) {
12711341
pubSrc = null;
12721342
}
12731343
}
12741344

12751345
let sigOk = false;
12761346
let sigErr = null;
12771347

1278-
if (pubPem) {
1348+
if (pubkeyBytes) {
12791349
try {
1280-
sigOk = verifyEd25519Base64(proof.hash_sha256, proof.signature_b64, pubPem);
1350+
sigOk = verifyEd25519Base64(proof.hash_sha256, proof.signature_b64, pubkeyBytes);
12811351
} catch (e) {
12821352
sigOk = false;
12831353
sigErr = e?.message || "signature verify failed";
12841354
}
12851355
} else {
12861356
sigOk = false;
1287-
sigErr = "no public key available (set RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64 or pass ens=1 with ETH_RPC_URL)";
1357+
sigErr = "no public key available (set RECEIPT_SIGNING_PUBLIC_KEY or pass ens=1 with ETH_RPC_URL)";
12881358
}
12891359

12901360
// Schema validation (edge-safe)
@@ -1314,6 +1384,7 @@ app.post("/verify", async (req, res) => {
13141384
claimed_hash: proof.hash_sha256 ?? null,
13151385
recomputed_hash: recomputed,
13161386
pubkey_source: pubSrc,
1387+
kid: resolvedKid,
13171388
},
13181389
errors: { schema_errors: schemaErrors, signature_error: sigErr },
13191390
retry_after_ms: 1000,
@@ -1348,6 +1419,7 @@ app.post("/verify", async (req, res) => {
13481419
claimed_hash: proof.hash_sha256 ?? null,
13491420
recomputed_hash: recomputed,
13501421
pubkey_source: pubSrc,
1422+
kid: resolvedKid,
13511423
},
13521424
errors: { schema_errors: schemaErrors, signature_error: sigErr },
13531425
});

tests/smoke.mjs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { spawn } from 'node:child_process';
33
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
44
import { tmpdir } from 'node:os';
55
import { join } from 'node:path';
6-
import { randomBytes } from 'node:crypto';
6+
import { createPublicKey, randomBytes } from 'node:crypto';
77
import { execFileSync } from 'node:child_process';
88

99
const PORT = 19080;
@@ -13,6 +13,13 @@ function b64File(path) {
1313
return readFileSync(path).toString('base64');
1414
}
1515

16+
function ed25519TxtFromPublicPem(path) {
17+
const pem = readFileSync(path, 'utf8');
18+
const der = createPublicKey(pem).export({ format: 'der', type: 'spki' });
19+
const raw = Buffer.from(der).subarray(-32);
20+
return `ed25519:${raw.toString('base64')}`;
21+
}
22+
1623
function sleep(ms) {
1724
return new Promise((resolve) => setTimeout(resolve, ms));
1825
}
@@ -41,7 +48,7 @@ try {
4148
...process.env,
4249
PORT: String(PORT),
4350
RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: b64File(priv),
44-
RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64: b64File(pub),
51+
RECEIPT_SIGNING_PUBLIC_KEY: ed25519TxtFromPublicPem(pub),
4552
RECEIPT_SIGNER_ID: 'runtime.test',
4653
DEBUG_ROUTES_ENABLED: '1',
4754
DEBUG_BEARER_TOKEN: 'secret-token',

0 commit comments

Comments
 (0)