prometheus/service/signing.js
Local Dev c4566b4ea3 prometheus: upload + ClamAV scan + signed scan reports
POST /api/upload accepts a multipart file, spools it to a temp path while
hashing to SHA-256, hands the on-disk copy to clamd via INSTREAM, keeps
the file at uploads/<sha256>.bin on a clean verdict and discards it on
anything else. The response carries the (hash, size, verdict) tuple
signed by the service's ed25519 key — the artifact the manifest step
will attach to the APS1 record later.

For local dev on a box without ClamAV, MOCK_SCAN=clean|infected|error
short-circuits the TCP call while still draining the bytes end-to-end.
CLAMD_HOST / CLAMD_PORT / PROMETHEUS_MAX_UPLOAD_BYTES override the
defaults (127.0.0.1:3310, 2 GiB).

Service key lives at .keys/service.key (gitignored, 0600), generated on
first start if absent. The matching public key is served at
/.well-known/prometheus-service-key.json so clients can cache it per
origin. ed25519 chosen over BIP-137 because the scan report is a service
attestation, not a Bitcoin thing — the key never signs value, never
signs for a user, never touches the chain.

Canonicalisation walks the payload recursively (sorted keys at every
level, no whitespace). The first draft used JSON.stringify with a keys
array, which only serialises top-level keys from the array — nested
objects came out as {}, so the signature verified nothing useful.

/healthz pings clamd (or the mock) so monitors can tell when the scanner
is down.

The /submit page gets a working upload form that drives the endpoint
and renders the full signed response, so the whole chain is testable in
the browser as well as via curl.

One dependency added: busboy. Rolling our own multipart parser was the
obvious alternative; busboy is one small, maintained library with no
runtime deps of its own, and multipart RFC 7578 edge cases are not
places this project earns anything by rediscovering.
2026-10-05 22:41:55 +02:00

108 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 + '}';
}