#!/usr/bin/env node // Write and sign an account folder's manifest.json (Streamliner config, streamline.config/1). Node 20+, no dependencies. // // node sign-config.mjs --publisher-key [--key-id ] [--min-version ] [--force] [--check] // // The manifest lists every DEVICE-VISIBLE file in the folder (everything except manifest.json, enrolment.json and // private/) with sha256 and byte size, gets version + 1 (at least --min-version), and is signed with Ed25519 over its // RFC 8785 (JCS) bytes without the `signature` member. The key file is an Ed25519 private JWK (kty OKP, crv Ed25519, // d, x); its public half must be one of the account's publisher keys (enrolment.json `keys[]`, or the live manifest's // `nextKeys`), or the Worker refuses the publish. The key is read from the path given and never printed. // // --check write nothing; exit 0 if the manifest is current and verifies, 1 (with the reason) if not. // --force re-sign even if no file changed (a private/-only change still needs a new version to publish). import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs"; import { join, relative, sep } from "node:path"; import { createHash, createPrivateKey, createPublicKey, sign, verify } from "node:crypto"; const argv = process.argv.slice(2); const opt = (n) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : undefined; }; const flag = (n) => argv.includes(`--${n}`); const VALUED = new Set(["--publisher-key", "--key-id", "--min-version"]); const dir = argv.find((a, i) => !a.startsWith("--") && !VALUED.has(argv[i - 1])); const die = (m, c = 1) => { console.error(`sign-config: ${m}`); process.exit(c); }; if (!dir || !existsSync(join(dir, "tenant.json"))) die("usage: node sign-config.mjs --publisher-key [--key-id id] [--min-version n] [--force] [--check]", 2); const jcs = (v) => v === null || typeof v !== "object" ? JSON.stringify(v) : Array.isArray(v) ? "[" + v.map(jcs).join(",") + "]" : "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + jcs(v[k])).join(",") + "}"; const sha = (b) => createHash("sha256").update(b).digest("hex"); const walk = (d) => readdirSync(d).flatMap((n) => { const p = join(d, n); return statSync(p).isDirectory() ? walk(p) : n.startsWith(".") ? [] : [p]; }); const deviceVisible = (p) => !p.startsWith("private/") && p !== "manifest.json" && p !== "enrolment.json"; const tenant = JSON.parse(readFileSync(join(dir, "tenant.json"), "utf8")); const files = walk(dir).map((f) => relative(dir, f).split(sep).join("/")).filter(deviceVisible).sort() .map((p) => { const b = readFileSync(join(dir, p)); return { path: p, sha256: sha(b), bytes: b.length }; }); const mPath = join(dir, "manifest.json"); const old = existsSync(mPath) ? JSON.parse(readFileSync(mPath, "utf8")) : null; const enrol = existsSync(join(dir, "enrolment.json")) ? JSON.parse(readFileSync(join(dir, "enrolment.json"), "utf8")) : null; const pubs = [...(enrol?.keys || []), ...(old?.nextKeys || [])]; function verifies(m) { const k = pubs.find((x) => x.keyId === m.signature?.keyId); if (!k) return `keyId ${m.signature?.keyId} is not among the account's publisher keys${enrol ? "" : " (no enrolment.json here to check against)"}`; const { signature, ...unsigned } = m; const ok = verify(null, Buffer.from(jcs(unsigned)), createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: k.pub }, format: "jwk" }), Buffer.from(signature.sig, "base64url")); return ok ? null : "the signature does not verify"; } const sameFiles = old && jcs(old.files) === jcs(files) && old.tenant === tenant.id; if (flag("check")) { if (!old) die("no manifest.json"); if (!sameFiles) die("the manifest is stale: a device-visible file changed, was added or was removed (re-sign)"); const bad = verifies(old); if (bad) die(bad); console.log(`manifest current: ${tenant.id} version ${old.version}, ${files.length} files, signed by ${old.signature.keyId}`); process.exit(0); } const keyFile = opt("publisher-key"); if (!keyFile || !existsSync(keyFile)) die("--publisher-key is required (never paste the key itself)"); const jwk = JSON.parse(readFileSync(keyFile, "utf8")); if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !jwk.d) die("the publisher key must be an Ed25519 private JWK"); const keyId = opt("key-id") || jwk.kid || pubs.find((k) => k.pub === jwk.x)?.keyId; if (!keyId) die("which key id? --key-id (the keyId the account knows this publisher key by)"); const listed = pubs.find((k) => k.keyId === keyId); if (listed && listed.pub !== jwk.x) die(`keyId ${keyId} names a different public key in enrolment.json`); if (!listed) console.error(`warning: ${keyId} is not in enrolment.json keys[] or nextKeys; the Worker will refuse this manifest unless the account already has that key`); const minVersion = Number(opt("min-version") || 0); if (sameFiles && !flag("force") && (old.version || 0) >= minVersion && !verifies(old)) { console.log(`unchanged: ${tenant.id} version ${old.version} (use --force or --min-version to bump)`); process.exit(0); } const manifest = { schema: "streamline.config/1", tenant: tenant.id, version: Math.max((old?.version || 0) + 1, minVersion), published: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), ...(old?.minApp ? { minApp: old.minApp } : {}), ...(old?.refresh ? { refresh: old.refresh } : {}), ...(old?.nextKeys ? { nextKeys: old.nextKeys } : {}), files, }; const privateKey = createPrivateKey({ key: { kty: "OKP", crv: "Ed25519", d: jwk.d, x: jwk.x }, format: "jwk" }); manifest.signature = { alg: "Ed25519", keyId, sig: sign(null, Buffer.from(jcs(manifest)), privateKey).toString("base64url") }; writeFileSync(mPath, JSON.stringify(manifest, null, 2) + "\n"); console.log(`signed ${tenant.id} version ${manifest.version}: ${files.length} device-visible files, key ${keyId}`);