109 lines
4.1 KiB
JavaScript
109 lines
4.1 KiB
JavaScript
|
|
/**
|
||
|
|
* 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 + '}';
|
||
|
|
}
|