From d49730d2d358351ee2c67f603b4d287aa1dab845 Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Sun, 15 Feb 2026 17:28:32 -0500 Subject: [PATCH] Switch ENS signature key resolution to cl.sig.* records --- README.md | 4 +- docs/CONFIGURATION.md | 6 +- docs/OPERATIONS.md | 10 +-- server.mjs | 142 +++++++++++++++++++++++++++++++----------- tests/smoke.mjs | 11 +++- 5 files changed, 128 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 8a0cdb3..652a485 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ 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_PEM_B64="$(base64 -w0 < public.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" ``` @@ -105,7 +105,7 @@ 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 record (`VERIFIER_ENS_NAME`, `ENS_PUBKEY_TEXT_KEY`). +- `ens=1` — fetch verifier pubkey from ENS TXT records (`VERIFIER_ENS_NAME`, `cl.receipt.signer`, `cl.sig.pub`, `cl.sig.kid`). - `refresh=1` — bypass ENS cache and refresh lookup. - `schema=1` — validate receipt against verb schema. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f239cc5..522a956 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -26,7 +26,7 @@ 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 | Optional local pubkey for `/verify` signature checks. | +| `RECEIPT_SIGNING_PUBLIC_KEY` | empty | Optional local verifier pubkey text in `ed25519:` format for `/verify`. | | `ENS_NAME` | empty | Optional identity alias fallback. | ## ENS-based verification @@ -35,7 +35,9 @@ Comma-separated list of enabled handlers. Disabled verbs return `404`. |---|---|---| | `ETH_RPC_URL` | empty | Ethereum RPC endpoint for ENS resolver lookups. | | `VERIFIER_ENS_NAME` | `ENS_NAME` / `RECEIPT_SIGNER_ID` fallback | ENS name queried for TXT pubkey value. | -| `ENS_PUBKEY_TEXT_KEY` | `cl.receipt.pubkey.pem` | ENS TXT key containing PEM-formatted public key. | +| `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:` public key. | +| `ENS_SIG_KID_TEXT_KEY` | `cl.sig.kid` | ENS TXT key on signer name containing key identifier. | ## Schema fetching + validation budgets diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 2624a6a..27c254d 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -4,14 +4,16 @@ 1. Set signing keys: - `RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64` - - `RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64` + - `RECEIPT_SIGNING_PUBLIC_KEY` 2. Set identity metadata: - `RECEIPT_SIGNER_ID` - `SERVICE_NAME`, `SERVICE_VERSION` 3. If using ENS verification: - `ETH_RPC_URL` - `VERIFIER_ENS_NAME` - - `ENS_PUBKEY_TEXT_KEY` + - `ENS_SIGNER_TEXT_KEY` + - `ENS_SIG_PUB_TEXT_KEY` + - `ENS_SIG_KID_TEXT_KEY` 4. Set safety limits (`FETCH_TIMEOUT_MS`, `FETCH_MAX_BYTES`, `VERIFY_MAX_MS`). 5. Restrict outbound domains with `ALLOW_FETCH_HOSTS` where possible. @@ -44,10 +46,10 @@ Repeat validator polling until required verbs appear under `cached`. ### `no public key available` -- Set `RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64` **or** use ENS verification with: +- Set `RECEIPT_SIGNING_PUBLIC_KEY` (`ed25519:`) **or** use ENS verification with: - `ETH_RPC_URL` - `VERIFIER_ENS_NAME` - - valid PEM at ENS TXT key. + - valid `cl.sig.pub` and `cl.sig.kid` TXT values on signer ENS name. ### `validator_not_warmed_yet` with HTTP 202 diff --git a/server.mjs b/server.mjs index b1b1939..bec4097 100644 --- a/server.mjs +++ b/server.mjs @@ -53,7 +53,7 @@ const ENABLED_VERBS = (process.env.ENABLED_VERBS || "fetch,describe,format,clean const SIGNER_ID = process.env.RECEIPT_SIGNER_ID || process.env.ENS_NAME || "runtime"; const PRIV_PEM_B64 = process.env.RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64 || ""; -const PUB_PEM_B64 = process.env.RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64 || ""; +const PUB_KEY_TEXT = process.env.RECEIPT_SIGNING_PUBLIC_KEY || ""; // ---- service identity / discovery const SERVICE_NAME = process.env.SERVICE_NAME || "commandlayer-runtime"; @@ -64,7 +64,9 @@ const API_VERSION = process.env.API_VERSION || "1.0.0"; // ENS verifier config const ETH_RPC_URL = process.env.ETH_RPC_URL || ""; const VERIFIER_ENS_NAME = process.env.VERIFIER_ENS_NAME || process.env.ENS_NAME || SIGNER_ID || ""; -const ENS_PUBKEY_TEXT_KEY = process.env.ENS_PUBKEY_TEXT_KEY || "cl.receipt.pubkey.pem"; +const ENS_SIG_PUB_TEXT_KEY = process.env.ENS_SIG_PUB_TEXT_KEY || "cl.sig.pub"; +const ENS_SIG_KID_TEXT_KEY = process.env.ENS_SIG_KID_TEXT_KEY || "cl.sig.kid"; +const ENS_SIGNER_TEXT_KEY = process.env.ENS_SIGNER_TEXT_KEY || "cl.receipt.signer"; // IMPORTANT: AJV should fetch schemas from www, but schemas' $id/refs may be commandlayer.org. // We normalize fetch URLs to https://www.commandlayer.org to avoid redirect/host mismatches. @@ -148,6 +150,38 @@ function normalizePem(text) { return pem.includes("BEGIN") ? pem : null; } +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +function parseEd25519PublicKeyText(text) { + if (typeof text !== "string") throw new Error("public key must be a string"); + const trimmed = text.trim(); + const idx = trimmed.indexOf(":"); + if (idx <= 0) throw new Error("invalid ed25519 public key format (expected ed25519:)"); + const alg = trimmed.slice(0, idx).toLowerCase(); + const payload = trimmed.slice(idx + 1).trim(); + if (alg !== "ed25519" || !payload) throw new Error("invalid ed25519 public key format (expected ed25519:)"); + + let bytes; + try { + bytes = Buffer.from(payload, "base64"); + } catch { + throw new Error("invalid base64 in ed25519 public key"); + } + if (!bytes.length || bytes.toString("base64") !== payload) { + throw new Error("invalid base64 in ed25519 public key"); + } + if (bytes.length !== 32) throw new Error("invalid ed25519 public key length (expected 32 bytes)"); + return bytes; +} + +function ed25519PublicKeyObject(pubkeyBytes) { + if (!Buffer.isBuffer(pubkeyBytes) || pubkeyBytes.length !== 32) { + throw new Error("invalid ed25519 public key bytes"); + } + const spki = Buffer.concat([ED25519_SPKI_PREFIX, pubkeyBytes]); + return crypto.createPublicKey({ key: spki, format: "der", type: "spki" }); +} + function signEd25519Base64(messageUtf8) { const pem = pemFromB64(PRIV_PEM_B64); if (!pem) throw new Error("Missing RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64"); @@ -156,8 +190,8 @@ function signEd25519Base64(messageUtf8) { return sig.toString("base64"); } -function verifyEd25519Base64(messageUtf8, signatureB64, pubPem) { - const key = crypto.createPublicKey(pubPem); +function verifyEd25519Base64(messageUtf8, signatureB64, pubkeyBytes) { + const key = ed25519PublicKeyObject(pubkeyBytes); return crypto.verify(null, Buffer.from(messageUtf8, "utf8"), key, Buffer.from(signatureB64, "base64")); } @@ -255,7 +289,9 @@ async function ssrfGuardOrThrow(urlStr) { let ensCache = { fetched_at: 0, ttl_ms: 10 * 60 * 1000, - pem: null, + pubkeyBytes: null, + kid: null, + signer: null, error: null, source: null, }; @@ -269,31 +305,55 @@ async function withTimeout(promise, ms, label = "timeout") { return await Promise.race([promise, new Promise((_, rej) => setTimeout(() => rej(new Error(label)), ms))]); } -async function fetchEnsPubkeyPem({ refresh = false } = {}) { +async function resolveSignatureKey(name, { refresh = false } = {}) { const now = Date.now(); - if (!refresh && ensCache.pem && now - ensCache.fetched_at < ensCache.ttl_ms) { - return { ok: true, pem: ensCache.pem, source: ensCache.source, cache: { ...ensCache } }; + if (!refresh && ensCache.pubkeyBytes && now - ensCache.fetched_at < ensCache.ttl_ms) { + return { + ok: true, + pubkeyBytes: ensCache.pubkeyBytes, + kid: ensCache.kid, + signer: ensCache.signer, + source: ensCache.source, + cache: { ...ensCache }, + }; } - if (!VERIFIER_ENS_NAME) { - ensCache = { ...ensCache, fetched_at: now, pem: null, error: "Missing VERIFIER_ENS_NAME", source: null }; - return { ok: false, pem: null, source: null, error: ensCache.error, cache: { ...ensCache } }; + if (!name) { + ensCache = { ...ensCache, fetched_at: now, pubkeyBytes: null, kid: null, signer: null, error: "Missing ENS name", source: null }; + return { ok: false, pubkeyBytes: null, kid: null, signer: null, source: null, error: ensCache.error, cache: { ...ensCache } }; } if (!ETH_RPC_URL) { - ensCache = { ...ensCache, fetched_at: now, pem: null, error: "Missing ETH_RPC_URL", source: null }; - return { ok: false, pem: null, source: null, error: ensCache.error, cache: { ...ensCache } }; + ensCache = { + ...ensCache, + fetched_at: now, + pubkeyBytes: null, + kid: null, + signer: null, + error: "Missing ETH_RPC_URL", + source: null, + }; + return { ok: false, pubkeyBytes: null, kid: null, signer: null, source: null, error: ensCache.error, cache: { ...ensCache } }; } try { const provider = new ethers.JsonRpcProvider(ETH_RPC_URL); - const resolver = await withTimeout(provider.getResolver(VERIFIER_ENS_NAME), 6000, "ens_resolver_timeout"); + const resolver = await withTimeout(provider.getResolver(name), 6000, "ens_resolver_timeout"); if (!resolver) throw new Error("No resolver for ENS name"); - const txt = await withTimeout(resolver.getText(ENS_PUBKEY_TEXT_KEY), 6000, "ens_text_timeout"); - const pem = normalizePem(txt); - if (!pem) throw new Error(`ENS text ${ENS_PUBKEY_TEXT_KEY} missing/invalid PEM`); - ensCache = { ...ensCache, fetched_at: now, pem, error: null, source: "ens" }; - return { ok: true, pem, source: "ens", cache: { ...ensCache } }; + + const signerTxt = await withTimeout(resolver.getText(ENS_SIGNER_TEXT_KEY), 6000, "ens_signer_text_timeout"); + const signer = String(signerTxt || "").trim() || name; + const signerResolver = signer === name ? resolver : await withTimeout(provider.getResolver(signer), 6000, "ens_signer_resolver_timeout"); + if (!signerResolver) throw new Error("No resolver for signer ENS name"); + + const pubTxt = await withTimeout(signerResolver.getText(ENS_SIG_PUB_TEXT_KEY), 6000, "ens_pub_text_timeout"); + const kidTxt = await withTimeout(signerResolver.getText(ENS_SIG_KID_TEXT_KEY), 6000, "ens_kid_text_timeout"); + const pubkeyBytes = parseEd25519PublicKeyText(String(pubTxt || "")); + const kid = String(kidTxt || "").trim(); + if (!kid) throw new Error(`ENS text ${ENS_SIG_KID_TEXT_KEY} missing/invalid`); + + ensCache = { ...ensCache, fetched_at: now, pubkeyBytes, kid, signer, error: null, source: "ens" }; + return { ok: true, pubkeyBytes, kid, signer, source: "ens", cache: { ...ensCache } }; } catch (e) { - ensCache = { ...ensCache, fetched_at: now, pem: null, error: e?.message || "ens fetch failed", source: null }; - return { ok: false, pem: null, source: null, error: ensCache.error, cache: { ...ensCache } }; + ensCache = { ...ensCache, fetched_at: now, pubkeyBytes: null, kid: null, signer: null, error: e?.message || "ens fetch failed", source: null }; + return { ok: false, pubkeyBytes: null, kid: null, signer: null, source: null, error: ensCache.error, cache: { ...ensCache } }; } } @@ -1071,9 +1131,11 @@ app.get("/debug/env", (req, res) => { signer_id: SIGNER_ID, signer_ok: !!pemFromB64(PRIV_PEM_B64), has_priv_b64: !!PRIV_PEM_B64, - has_pub_b64: !!PUB_PEM_B64, + has_pubkey_text: !!PUB_KEY_TEXT, verifier_ens_name: VERIFIER_ENS_NAME || null, - ens_pubkey_text_key: ENS_PUBKEY_TEXT_KEY, + ens_sig_pub_text_key: ENS_SIG_PUB_TEXT_KEY, + ens_sig_kid_text_key: ENS_SIG_KID_TEXT_KEY, + ens_signer_text_key: ENS_SIGNER_TEXT_KEY, has_rpc: hasRpc(), schema_host: SCHEMA_HOST, schema_fetch_timeout_ms: SCHEMA_FETCH_TIMEOUT_MS, @@ -1115,14 +1177,20 @@ app.get("/debug/env", (req, res) => { app.get("/debug/enskey", async (req, res) => { if (!requireDebugAccess(req, res)) return; const refresh = String(req.query.refresh || "0") === "1"; - const out = await fetchEnsPubkeyPem({ refresh }); + const out = await resolveSignatureKey(VERIFIER_ENS_NAME, { refresh }); res.json({ ok: !!out.ok, pubkey_source: out.source || null, ens_name: VERIFIER_ENS_NAME || null, - txt_key: ENS_PUBKEY_TEXT_KEY, + signer: out.signer || null, + txt_keys: { + signer: ENS_SIGNER_TEXT_KEY, + pub: ENS_SIG_PUB_TEXT_KEY, + kid: ENS_SIG_KID_TEXT_KEY, + }, + kid: out.kid || null, cache: out.cache ? { fetched_at: new Date(out.cache.fetched_at).toISOString(), ttl_ms: out.cache.ttl_ms } : null, - preview: out.pem ? out.pem.slice(0, 80) + "..." : null, + preview: out.pubkeyBytes ? `ed25519:${out.pubkeyBytes.toString("base64").slice(0, 24)}...` : null, error: out.error || null, }); }); @@ -1259,15 +1327,17 @@ app.post("/verify", async (req, res) => { const hashMatches = recomputed === proof.hash_sha256; - let pubPem = pemFromB64(PUB_PEM_B64); - let pubSrc = pubPem ? "env-b64" : null; + let pubkeyBytes = PUB_KEY_TEXT ? parseEd25519PublicKeyText(PUB_KEY_TEXT) : null; + let pubSrc = pubkeyBytes ? "env" : null; + let resolvedKid = null; if (wantEns) { - const ensOut = await fetchEnsPubkeyPem({ refresh }); - if (ensOut.ok && ensOut.pem) { - pubPem = ensOut.pem; + const ensOut = await resolveSignatureKey(VERIFIER_ENS_NAME, { refresh }); + if (ensOut.ok && ensOut.pubkeyBytes) { + pubkeyBytes = ensOut.pubkeyBytes; + resolvedKid = ensOut.kid; pubSrc = "ens"; - } else if (!pubPem) { + } else if (!pubkeyBytes) { pubSrc = null; } } @@ -1275,16 +1345,16 @@ app.post("/verify", async (req, res) => { let sigOk = false; let sigErr = null; - if (pubPem) { + if (pubkeyBytes) { try { - sigOk = verifyEd25519Base64(proof.hash_sha256, proof.signature_b64, pubPem); + sigOk = verifyEd25519Base64(proof.hash_sha256, proof.signature_b64, pubkeyBytes); } catch (e) { sigOk = false; sigErr = e?.message || "signature verify failed"; } } else { sigOk = false; - sigErr = "no public key available (set RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64 or pass ens=1 with ETH_RPC_URL)"; + sigErr = "no public key available (set RECEIPT_SIGNING_PUBLIC_KEY or pass ens=1 with ETH_RPC_URL)"; } // Schema validation (edge-safe) @@ -1314,6 +1384,7 @@ app.post("/verify", async (req, res) => { claimed_hash: proof.hash_sha256 ?? null, recomputed_hash: recomputed, pubkey_source: pubSrc, + kid: resolvedKid, }, errors: { schema_errors: schemaErrors, signature_error: sigErr }, retry_after_ms: 1000, @@ -1348,6 +1419,7 @@ app.post("/verify", async (req, res) => { claimed_hash: proof.hash_sha256 ?? null, recomputed_hash: recomputed, pubkey_source: pubSrc, + kid: resolvedKid, }, errors: { schema_errors: schemaErrors, signature_error: sigErr }, }); diff --git a/tests/smoke.mjs b/tests/smoke.mjs index cf5e3d8..beca9e7 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -3,7 +3,7 @@ import { spawn } from 'node:child_process'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { randomBytes } from 'node:crypto'; +import { createPublicKey, randomBytes } from 'node:crypto'; import { execFileSync } from 'node:child_process'; const PORT = 19080; @@ -13,6 +13,13 @@ function b64File(path) { return readFileSync(path).toString('base64'); } +function ed25519TxtFromPublicPem(path) { + const pem = readFileSync(path, 'utf8'); + const der = createPublicKey(pem).export({ format: 'der', type: 'spki' }); + const raw = Buffer.from(der).subarray(-32); + return `ed25519:${raw.toString('base64')}`; +} + function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -41,7 +48,7 @@ try { ...process.env, PORT: String(PORT), RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: b64File(priv), - RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64: b64File(pub), + RECEIPT_SIGNING_PUBLIC_KEY: ed25519TxtFromPublicPem(pub), RECEIPT_SIGNER_ID: 'runtime.test', DEBUG_ROUTES_ENABLED: '1', DEBUG_BEARER_TOKEN: 'secret-token',