@@ -53,7 +53,7 @@ const ENABLED_VERBS = (process.env.ENABLED_VERBS || "fetch,describe,format,clean
5353
5454const SIGNER_ID = process . env . RECEIPT_SIGNER_ID || process . env . ENS_NAME || "runtime" ;
5555const 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
5959const 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
6565const ETH_RPC_URL = process . env . ETH_RPC_URL || "" ;
6666const 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+
151185function 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) {
255289let 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) => {
11151177app . 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 } ) ;
0 commit comments