Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
```

Expand Down Expand Up @@ -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.

Expand Down
6 changes: 4 additions & 2 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<base64>` format for `/verify`. |
| `ENS_NAME` | empty | Optional identity alias fallback. |

## ENS-based verification
Expand All @@ -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:<base64>` public key. |
| `ENS_SIG_KID_TEXT_KEY` | `cl.sig.kid` | ENS TXT key on signer name containing key identifier. |

## Schema fetching + validation budgets

Expand Down
10 changes: 6 additions & 4 deletions docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:<base64>`) **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

Expand Down
142 changes: 107 additions & 35 deletions server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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.
Expand Down Expand Up @@ -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");
Comment on lines +170 to +171

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept unpadded base64 in ed25519 public key text

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, valid ed25519:<base64> values can be rejected with invalid base64, causing unnecessary verification failures.

Useful? React with 👍 / 👎.

}
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");
Expand All @@ -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"));
}

Expand Down Expand Up @@ -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,
};
Expand All @@ -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 } };
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
});
Expand Down Expand Up @@ -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;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve ENS fallback when local key text is invalid

/verify now parses RECEIPT_SIGNING_PUBLIC_KEY before attempting ENS lookup, so any malformed local key throws and returns a 500 even when ens=1 and ENS has a valid signature key. This regresses the prior behavior where a bad local key could be ignored and ENS-based verification could still succeed, so one misconfigured optional env var can take verification down in deployments that rely on ENS.

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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
});
Expand Down
11 changes: 9 additions & 2 deletions tests/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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));
}
Expand Down Expand Up @@ -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',
Expand Down