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.
108 lines
4.1 KiB
JavaScript
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 + '}';
|
|
}
|