-
Notifications
You must be signed in to change notification settings - Fork 0
Resolve ENS signature keys from cl.sig.* and use ed25519:<base64> format #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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:<base64>)"); | ||
| 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:<base64>)"); | ||
|
|
||
| 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,32 +1327,34 @@ 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| 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; | ||
| } | ||
| } | ||
|
|
||
| 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 }, | ||
| }); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The validation requires
bytes.toString("base64") === payload, which rejects otherwise valid unpadded base64 encodings (same 32-byte key, missing trailing=) that are commonly produced by tooling and manual ENS/env configuration. As written, valided25519:<base64>values can be rejected withinvalid base64, causing unnecessary verification failures.Useful? React with 👍 / 👎.