create-wordjs 2.11.8 → 2.11.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +7 -5
  2. package/index.js +86 -9
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -205,11 +205,13 @@ need `openssl` on the PATH. Full details, port matrix and the manual (source-che
205
205
 
206
206
  - **GitHub downloads are verified.** Every release publishes `wordjs-<tag>.zip.sha256`
207
207
  (`sha256sum` format) next to the bundle. `create-wordjs` — including `upgrade`, `gateway` and
208
- `join` — downloads it and refuses to extract a ZIP whose SHA-256 differs. Releases published
209
- **before** the checksum asset was introduced have nothing to verify against: they still install
210
- (so `--version <old-tag>` rollbacks keep working) but with a clear warning that the download was
211
- not verified. The checksum is fetched from the same GitHub release over HTTPS, so it catches a
212
- corrupted or swapped download, not a compromised release.
208
+ `join` — downloads it and refuses to extract a ZIP whose SHA-256 differs. A release that
209
+ publishes **no** checksum (only those from before the asset was introduced) is refused unless you
210
+ pass `--allow-unverified`, which installs it with a clear warning that nothing was verified.
211
+ - **Release signature.** The checksum comes from the same release, so it proves the download is
212
+ intact, not who published it. A signed release also publishes `wordjs-<tag>.zip.sha256.sig`, an
213
+ Ed25519 signature of the checksum file made with the project's release key; `create-wordjs`
214
+ verifies it against the public keys embedded in this package and refuses one that does not verify.
213
215
  - **`--zip` sources**: a URL must be `https://` (plain `http://` and other schemes are refused); a
214
216
  local path is accepted as is. Neither is verified unless you pass `--sha256 <hex>` (a URL
215
217
  without it prints a warning).
package/index.js CHANGED
@@ -65,6 +65,8 @@ Options:
65
65
  --sha256 <hex> Require the ZIP to have this SHA-256 (pin a checksum for --zip; also checked
66
66
  on top of the release's own .sha256 asset).
67
67
  --version <tag> Install/upgrade to a specific release tag (e.g. vX.Y.Z) instead of the latest.
68
+ --allow-unverified Install a GitHub release that publishes no .sha256 for its bundle (only very old
69
+ releases). Without it such a release is refused: nothing would be verified.
68
70
  --http Serve plain HTTP instead of self-signed HTTPS (sets WORDJS_HTTP=1). (create)
69
71
  --no-start Scaffold + install dependencies only; don't start the server.
70
72
  --yes, -y Skip the confirmation prompt (required when upgrading non-interactively).
@@ -132,7 +134,7 @@ function parseArgs(argv) {
132
134
  mode: 'create', dir: null, zip: null, version: null, http: false, start: true, yes: false, force: false, install: true,
133
135
  role: null, gateway: null, token: null, caHash: null, advertise: null, enrollPort: null, host: null,
134
136
  systemd: false, port: null, serviceUser: null, mail: false,
135
- sha256: null,
137
+ sha256: null, allowUnverified: false,
136
138
  };
137
139
  // A leading subcommand selects the mode (default is the monolith create flow).
138
140
  if (['upgrade', 'gateway', 'join'].includes(argv[0])) { opts.mode = argv[0]; argv = argv.slice(1); }
@@ -150,6 +152,7 @@ function parseArgs(argv) {
150
152
  }
151
153
  else if (a === '--version') { opts.version = argv[++i] || fail('--version needs a value (a release tag, e.g. vX.Y.Z).'); }
152
154
  else if (a === '--http') opts.http = true;
155
+ else if (a === '--allow-unverified') opts.allowUnverified = true;
153
156
  else if (a === '--no-start') opts.start = false;
154
157
  else if (a === '--yes' || a === '-y') opts.yes = true;
155
158
  else if (a === '--force') opts.force = true;
@@ -236,9 +239,62 @@ function readBody(res) {
236
239
  // The bundle we download becomes the site's code, so it is verified before a single entry is
237
240
  // extracted. release.yml publishes `wordjs-<tag>.zip.sha256` (sha256sum format: `<hex> <name>`) next
238
241
  // to every tag-named bundle; the GitHub flow downloads it and refuses a ZIP whose digest differs.
239
- // Releases published before that asset existed have no checksum to check against — those install
240
- // with a loud warning rather than not at all. `--sha256 <hex>` pins a digest the user obtained out of
241
- // band, for `--zip` sources (and on top of the release checksum when both are present).
242
+ // A release that publishes no checksum (only those from before that asset existed) is refused unless
243
+ // the operator passes --allow-unverified, which says out loud that nothing was checked. `--sha256 <hex>`
244
+ // pins a digest the user obtained out of band, for `--zip` sources (and on top of the release checksum
245
+ // when both are present).
246
+ //
247
+ // WHO PUBLISHED IT. The checksum comes from the same release as the ZIP, so it proves the download is
248
+ // intact, not who made it. release.yml therefore signs the checksum file with the project's Ed25519
249
+ // release key (`<bundle>.sha256.sig`, base64), and this CLI verifies that signature against the public
250
+ // keys embedded below — keys that travel inside the npm package, not inside the release. A published
251
+ // signature that no embedded key verifies is fatal. Once SIGNATURE_REQUIRED_SINCE is set, a release at
252
+ // or above that version that publishes no signature is refused as well.
253
+
254
+ /** The project's release-signing public keys (PEM, Ed25519 SPKI). Several, so a key can be rotated. */
255
+ const RELEASE_SIGNING_KEYS = [];
256
+ /** The first release tag every later release must be signed from (e.g. 'v2.12.0'), or null. */
257
+ const SIGNATURE_REQUIRED_SINCE = null;
258
+
259
+ /** Numeric x.y.z compare of two tags (pre-release suffixes ignored); <0, 0 or >0. */
260
+ function compareTags(a, b) {
261
+ const nums = (t) => String(t || '').replace(/^v/, '').split('-')[0].split('.').map((n) => Number(n) || 0);
262
+ const x = nums(a); const y = nums(b);
263
+ for (let i = 0; i < 3; i++) if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) - (y[i] || 0);
264
+ return 0;
265
+ }
266
+
267
+ /**
268
+ * Does `signatureText` (base64 Ed25519) sign exactly `checksumText` under one of `keys`? Never throws:
269
+ * an unusable key or a malformed signature is simply not a match.
270
+ */
271
+ function verifyChecksumSignature(checksumText, signatureText, keys = RELEASE_SIGNING_KEYS) {
272
+ const b64 = String(signatureText == null ? '' : signatureText).trim();
273
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(b64)) return false;
274
+ const signature = Buffer.from(b64, 'base64');
275
+ const data = Buffer.from(String(checksumText == null ? '' : checksumText), 'utf8');
276
+ return (Array.isArray(keys) ? keys : []).some((pem) => {
277
+ try { return crypto.verify(null, data, crypto.createPublicKey(pem), signature); } catch { return false; }
278
+ });
279
+ }
280
+
281
+ /**
282
+ * The signature rule for one release, as a verdict: 'verified', 'unsigned' (allowed), or a reason to
283
+ * refuse. `keys` and `requiredSince` are parameters so the rule can be tested with a throwaway key.
284
+ */
285
+ function signatureVerdict({ tag, checksumText, signatureText, keys = RELEASE_SIGNING_KEYS, requiredSince = SIGNATURE_REQUIRED_SINCE }) {
286
+ const haveKeys = Array.isArray(keys) && keys.length > 0;
287
+ if (signatureText != null) {
288
+ if (!haveKeys) return { ok: true, state: 'unchecked' };
289
+ return verifyChecksumSignature(checksumText, signatureText, keys)
290
+ ? { ok: true, state: 'verified' }
291
+ : { ok: false, reason: `the release's checksum signature does not match the WordJS release key` };
292
+ }
293
+ if (haveKeys && requiredSince && compareTags(tag, requiredSince) >= 0) {
294
+ return { ok: false, reason: `release ${tag} publishes no checksum signature, and every release since ${requiredSince} must` };
295
+ }
296
+ return { ok: true, state: 'unsigned' };
297
+ }
242
298
 
243
299
  /** Lower-case 64-char hex, or null. Accepts the `AA:BB:…` colon form some tools print. */
244
300
  function normalizeSha256(value) {
@@ -343,6 +399,12 @@ function pickChecksumAsset(assets, bundleName) {
343
399
  return (Array.isArray(assets) ? assets : []).find((a) => String(a && a.name || '').toLowerCase() === wanted) || null;
344
400
  }
345
401
 
402
+ /** The `<bundle>.sha256.sig` asset (the release key's signature over the checksum file), by exact name. */
403
+ function pickSignatureAsset(assets, bundleName) {
404
+ const wanted = `${bundleName}.sha256.sig`.toLowerCase();
405
+ return (Array.isArray(assets) ? assets : []).find((a) => String(a && a.name || '').toLowerCase() === wanted) || null;
406
+ }
407
+
346
408
  /** Every asset matching the loose `wordjs-*.zip` shape — used to explain an ambiguous refusal. */
347
409
  function looseBundleCandidates(assets) {
348
410
  const list = Array.isArray(assets) ? assets : [];
@@ -370,9 +432,11 @@ async function resolveReleaseAsset(tag) {
370
432
  fail(`Release ${release.tag_name} has no wordjs-*.zip asset.`, 'Pass --zip <path-or-url> instead.');
371
433
  }
372
434
  const sum = pickChecksumAsset(release.assets, asset.name);
435
+ const sig = pickSignatureAsset(release.assets, asset.name);
373
436
  return {
374
437
  name: asset.name, url: asset.browser_download_url, tag: release.tag_name,
375
438
  checksumName: sum ? sum.name : null, checksumUrl: sum ? sum.browser_download_url : null,
439
+ signatureName: sig ? sig.name : null, signatureUrl: sig ? sig.browser_download_url : null,
376
440
  };
377
441
  }
378
442
 
@@ -554,21 +618,34 @@ async function obtainBundleZip(opts) {
554
618
  const asset = await resolveReleaseAsset(opts.version);
555
619
  tag = asset.tag;
556
620
  console.log(` Found ${asset.tag} → ${asset.name}`);
621
+ if (!asset.checksumUrl && !opts.allowUnverified) {
622
+ fail(`Release ${asset.tag} publishes no ${asset.name}.sha256, so its download cannot be verified.`,
623
+ 'Refusing to install it. Pick a release that publishes a checksum, or pass --allow-unverified if you accept installing it unchecked.');
624
+ }
557
625
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'create-wordjs-'));
558
626
  zipPath = path.join(tmpDir, asset.name);
559
627
  await download(asset.url, zipPath, asset.name);
560
628
  if (asset.checksumUrl) {
561
- const expected = parseChecksumFile(await downloadText(asset.checksumUrl), asset.name);
629
+ const checksumText = await downloadText(asset.checksumUrl);
630
+ const signed = signatureVerdict({
631
+ tag: asset.tag, checksumText,
632
+ signatureText: asset.signatureUrl ? await downloadText(asset.signatureUrl) : null,
633
+ });
634
+ if (!signed.ok) {
635
+ cleanup();
636
+ fail(`Refusing to install ${asset.name}: ${signed.reason}.`, 'The download may not come from the WordJS project.');
637
+ }
638
+ if (signed.state === 'verified') console.log(` ✓ Release signature verified (${asset.signatureName})`);
639
+ const expected = parseChecksumFile(checksumText, asset.name);
562
640
  if (!expected) {
563
641
  cleanup();
564
642
  fail(`${asset.checksumName} does not contain a SHA-256 for ${asset.name}.`, 'Refusing to install a bundle that cannot be verified.');
565
643
  }
566
644
  checks.push({ expected, source: asset.checksumName });
567
645
  } else {
568
- // Releases published before the checksum asset existed. Installing them still works (so
569
- // `--version <old-tag>` rollbacks keep working), but say plainly that nothing was verified.
646
+ // Only reachable with --allow-unverified: say plainly that nothing was verified.
570
647
  console.warn(` ⚠️ Release ${asset.tag} publishes no ${asset.name}.sha256 — the download's integrity`);
571
- console.warn(' could NOT be verified (releases before the checksum asset was introduced).');
648
+ console.warn(' could NOT be verified (--allow-unverified).');
572
649
  console.warn(' Pin one with --sha256 <hex> if you have it from a trusted source.');
573
650
  }
574
651
  }
@@ -1596,7 +1673,7 @@ if (require.main === module) {
1596
1673
  }
1597
1674
 
1598
1675
  module.exports = {
1599
- pickBundleAsset, pickChecksumAsset, extractZip, normalizeSha256, parseChecksumFile, classifyZipSource, sha256File,
1676
+ pickBundleAsset, pickChecksumAsset, pickSignatureAsset, verifyChecksumSignature, signatureVerdict, compareTags, extractZip, normalizeSha256, parseChecksumFile, classifyZipSource, sha256File,
1600
1677
  parseArgs, buildSystemdFiles, systemdInstallSteps, writeSystemdFiles,
1601
1678
  copyMerge, PRESERVE_ON_UPGRADE,
1602
1679
  sysctlDropIn, serviceUserProblem, systemdPreflight, createSystemdStagingDir, LOW_PORT_ADVICE, HELP, installedFileMode,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-wordjs",
3
- "version": "2.11.8",
3
+ "version": "2.11.10",
4
4
  "description": "Create a WordJS site with one command — the self-hosted CMS where third-party plugins run in an OS-isolated process with per-capability permission grants. SSR/SEO out of the box, SQLite by default, no PHP.",
5
5
  "license": "MIT",
6
6
  "author": "Jaime Martinez (https://github.com/jaimemartinez)",