| 1 | #!/usr/bin/env node |
| 2 | /** |
| 3 | * CodeWhale cloud facts (facts/v1) authoring tool. Zero npm dependencies. |
| 4 | * |
| 5 | * node scripts/facts-publish.mjs keygen --key-id cwf-2026-09 --out /secure/path.key |
| 6 | * node scripts/facts-publish.mjs sign --source ../docs/cloud-facts/stable.json --channel stable \ |
| 7 | * --key-id cwf-2026-09 [--facts-version N] [--out envelope.json] |
| 8 | * node scripts/facts-publish.mjs verify envelope.json [--public-key <base64>] |
| 9 | * node scripts/facts-publish.mjs emit-sql envelope.json [--published-by who] [--public-key <base64>] |
| 10 | * node scripts/facts-publish.mjs publish envelope.json [--dry-run] [--published-by who] |
| 11 | * node scripts/facts-publish.mjs revoke --channel stable --version N --reason "..." [--dry-run] |
| 12 | * |
| 13 | * Secrets are read ONLY from the environment at sign/publish time and are never |
| 14 | * printed: |
| 15 | * CODEWHALE_FACTS_SIGNING_KEY PEM (PKCS#8) Ed25519 private key contents |
| 16 | * CODEWHALE_FACTS_SIGNING_KEY_FILE path to that PEM (alternative) |
| 17 | * SUPABASE_URL https://<ref>.supabase.co (publish/revoke) |
| 18 | * SUPABASE_SERVICE_ROLE_KEY service-role key (publish/revoke only; never embed) |
| 19 | * |
| 20 | * Signing contract (must match crates/config/src/cloud_facts/verify.rs and |
| 21 | * web/lib/cloud-facts.ts): Ed25519 detached signature over |
| 22 | * "codewhale-facts/v1\0" || key_id || "\0" || payload_bytes |
| 23 | * where payload_bytes is canonical JSON (sorted keys, no whitespace, UTF-8). |
| 24 | * Clients verify the exact bytes carried in payload_b64; they never re-canonicalize. |
| 25 | */ |
| 26 | import { createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify, createHash } from "node:crypto"; |
| 27 | import { readFileSync, writeFileSync, mkdirSync, openSync, closeSync, readSync, fstatSync, lstatSync, constants } from "node:fs"; |
| 28 | import { dirname, resolve } from "node:path"; |
| 29 | import { fileURLToPath } from "node:url"; |
| 30 | |
| 31 | export const DOMAIN = "codewhale-facts/v1\0"; |
| 32 | export const ENVELOPE_VERSION = 1; |
| 33 | export const SCHEMA_VERSION = 1; |
| 34 | export const MAX_PAYLOAD_BYTES = 512 * 1024; |
| 35 | export const MAX_ENVELOPE_BYTES = 768 * 1024; |
| 36 | const KEY_ID_RE = /^cwf-[a-z0-9-]{1,32}$/; |
| 37 | const CHANNEL_RE = /^[a-z0-9][a-z0-9-]{0,31}$/; |
| 38 | const CI_MARKERS = ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "CIRCLECI", "JENKINS_URL", "TF_BUILD"]; |
| 39 | |
| 40 | const here = dirname(fileURLToPath(import.meta.url)); |
| 41 | const WEB_ROOT = resolve(here, ".."); |
| 42 | const REPO_ROOT = resolve(WEB_ROOT, ".."); |
| 43 | |
| 44 | // --------------------------------------------------------------------------- |
| 45 | // Canonical JSON + signing primitives (exported for tests) |
| 46 | // --------------------------------------------------------------------------- |
| 47 | |
| 48 | export function canonicalize(value) { |
| 49 | if (value === null || typeof value !== "object") { |
| 50 | if (typeof value === "number" && !Number.isFinite(value)) { |
| 51 | throw new Error("non-finite number in payload"); |
| 52 | } |
| 53 | return JSON.stringify(value); |
| 54 | } |
| 55 | if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`; |
| 56 | const keys = Object.keys(value).sort(); |
| 57 | const parts = []; |
| 58 | for (const key of keys) { |
| 59 | const v = value[key]; |
| 60 | if (v === undefined) continue; |
| 61 | parts.push(`${JSON.stringify(key)}:${canonicalize(v)}`); |
| 62 | } |
| 63 | return `{${parts.join(",")}}`; |
| 64 | } |
| 65 | |
| 66 | export function signingMessage(keyId, payloadBytes) { |
| 67 | return Buffer.concat([Buffer.from(DOMAIN, "utf8"), Buffer.from(keyId, "utf8"), Buffer.from([0]), payloadBytes]); |
| 68 | } |
| 69 | |
| 70 | export function rawPublicKeyFromKeyObject(keyObject) { |
| 71 | const spki = keyObject.export({ type: "spki", format: "der" }); |
| 72 | // Ed25519 SPKI DER is a fixed 12-byte prefix followed by the 32-byte key. |
| 73 | return spki.subarray(spki.length - 32); |
| 74 | } |
| 75 | |
| 76 | export function publicKeyObjectFromRaw(rawB64) { |
| 77 | const raw = strictBase64(rawB64, 32); |
| 78 | if (raw.length !== 32) throw new Error("public key must decode to 32 bytes"); |
| 79 | const prefix = Buffer.from("302a300506032b6570032100", "hex"); |
| 80 | return createPublicKey({ key: Buffer.concat([prefix, raw]), type: "spki", format: "der" }); |
| 81 | } |
| 82 | |
| 83 | export function signPayload(privateKey, keyId, payloadBytes) { |
| 84 | return sign(null, signingMessage(keyId, payloadBytes), privateKey); |
| 85 | } |
| 86 | |
| 87 | /** Canonical base64 is checked before decoding to bound allocation. */ |
| 88 | export function strictBase64(value, maxBytes) { |
| 89 | if (typeof value !== "string" || !value.length || value.length > 4 * Math.ceil(maxBytes / 3) || |
| 90 | (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value))) throw new Error("invalid base64"); |
| 91 | const bytes = Buffer.from(value, "base64"); |
| 92 | if (bytes.length > maxBytes || bytes.toString("base64") !== value) throw new Error("invalid base64"); |
| 93 | return bytes; |
| 94 | } |
| 95 | |
| 96 | export function utcTime(value) { |
| 97 | if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value)) return null; |
| 98 | const time = Date.parse(value); |
| 99 | return Number.isFinite(time) && new Date(time).toISOString().slice(0, 19) === value.slice(0, 19) ? time : null; |
| 100 | } |
| 101 | |
| 102 | export function verifyEnvelope(envelope, publicKeyB64) { |
| 103 | const errors = []; |
| 104 | if (!isPlainObject(envelope)) return { ok: false, errors: ["envelope must be an object"] }; |
| 105 | if (envelope.envelope !== ENVELOPE_VERSION) errors.push("unsupported envelope version"); |
| 106 | if (envelope.alg !== "ed25519") errors.push("unsupported signature algorithm"); |
| 107 | if (typeof envelope.key_id !== "string" || !KEY_ID_RE.test(envelope.key_id)) errors.push("bad key_id"); |
| 108 | if (envelope.schema_version !== SCHEMA_VERSION) errors.push("unsupported schema version"); |
| 109 | if (!Number.isSafeInteger(envelope.facts_version) || envelope.facts_version <= 0) errors.push("facts_version must be a positive safe integer"); |
| 110 | if (typeof envelope.channel !== "string" || !CHANNEL_RE.test(envelope.channel)) errors.push("bad channel"); |
| 111 | if (typeof envelope.applies_to !== "string" || envelope.applies_to.length > 200 || !VERSION_REQ_RE.test(envelope.applies_to)) errors.push("bad applies_to"); |
| 112 | if (utcTime(envelope.published_at) === null || (envelope.not_after != null && utcTime(envelope.not_after) === null)) errors.push("bad timestamp"); |
| 113 | if (typeof envelope.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(envelope.sha256)) errors.push("bad sha256"); |
| 114 | if (!Array.isArray(envelope.sigs) || envelope.sigs.length > 7) errors.push("bad extra signatures"); |
| 115 | else for (const candidate of envelope.sigs) { |
| 116 | if (!isPlainObject(candidate) || typeof candidate.key_id !== "string" || !KEY_ID_RE.test(candidate.key_id)) { errors.push("bad extra signature"); continue; } |
| 117 | try { if (strictBase64(candidate.sig_b64, 64).length !== 64) errors.push("bad extra signature size"); } |
| 118 | catch { errors.push("bad extra signature encoding"); } |
| 119 | } |
| 120 | if (errors.length) return { ok: false, errors }; |
| 121 | let payloadBytes, sig, key; |
| 122 | try { |
| 123 | payloadBytes = strictBase64(envelope.payload_b64, MAX_PAYLOAD_BYTES); |
| 124 | sig = strictBase64(envelope.sig_b64, 64); |
| 125 | if (sig.length !== 64) throw new Error("bad signature size"); |
| 126 | key = publicKeyObjectFromRaw(publicKeyB64); |
| 127 | } catch { return { ok: false, errors: ["invalid payload, signature or public key encoding"] }; } |
| 128 | const sha = createHash("sha256").update(payloadBytes).digest("hex"); |
| 129 | if (envelope.sha256 !== sha) return { ok: false, errors: ["sha256 mismatch"] }; |
| 130 | if (!verify(null, signingMessage(envelope.key_id, payloadBytes), key, sig)) return { ok: false, errors: ["bad signature"] }; |
| 131 | let payload; |
| 132 | try { payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(payloadBytes)); } |
| 133 | catch { return { ok: false, errors: ["payload is not UTF-8 JSON"] }; } |
| 134 | errors.push(...validateSource(payload)); |
| 135 | for (const field of ["channel", "facts_version", "applies_to", "schema_version", "published_at"]) { |
| 136 | if (envelope[field] !== payload?.[field]) errors.push(`outer ${field} differs from signed payload`); |
| 137 | } |
| 138 | if ((envelope.not_after ?? null) !== (payload?.not_after ?? null)) errors.push("outer not_after differs from signed payload"); |
| 139 | if (payload?.not_after != null && utcTime(payload.not_after) <= utcTime(payload.published_at)) errors.push("not_after must follow published_at"); |
| 140 | return errors.length ? { ok: false, errors } : { ok: true, errors: [], payload, sha256: sha }; |
| 141 | } |
| 142 | |
| 143 | // --------------------------------------------------------------------------- |
| 144 | // Source validation (docs/cloud-facts/<channel>.json) |
| 145 | // --------------------------------------------------------------------------- |
| 146 | |
| 147 | const MODEL_OPS = new Set(["upsert", "deprecate", "hide"]); |
| 148 | const LEVELS = new Set(["info", "warn"]); |
| 149 | const SURFACES = new Set(["tui", "desktop", "web"]); |
| 150 | const VERSION_REQ_RE = /^(\*|(?:>=|<=|>|<|=|\^|~)?\s*\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?(\s*,\s*(?:>=|<=|>|<|=|\^|~)?\s*\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?)*)$/; |
| 151 | |
| 152 | function isPlainObject(v) { |
| 153 | return v !== null && typeof v === "object" && !Array.isArray(v); |
| 154 | } |
| 155 | |
| 156 | function optString(errors, where, obj, key, max = 500) { |
| 157 | const v = obj[key]; |
| 158 | if (v === undefined || v === null) return; |
| 159 | if (typeof v !== "string" || v.length > max) errors.push(`${where}.${key} must be a string (<= ${max} chars)`); |
| 160 | } |
| 161 | |
| 162 | function optVersionReq(errors, where, obj, key = "applies_to") { |
| 163 | const v = obj[key]; |
| 164 | if (v === undefined || v === null) return; |
| 165 | if (typeof v !== "string" || v.length > 200 || !VERSION_REQ_RE.test(v.trim())) errors.push(`${where}.${key} is not a semver requirement: ${JSON.stringify(v)}`); |
| 166 | } |
| 167 | |
| 168 | export function validateSource(source) { |
| 169 | const errors = []; |
| 170 | if (!isPlainObject(source)) return ["source must be an object"]; |
| 171 | if (source.schema_version !== undefined && source.schema_version !== SCHEMA_VERSION) { |
| 172 | errors.push(`schema_version must be ${SCHEMA_VERSION}`); |
| 173 | } |
| 174 | if (source.channel !== undefined && !CHANNEL_RE.test(String(source.channel))) errors.push("channel slug invalid"); |
| 175 | if (source.facts_version !== undefined && !(Number.isSafeInteger(source.facts_version) && source.facts_version > 0)) { |
| 176 | errors.push("facts_version must be a positive integer"); |
| 177 | } |
| 178 | optVersionReq(errors, "root", source); |
| 179 | optString(errors, "root", source, "not_after", 40); |
| 180 | for (const field of ["published_at", "not_after"]) { |
| 181 | if (source[field] != null && utcTime(source[field]) === null) errors.push(`${field} must be a valid UTC timestamp`); |
| 182 | } |
| 183 | const models = source.models ?? []; |
| 184 | if (!Array.isArray(models)) errors.push("models must be an array"); |
| 185 | else { |
| 186 | models.forEach((m, i) => { |
| 187 | const where = `models[${i}]`; |
| 188 | if (!isPlainObject(m)) return errors.push(`${where} must be an object`); |
| 189 | if (typeof m.provider !== "string" || !m.provider) errors.push(`${where}.provider required`); |
| 190 | if (typeof m.id !== "string" || !m.id) errors.push(`${where}.id required`); |
| 191 | if (m.op !== undefined && !MODEL_OPS.has(m.op)) errors.push(`${where}.op must be one of ${[...MODEL_OPS].join("/")}`); |
| 192 | for (const k of ["context_window", "max_output"]) { |
| 193 | if (m[k] !== undefined && !(Number.isSafeInteger(m[k]) && m[k] > 0)) errors.push(`${where}.${k} must be a positive integer`); |
| 194 | } |
| 195 | if (m.pricing !== undefined) { |
| 196 | if (!isPlainObject(m.pricing)) errors.push(`${where}.pricing must be an object`); |
| 197 | else for (const k of Object.keys(m.pricing)) { |
| 198 | if (!["input_per_m", "output_per_m", "cache_read_per_m"].includes(k)) errors.push(`${where}.pricing.${k} unknown`); |
| 199 | else if (typeof m.pricing[k] !== "number" || !Number.isFinite(m.pricing[k]) || m.pricing[k] < 0) errors.push(`${where}.pricing.${k} must be a non-negative number`); |
| 200 | } |
| 201 | } |
| 202 | if (m.reasoning !== undefined && typeof m.reasoning !== "boolean") errors.push(`${where}.reasoning must be boolean`); |
| 203 | // Additive field: older clients deserialize it as false and keep provider |
| 204 | // roster dominance, so an unsigned or unaware reader loses nothing. |
| 205 | if (m.allow_unlisted !== undefined) { |
| 206 | if (typeof m.allow_unlisted !== "boolean") errors.push(`${where}.allow_unlisted must be boolean`); |
| 207 | else if (m.allow_unlisted) { |
| 208 | if (m.op !== undefined && m.op !== "upsert") errors.push(`${where}.allow_unlisted requires op upsert`); |
| 209 | // The client drops the assertion in a payload that cannot expire. |
| 210 | if (!source.not_after) errors.push(`${where}.allow_unlisted requires a payload not_after`); |
| 211 | } |
| 212 | } |
| 213 | optString(errors, where, m, "display_name", 120); |
| 214 | optString(errors, where, m, "deprecated_at", 40); |
| 215 | optString(errors, where, m, "replacement", 200); |
| 216 | optString(errors, where, m, "note", 300); |
| 217 | optVersionReq(errors, where, m); |
| 218 | }); |
| 219 | } |
| 220 | const defaults = source.provider_defaults ?? {}; |
| 221 | if (!isPlainObject(defaults)) errors.push("provider_defaults must be an object"); |
| 222 | else for (const [provider, d] of Object.entries(defaults)) { |
| 223 | const where = `provider_defaults.${provider}`; |
| 224 | if (!isPlainObject(d)) { errors.push(`${where} must be an object`); continue; } |
| 225 | optString(errors, where, d, "default_model", 200); |
| 226 | optString(errors, where, d, "base_url", 300); |
| 227 | if (d.base_url !== undefined) { |
| 228 | try { |
| 229 | const url = new URL(d.base_url); |
| 230 | if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || /[\\\s]/.test(d.base_url)) throw new Error(); |
| 231 | } catch { errors.push(`${where}.base_url must be an unambiguous credential-free https URL`); } |
| 232 | } |
| 233 | optVersionReq(errors, where, d); |
| 234 | } |
| 235 | if (source.release !== undefined && source.release !== null) { |
| 236 | const r = source.release; |
| 237 | const where = "release"; |
| 238 | if (!isPlainObject(r)) errors.push("release must be an object"); |
| 239 | else { |
| 240 | if (typeof r.latest !== "string" || !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(r.latest)) errors.push("release.latest must be a semver version"); |
| 241 | if (r.yanked !== undefined && !(Array.isArray(r.yanked) && r.yanked.every((v) => typeof v === "string"))) errors.push("release.yanked must be a string array"); |
| 242 | optString(errors, where, r, "min_supported", 40); |
| 243 | optString(errors, where, r, "notice", 300); |
| 244 | optString(errors, where, r, "release_url", 300); |
| 245 | optVersionReq(errors, where, r); |
| 246 | } |
| 247 | } |
| 248 | const ann = source.announcements ?? []; |
| 249 | if (!Array.isArray(ann)) errors.push("announcements must be an array"); |
| 250 | else { |
| 251 | const seen = new Set(); |
| 252 | ann.forEach((a, i) => { |
| 253 | const where = `announcements[${i}]`; |
| 254 | if (!isPlainObject(a)) return errors.push(`${where} must be an object`); |
| 255 | if (typeof a.id !== "string" || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(a.id)) errors.push(`${where}.id invalid`); |
| 256 | if (seen.has(a.id)) errors.push(`${where}.id duplicated`); |
| 257 | seen.add(a.id); |
| 258 | if (a.level !== undefined && !LEVELS.has(a.level)) errors.push(`${where}.level must be info|warn`); |
| 259 | if (typeof a.text !== "string" || !a.text.trim() || a.text.length > 200) errors.push(`${where}.text required (<= 200 chars)`); |
| 260 | optString(errors, where, a, "url", 300); |
| 261 | if (a.surfaces !== undefined && !(Array.isArray(a.surfaces) && a.surfaces.every((s) => SURFACES.has(s)))) errors.push(`${where}.surfaces invalid`); |
| 262 | optVersionReq(errors, where, a); |
| 263 | optString(errors, where, a, "starts_at", 40); |
| 264 | optString(errors, where, a, "expires_at", 40); |
| 265 | }); |
| 266 | } |
| 267 | const allowed = new Set(["$schema", "_meta", "schema_version", "channel", "facts_version", "published_at", "not_after", "applies_to", "models", "provider_defaults", "release", "announcements"]); |
| 268 | for (const k of Object.keys(source)) if (!allowed.has(k)) errors.push(`unknown top-level field ${k}`); |
| 269 | return errors; |
| 270 | } |
| 271 | |
| 272 | /** Build the signed payload object (no signing) from a source file. */ |
| 273 | export function buildPayload(source, { channel, factsVersion, publishedAt }) { |
| 274 | const errors = validateSource(source); |
| 275 | if (errors.length) throw new Error(`source invalid:\n - ${errors.join("\n - ")}`); |
| 276 | const payload = { |
| 277 | schema_version: SCHEMA_VERSION, |
| 278 | channel, |
| 279 | facts_version: factsVersion, |
| 280 | published_at: publishedAt, |
| 281 | applies_to: typeof source.applies_to === "string" ? source.applies_to.trim() : "*", |
| 282 | models: source.models ?? [], |
| 283 | provider_defaults: source.provider_defaults ?? {}, |
| 284 | release: source.release ?? null, |
| 285 | announcements: source.announcements ?? [], |
| 286 | }; |
| 287 | if (source.not_after) payload.not_after = source.not_after; |
| 288 | const payloadErrors = validateSource(payload); |
| 289 | if (payloadErrors.length || utcTime(publishedAt) === null || !Number.isSafeInteger(factsVersion) || factsVersion <= 0 || !CHANNEL_RE.test(channel)) { |
| 290 | throw new Error("invalid signed payload metadata"); |
| 291 | } |
| 292 | return payload; |
| 293 | } |
| 294 | |
| 295 | export function buildEnvelope({ privateKey, keyId, payload }) { |
| 296 | if (!KEY_ID_RE.test(keyId)) throw new Error(`key_id must match ${KEY_ID_RE}`); |
| 297 | const payloadBytes = Buffer.from(canonicalize(payload), "utf8"); |
| 298 | if (payloadBytes.length > MAX_PAYLOAD_BYTES) throw new Error(`payload exceeds ${MAX_PAYLOAD_BYTES} bytes`); |
| 299 | const sig = signPayload(privateKey, keyId, payloadBytes); |
| 300 | const sha256 = createHash("sha256").update(payloadBytes).digest("hex"); |
| 301 | const envelope = { |
| 302 | envelope: ENVELOPE_VERSION, |
| 303 | channel: payload.channel, |
| 304 | facts_version: payload.facts_version, |
| 305 | schema_version: payload.schema_version, |
| 306 | key_id: keyId, |
| 307 | alg: "ed25519", |
| 308 | applies_to: payload.applies_to, |
| 309 | published_at: payload.published_at, |
| 310 | payload_b64: payloadBytes.toString("base64"), |
| 311 | sig_b64: sig.toString("base64"), |
| 312 | sigs: [], |
| 313 | sha256, |
| 314 | }; |
| 315 | if (payload.not_after != null) envelope.not_after = payload.not_after; |
| 316 | const pub = rawPublicKeyFromKeyObject(createPublicKey(privateKey)).toString("base64"); |
| 317 | const check = verifyEnvelope(envelope, pub); |
| 318 | if (!check.ok) throw new Error(`self-verify failed: ${check.errors.join("; ")}`); |
| 319 | return envelope; |
| 320 | } |
| 321 | |
| 322 | // --------------------------------------------------------------------------- |
| 323 | // CLI helpers |
| 324 | // --------------------------------------------------------------------------- |
| 325 | |
| 326 | function parseArgs(argv) { |
| 327 | const positional = []; |
| 328 | const flags = {}; |
| 329 | for (let i = 0; i < argv.length; i += 1) { |
| 330 | const arg = argv[i]; |
| 331 | if (arg.startsWith("--")) { |
| 332 | const key = arg.slice(2); |
| 333 | const next = argv[i + 1]; |
| 334 | if (next === undefined || next.startsWith("--")) flags[key] = true; |
| 335 | else { flags[key] = next; i += 1; } |
| 336 | } else positional.push(arg); |
| 337 | } |
| 338 | return { positional, flags }; |
| 339 | } |
| 340 | |
| 341 | /** Bounded, regular, single-link file reads; no symlink or FIFO following. */ |
| 342 | export function readBoundedFile(path, maxBytes = MAX_ENVELOPE_BYTES) { |
| 343 | const before = lstatSync(path); |
| 344 | if (!before.isFile() || before.nlink !== 1) throw new Error("file is not a regular single-link file"); |
| 345 | const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); |
| 346 | try { |
| 347 | const stat = fstatSync(fd); |
| 348 | if (!stat.isFile() || stat.nlink !== 1 || stat.size > maxBytes || stat.ino !== before.ino || stat.dev !== before.dev) throw new Error("file is not a bounded regular single-link file"); |
| 349 | const bytes = Buffer.alloc(maxBytes + 1); |
| 350 | let size = 0; |
| 351 | while (size <= maxBytes) { |
| 352 | const count = readSync(fd, bytes, size, maxBytes + 1 - size, null); |
| 353 | if (!count) break; |
| 354 | size += count; |
| 355 | } |
| 356 | if (size > maxBytes) throw new Error("file exceeds size limit"); |
| 357 | return bytes.subarray(0, size); |
| 358 | } finally { closeSync(fd); } |
| 359 | } |
| 360 | |
| 361 | function loadPrivateKeyFromEnv() { |
| 362 | refuseUnderCi(); |
| 363 | let pem = process.env.CODEWHALE_FACTS_SIGNING_KEY; |
| 364 | const file = process.env.CODEWHALE_FACTS_SIGNING_KEY_FILE; |
| 365 | if (!pem && file) pem = readBoundedFile(file, 16 * 1024).toString("utf8"); |
| 366 | if (!pem) throw new Error("set CODEWHALE_FACTS_SIGNING_KEY (PEM) or CODEWHALE_FACTS_SIGNING_KEY_FILE"); |
| 367 | if (Buffer.byteLength(pem) > 16 * 1024) throw new Error("signing key exceeds size limit"); |
| 368 | const key = createPrivateKey({ key: pem, format: "pem" }); |
| 369 | if (key.asymmetricKeyType !== "ed25519") throw new Error("signing key must be Ed25519"); |
| 370 | return key; |
| 371 | } |
| 372 | |
| 373 | export function validateTrustedKeys(keys) { |
| 374 | const seen = new Set(); |
| 375 | for (const key of keys) { |
| 376 | if (!KEY_ID_RE.test(key.keyId) || seen.has(key.keyId) || !["active", "retired"].includes(key.status) || strictBase64(key.publicKey, 32).length !== 32) throw new Error("invalid or duplicated pinned key"); |
| 377 | seen.add(key.keyId); |
| 378 | } |
| 379 | return keys; |
| 380 | } |
| 381 | |
| 382 | /** Deliberately narrow syntax: a changed/unparseable table must fail the gate. */ |
| 383 | export function parseTsKeys(text) { |
| 384 | const source = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); |
| 385 | const tables = [...source.matchAll(/^\s*export\s+const\s+TRUSTED_KEYS\s*:\s*readonly\s+TrustedKey\[\]\s*=\s*\[([\s\S]*?)\]\s*;/gm)]; |
| 386 | if (tables.length !== 1) throw new Error("cannot parse exactly one TypeScript TRUSTED_KEYS table"); |
| 387 | const table = tables[0]; |
| 388 | const body = table[1].replace(/^\s*\/\/.*$/gm, ""); |
| 389 | const keys = []; |
| 390 | const remainder = body.replace(/\{\s*keyId:\s*"([^"]+)",\s*publicKey:\s*"([^"]+)",\s*status:\s*"([^"]+)"\s*,?\s*\}/g, (_, keyId, publicKey, status) => { |
| 391 | keys.push({ keyId, publicKey, status }); |
| 392 | return ""; |
| 393 | }); |
| 394 | if (remainder.replace(/[\s,]/g, "")) throw new Error("unparsed TypeScript TRUSTED_KEYS entry"); |
| 395 | return validateTrustedKeys(keys); |
| 396 | } |
| 397 | |
| 398 | function loadTrustedKeysFromRepo() { |
| 399 | const keys = parseTsKeys(readBoundedFile(resolve(WEB_ROOT, "lib/cloud-facts/keys.ts"), 64 * 1024).toString("utf8")); |
| 400 | return new Map(keys.map((key) => [key.keyId, key])); |
| 401 | } |
| 402 | |
| 403 | export function activePublishingKey(envelope, keys, now = Date.now()) { |
| 404 | const key = validateTrustedKeys(keys).find((key) => key.keyId === envelope.key_id && key.status === "active"); |
| 405 | if (!key) throw new Error("primary signing key is not pinned and active; refusing publication"); |
| 406 | const check = verifyEnvelope(envelope, key.publicKey); |
| 407 | if (!check.ok) throw new Error(`envelope does not verify: ${check.errors.join("; ")}`); |
| 408 | if (!Number.isFinite(now) || utcTime(check.payload.published_at) > now + 300_000 || |
| 409 | (check.payload.not_after != null && utcTime(check.payload.not_after) <= now)) throw new Error("publication timestamp is future or expired"); |
| 410 | return { key, check }; |
| 411 | } |
| 412 | |
| 413 | function refuseUnderCi() { |
| 414 | for (const marker of CI_MARKERS) { |
| 415 | if (process.env[marker] && !/^(0|false|no|off)$/i.test(process.env[marker])) { |
| 416 | throw new Error(`refusing to run with a secret under CI (${marker} is set); publish from the founder's machine`); |
| 417 | } |
| 418 | } |
| 419 | } |
| 420 | |
| 421 | function sqlLiteral(value) { |
| 422 | if (value === null || value === undefined) return "null"; |
| 423 | return `'${String(value).replace(/'/g, "''")}'`; |
| 424 | } |
| 425 | |
| 426 | export function emitSql(envelope, { publishedBy = "", publicKeyB64, notes = "" }) { |
| 427 | if (!publicKeyB64) throw new Error("public key required to emit the facts_key row"); |
| 428 | const check = verifyEnvelope(envelope, publicKeyB64); |
| 429 | if (!check.ok) throw new Error(`envelope does not verify: ${check.errors.join("; ")}`); |
| 430 | const payloadJson = Buffer.from(envelope.payload_b64, "base64").toString("utf8"); |
| 431 | return [ |
| 432 | "begin;", |
| 433 | `insert into public.facts_key (key_id, scope, algorithm, public_key, status)`, |
| 434 | ` values (${sqlLiteral(envelope.key_id)}, 'global', 'ed25519', ${sqlLiteral(publicKeyB64)}, 'active')`, |
| 435 | ` on conflict (key_id) do nothing;`, |
| 436 | `insert into public.facts_release (channel_id, facts_version, schema_version, envelope_version, applies_to, key_id, payload_b64, sig_b64, sigs, payload, published_at, not_after, published_by, notes)`, |
| 437 | ` select c.id, ${envelope.facts_version}, ${envelope.schema_version}, ${envelope.envelope}, ${sqlLiteral(envelope.applies_to)}, ${sqlLiteral(envelope.key_id)},`, |
| 438 | ` ${sqlLiteral(envelope.payload_b64)}, ${sqlLiteral(envelope.sig_b64)}, ${sqlLiteral(JSON.stringify(envelope.sigs ?? []))}::jsonb,`, |
| 439 | ` ${sqlLiteral(payloadJson)}::jsonb, ${sqlLiteral(envelope.published_at)}::timestamptz, ${sqlLiteral(check.payload.not_after ?? null)}::timestamptz,`, |
| 440 | ` ${sqlLiteral(publishedBy)}, ${sqlLiteral(notes)}`, |
| 441 | ` from public.facts_channel c where c.scope = 'global' and c.slug = ${sqlLiteral(envelope.channel)};`, |
| 442 | "commit;", |
| 443 | "", |
| 444 | ].join("\n"); |
| 445 | } |
| 446 | |
| 447 | async function postgrest(path, { method = "GET", body, prefer } = {}) { |
| 448 | refuseUnderCi(); |
| 449 | const url = process.env.SUPABASE_URL; |
| 450 | const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SECRET_KEY; |
| 451 | if (!url || !key) throw new Error("SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required"); |
| 452 | const endpoint = new URL(url); |
| 453 | if (endpoint.protocol !== "https:" || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) throw new Error("invalid Supabase endpoint"); |
| 454 | const res = await fetch(`${url.replace(/\/$/, "")}/rest/v1/${path}`, { |
| 455 | method, |
| 456 | signal: AbortSignal.timeout(30_000), |
| 457 | redirect: "error", |
| 458 | headers: { |
| 459 | apikey: key, |
| 460 | Authorization: `Bearer ${key}`, |
| 461 | "Content-Type": "application/json", |
| 462 | ...(prefer ? { Prefer: prefer } : {}), |
| 463 | }, |
| 464 | body: body === undefined ? undefined : JSON.stringify(body), |
| 465 | }); |
| 466 | if (!res.ok) { await res.body?.cancel(); throw new Error(`PostgREST request failed (HTTP ${res.status})`); } |
| 467 | const text = await readBoundedResponse(res); |
| 468 | return text ? JSON.parse(text) : null; |
| 469 | } |
| 470 | |
| 471 | export async function readBoundedResponse(response, maxBytes = MAX_ENVELOPE_BYTES) { |
| 472 | const length = response.headers.get("content-length"); |
| 473 | if (length !== null && (!/^\d+$/.test(length) || Number(length) > maxBytes)) { |
| 474 | await response.body?.cancel(); |
| 475 | throw new Error("response exceeds size limit or has invalid length"); |
| 476 | } |
| 477 | if (!response.body) return ""; |
| 478 | const reader = response.body.getReader(); |
| 479 | const chunks = []; |
| 480 | let size = 0; |
| 481 | try { |
| 482 | while (true) { |
| 483 | const { value, done } = await reader.read(); |
| 484 | if (done) break; |
| 485 | size += value.byteLength; |
| 486 | if (size > maxBytes) throw new Error("response exceeds size limit"); |
| 487 | chunks.push(value); |
| 488 | } |
| 489 | } catch (error) { try { await reader.cancel(); } catch { /* Keep rejection. */ } throw error; } |
| 490 | finally { reader.releaseLock(); } |
| 491 | return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, size)); |
| 492 | } |
| 493 | |
| 494 | function readJson(path) { |
| 495 | return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(readBoundedFile(path))); |
| 496 | } |
| 497 | |
| 498 | function nowIso() { |
| 499 | return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); |
| 500 | } |
| 501 | |
| 502 | async function main(argv) { |
| 503 | const { positional, flags } = parseArgs(argv); |
| 504 | const cmd = positional[0]; |
| 505 | if (!cmd || flags.help) { |
| 506 | console.log(readFileSync(fileURLToPath(import.meta.url), "utf8").split("\n").slice(1, 26).join("\n")); |
| 507 | return 0; |
| 508 | } |
| 509 | if (cmd === "keygen") { |
| 510 | const keyId = String(flags["key-id"] ?? ""); |
| 511 | if (!KEY_ID_RE.test(keyId)) throw new Error("--key-id must match cwf-[a-z0-9-]{1,32}"); |
| 512 | const out = flags.out ? resolve(String(flags.out)) : null; |
| 513 | if (!out) throw new Error("--out <path> is required (write the private key OUTSIDE any repository)"); |
| 514 | refuseUnderCi(); |
| 515 | const { privateKey, publicKey } = generateKeyPairSync("ed25519"); |
| 516 | mkdirSync(dirname(out), { recursive: true, mode: 0o700 }); |
| 517 | const fd = openSync(out, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); |
| 518 | try { writeFileSync(fd, privateKey.export({ type: "pkcs8", format: "pem" })); } |
| 519 | finally { closeSync(fd); } |
| 520 | const raw = rawPublicKeyFromKeyObject(publicKey); |
| 521 | console.log(JSON.stringify({ |
| 522 | key_id: keyId, |
| 523 | algorithm: "ed25519", |
| 524 | public_key_b64: raw.toString("base64"), |
| 525 | public_key_bytes: [...raw], |
| 526 | private_key_file: out, |
| 527 | note: "Private key written with mode 0600. Move it into custody (password manager); never commit it.", |
| 528 | }, null, 2)); |
| 529 | return 0; |
| 530 | } |
| 531 | if (cmd === "sign") { |
| 532 | refuseUnderCi(); |
| 533 | const sourcePath = resolve(String(flags.source ?? resolve(REPO_ROOT, "docs/cloud-facts/stable.json"))); |
| 534 | const source = readJson(sourcePath); |
| 535 | const channel = String(flags.channel ?? source.channel ?? "stable"); |
| 536 | if (!CHANNEL_RE.test(channel)) throw new Error("bad channel slug"); |
| 537 | const factsVersion = Number(flags["facts-version"] ?? source.facts_version); |
| 538 | if (!Number.isSafeInteger(factsVersion) || factsVersion <= 0) throw new Error("--facts-version (or source.facts_version) must be a positive integer"); |
| 539 | const keyId = String(flags["key-id"] ?? ""); |
| 540 | const privateKey = loadPrivateKeyFromEnv(); |
| 541 | const publishedAt = String(flags["published-at"] ?? nowIso()); |
| 542 | const payload = buildPayload(source, { channel, factsVersion, publishedAt }); |
| 543 | const envelope = buildEnvelope({ privateKey, keyId, payload }); |
| 544 | const text = `${JSON.stringify(envelope, null, 2)}\n`; |
| 545 | if (flags.out) { |
| 546 | writeFileSync(resolve(String(flags.out)), text); |
| 547 | console.error(`wrote ${flags.out} (channel=${channel} facts_version=${factsVersion} key_id=${keyId} sha256=${envelope.sha256})`); |
| 548 | } else process.stdout.write(text); |
| 549 | return 0; |
| 550 | } |
| 551 | if (cmd === "verify") { |
| 552 | const envelope = readJson(resolve(String(positional[1] ?? ""))); |
| 553 | let pub = flags["public-key"]; |
| 554 | if (!pub) { |
| 555 | const trusted = loadTrustedKeysFromRepo().get(envelope.key_id); |
| 556 | if (!trusted || trusted.status !== "active") throw new Error("key is not pinned and active; use --public-key only for explicit offline verification"); |
| 557 | pub = trusted.publicKey; |
| 558 | } |
| 559 | const result = verifyEnvelope(envelope, String(pub)); |
| 560 | console.log(JSON.stringify({ ok: result.ok, errors: result.errors, channel: envelope.channel, facts_version: envelope.facts_version, key_id: envelope.key_id, sha256: result.sha256 ?? null }, null, 2)); |
| 561 | return result.ok ? 0 : 1; |
| 562 | } |
| 563 | if (cmd === "emit-sql") { |
| 564 | const envelope = readJson(resolve(String(positional[1] ?? ""))); |
| 565 | let pub = flags["public-key"]; |
| 566 | if (!pub) pub = activePublishingKey(envelope, [...loadTrustedKeysFromRepo().values()]).key.publicKey; |
| 567 | process.stdout.write(emitSql(envelope, { publishedBy: String(flags["published-by"] ?? ""), publicKeyB64: pub ? String(pub) : undefined, notes: String(flags.notes ?? "") })); |
| 568 | return 0; |
| 569 | } |
| 570 | if (cmd === "publish") { |
| 571 | const envelope = readJson(resolve(String(positional[1] ?? ""))); |
| 572 | if (flags["public-key"] !== undefined) throw new Error("--public-key is only for offline verify/emit-sql; publication requires the active pinned table"); |
| 573 | const { key, check } = activePublishingKey(envelope, [...loadTrustedKeysFromRepo().values()]); |
| 574 | const pub = key.publicKey; |
| 575 | const row = { |
| 576 | facts_version: envelope.facts_version, |
| 577 | schema_version: envelope.schema_version, |
| 578 | envelope_version: envelope.envelope, |
| 579 | applies_to: envelope.applies_to, |
| 580 | key_id: envelope.key_id, |
| 581 | payload_b64: envelope.payload_b64, |
| 582 | sig_b64: envelope.sig_b64, |
| 583 | sigs: envelope.sigs ?? [], |
| 584 | payload: check.payload, |
| 585 | published_at: envelope.published_at, |
| 586 | not_after: check.payload.not_after ?? null, |
| 587 | published_by: String(flags["published-by"] ?? ""), |
| 588 | notes: String(flags.notes ?? ""), |
| 589 | }; |
| 590 | if (flags["dry-run"]) { |
| 591 | console.log(JSON.stringify({ dry_run: true, channel: envelope.channel, facts_key: { key_id: envelope.key_id, public_key: pub }, facts_release: { ...row, payload_b64: `<${envelope.payload_b64.length} chars>` } }, null, 2)); |
| 592 | return 0; |
| 593 | } |
| 594 | const channels = await postgrest(`facts_channel?scope=eq.global&slug=eq.${encodeURIComponent(envelope.channel)}&select=id`); |
| 595 | if (!channels?.length) throw new Error(`channel ${envelope.channel} does not exist`); |
| 596 | await postgrest("facts_key", { method: "POST", body: { key_id: envelope.key_id, scope: "global", algorithm: "ed25519", public_key: pub, status: "active" }, prefer: "resolution=ignore-duplicates,return=minimal" }); |
| 597 | const inserted = await postgrest("facts_release", { method: "POST", body: { ...row, channel_id: channels[0].id }, prefer: "return=representation" }); |
| 598 | console.log(JSON.stringify({ published: true, channel: envelope.channel, facts_version: envelope.facts_version, release_id: inserted?.[0]?.id ?? null, payload_sha256: inserted?.[0]?.payload_sha256 ?? null }, null, 2)); |
| 599 | return 0; |
| 600 | } |
| 601 | if (cmd === "revoke") { |
| 602 | const channel = String(flags.channel ?? ""); |
| 603 | const version = Number(flags.version); |
| 604 | const reason = String(flags.reason ?? ""); |
| 605 | if (!CHANNEL_RE.test(channel) || !Number.isSafeInteger(version) || version <= 0 || !reason) throw new Error("--channel, --version and --reason are required"); |
| 606 | if (flags["dry-run"]) { |
| 607 | console.log(JSON.stringify({ dry_run: true, channel, facts_version: version, status: "revoked", revoke_reason: reason }, null, 2)); |
| 608 | return 0; |
| 609 | } |
| 610 | const channels = await postgrest(`facts_channel?scope=eq.global&slug=eq.${encodeURIComponent(channel)}&select=id`); |
| 611 | if (!channels?.length) throw new Error(`channel ${channel} does not exist`); |
| 612 | const updated = await postgrest(`facts_release?channel_id=eq.${channels[0].id}&facts_version=eq.${version}`, { |
| 613 | method: "PATCH", |
| 614 | body: { status: "revoked", revoked_at: nowIso(), revoke_reason: reason }, |
| 615 | prefer: "return=representation", |
| 616 | }); |
| 617 | console.log(JSON.stringify({ revoked: updated?.length ?? 0, channel, facts_version: version }, null, 2)); |
| 618 | return 0; |
| 619 | } |
| 620 | throw new Error(`unknown command ${cmd}`); |
| 621 | } |
| 622 | |
| 623 | const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); |
| 624 | if (invokedDirectly) { |
| 625 | main(process.argv.slice(2)).then((code) => process.exit(code)).catch((err) => { |
| 626 | console.error(`facts-publish: ${err.message}`); |
| 627 | process.exit(1); |
| 628 | }); |
| 629 | } |
| 630 |