/** * Service key — the identity that signs scan reports. * * This is NOT the publisher's BNS key and NOT the operator's BCH wallet. It * is the service's own identity, used to attest "Prometheus's scanner saw * this file and this is what it reported." Readers' clients verify the * signature against the service's public key (published out of band — see * `/.well-known/prometheus-service-key.json`) to tell a report that came * from this service apart from one somebody fabricated. * * ed25519 was chosen over secp256k1 BIP-137 (the BCH convention) because the * scan report isn't a Bitcoin thing. The key never signs value, never signs * on behalf of a user, and never touches the chain. ed25519 keeps the surface * small and the signatures short. * * The private key lives at `KEY_DIR/service.key` (defaults to a directory * next to the service). It is generated on first start if absent. The matching * public key is served alongside signed reports and at * `/.well-known/prometheus-service-key.json` so clients can cache it. */ import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as nodeSign } from 'node:crypto'; import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const KEY_DIR = process.env.PROMETHEUS_KEY_DIR || resolve(dirname(fileURLToPath(import.meta.url)), '..', '.keys'); const KEY_PATH = resolve(KEY_DIR, 'service.key'); let cachedPrivate = null; let cachedPublic = null; let cachedPublicBase64 = null; /** * Load (or generate) the service key. Idempotent. */ export function loadServiceKey() { if (cachedPrivate) return; if (!existsSync(KEY_PATH)) { mkdirSync(KEY_DIR, { recursive: true }); const { privateKey } = generateKeyPairSync('ed25519'); const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }); // Restrictive mode intentional — the file is a secret. writeFileSync(KEY_PATH, pem, { mode: 0o600 }); console.log(`[prometheus] generated new service key at ${KEY_PATH}`); } const pem = readFileSync(KEY_PATH, 'utf8'); cachedPrivate = createPrivateKey(pem); cachedPublic = createPublicKey(cachedPrivate); // Raw 32-byte Ed25519 public key, base64url-encoded. Matches the shape // the client-side verifier wants. const der = cachedPublic.export({ type: 'spki', format: 'der' }); // The 32 bytes of the key sit at the end of the DER SPKI envelope. const raw = der.subarray(der.length - 32); cachedPublicBase64 = raw.toString('base64url'); } /** * The service's public key, base64url-encoded (raw 32-byte Ed25519). */ export function publicKeyBase64() { loadServiceKey(); return cachedPublicBase64; } /** * Sign a scan-report payload. The payload is a JSON object; we canonicalise * it (keys sorted at every level, no whitespace) before signing so verifiers * can reproduce the signed bytes without ambiguity. * * @param {object} payload * @returns {{alg: 'ed25519', payload: string, signature: string, pubkey: string}} */ export function signReport(payload) { loadServiceKey(); const canonical = canonicalJson(payload); const signature = nodeSign(null, Buffer.from(canonical, 'utf8'), cachedPrivate); return { alg: 'ed25519', payload: Buffer.from(canonical, 'utf8').toString('base64url'), signature: signature.toString('base64url'), pubkey: cachedPublicBase64, }; } /** * JSON with keys sorted at every level and no whitespace. Equivalent to RFC * 8785's "JCS" shape for the subset we use (plain data: null / boolean / * number / string / array / object). We don't carry anything exotic (dates * are ISO strings before this is called, numbers are integers), so a short * walker beats a 500 KB dependency. */ export function canonicalJson(value) { if (value === null || typeof value !== 'object') { return JSON.stringify(value); } if (Array.isArray(value)) { return '[' + value.map(canonicalJson).join(',') + ']'; } const keys = Object.keys(value).sort(); const body = keys .map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k])) .join(','); return '{' + body + '}'; }