@rightkit/release 0.2.56 → 0.2.57

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.
package/build-release.mjs CHANGED
@@ -29,6 +29,17 @@ import { buildPathPrefix, collectPreflight, formatPreflight, preflightFailures }
29
29
  import { assertCleanSource } from "./source-gate.mjs";
30
30
  import { acquireHeavyWorkSlot, heavyCommandEnvironment, terminateProcessTree } from "./heavy-command.mjs";
31
31
 
32
+ // Fingerprint of the pipeline code that can change the bytes we ship or the way
33
+ // they are signed — deliberately NOT the package version, which moves for docs
34
+ // and test-only edits and would force a cold Rust rebuild of every app each time.
35
+ // These four files are the ones whose behaviour the cached target directory can
36
+ // outlive.
37
+ const PIPELINE_FINGERPRINT_SOURCES = ["release.mjs", "sign-windows.mjs", "tauri-bundle-marker.mjs", "nsis-payload.mjs"];
38
+ const PIPELINE_FINGERPRINT = createHash("sha256")
39
+ .update(PIPELINE_FINGERPRINT_SOURCES.map((file) => `${file}:${createHash("sha256").update(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), file))).digest("hex")}`).join("\n"))
40
+ .digest("hex")
41
+ .slice(0, 16);
42
+
32
43
  /** Assemble preflight inputs from the app's own files (mirrors release.mjs). */
33
44
  function buildPreflight({ config, configPath, appRoot, repoRoot, platform }) {
34
45
  let version;
@@ -124,8 +135,13 @@ try {
124
135
  const receiptRoot = path.resolve(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(appRoot, ".right-release", "receipts"));
125
136
  const signingIdentity = platform === "win" ? {
126
137
  contract: target.signingContract ?? "<missing>",
138
+ // Pipeline code participates in cache identity. Without it, a fix to the
139
+ // signing or packaging logic leaves the previous logic's target directory
140
+ // eligible for reuse — which is how outputs from a known-bad pipeline
141
+ // outlived the change that was supposed to retire them.
142
+ pipeline: PIPELINE_FINGERPRINT,
127
143
  configSha256: hashFile(configPath),
128
- receiptInputs: ["raw-exe", "installer"].map((phase) => path.join(receiptRoot, `windows-${phase}.json`)),
144
+ receiptInputs: ["raw-exe", "installer", "embedding"].map((phase) => path.join(receiptRoot, `windows-${phase}.json`)),
129
145
  } : null;
130
146
  if (signingIdentity) inputHashes[".right-release/signing-identity.json"] = hashFileText(JSON.stringify(signingIdentity));
131
147
  const cargoLock = requiredInputs.find((file) => /Cargo\.lock$/i.test(file));
File without changes
@@ -0,0 +1,155 @@
1
+ // Prove the installer actually ships the bytes we signed.
2
+ //
3
+ // Signing the raw EXE and signing the installer are two separate signatures.
4
+ // Between them Tauri copies the EXE into the NSIS payload, and a single byte
5
+ // changed anywhere in that hand-off invalidates the inner Authenticode signature
6
+ // while leaving the outer installer signature perfectly valid. Every check that
7
+ // stops at "the installer is signed" therefore passes on a broken build — which
8
+ // is how invalid binaries shipped.
9
+ //
10
+ // So we open the finished installer, pull the embedded executable back out, and
11
+ // require it to be byte-identical to the EXE we signed. No inference, no proxy.
12
+ // 7-Zip reads the NSIS container format; if it is absent we fail closed rather
13
+ // than record an unverified pass.
14
+
15
+ import { createHash } from "node:crypto";
16
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { spawnSync } from "node:child_process";
20
+
21
+ const SEVEN_ZIP_CANDIDATES = [
22
+ "C:\\Program Files\\7-Zip\\7z.exe",
23
+ "C:\\Program Files (x86)\\7-Zip\\7z.exe",
24
+ "/opt/homebrew/bin/7z",
25
+ "/usr/local/bin/7z",
26
+ "/usr/bin/7z",
27
+ ];
28
+
29
+ export function resolveSevenZip({ env = process.env, exists = existsSync, which = commandPath } = {}) {
30
+ const explicit = env.RIGHT_RELEASE_SEVENZIP;
31
+ if (explicit) {
32
+ if (!exists(explicit)) throw new Error(`RIGHT_RELEASE_SEVENZIP does not exist: ${explicit}`);
33
+ return explicit;
34
+ }
35
+ for (const candidate of SEVEN_ZIP_CANDIDATES) {
36
+ if (exists(candidate)) return candidate;
37
+ }
38
+ for (const name of ["7z", "7za", "7zz"]) {
39
+ const found = which(name);
40
+ if (found) return found;
41
+ }
42
+ throw new Error(
43
+ "7-Zip is required to verify the NSIS installer payload but was not found; install 7-Zip (https://www.7-zip.org) or set RIGHT_RELEASE_SEVENZIP to its 7z executable",
44
+ );
45
+ }
46
+
47
+ /**
48
+ * Names of the files NSIS will install, parsed from 7-Zip's technical listing
49
+ * (`-slt`). The column-aligned default listing leaves the date blank for entries
50
+ * NSIS synthesises, which makes positional parsing quietly wrong; the key/value
51
+ * form has no such ambiguity. Directory entries are dropped.
52
+ *
53
+ * The first record describes the archive itself and is separated from the member
54
+ * records by a `----------` rule, so parsing starts after that rule.
55
+ */
56
+ export function parseSevenZipListing(stdout) {
57
+ const lines = String(stdout).replace(/\r\n?/g, "\n").split("\n");
58
+ const start = lines.findIndex((line) => line.trim() === "----------");
59
+ if (start === -1) return [];
60
+ const entries = [];
61
+ let current = null;
62
+ for (const line of lines.slice(start + 1)) {
63
+ const match = line.match(/^([A-Za-z][A-Za-z0-9 ]*?)\s=\s?(.*)$/);
64
+ if (!match) continue;
65
+ const [, key, value] = match;
66
+ if (key === "Path") {
67
+ if (current) entries.push(current);
68
+ current = { name: value.replace(/\\/g, "/"), sizeBytes: null, folder: false };
69
+ } else if (!current) continue;
70
+ else if (key === "Size") current.sizeBytes = value.trim() === "" ? null : Number(value.trim());
71
+ else if (key === "Folder") current.folder = value.trim() === "+";
72
+ else if (key === "Attributes") current.folder = current.folder || value.includes("D");
73
+ }
74
+ if (current) entries.push(current);
75
+ return entries.filter((entry) => !entry.folder).map(({ name, sizeBytes }) => ({ name, sizeBytes }));
76
+ }
77
+
78
+ export function listNsisPayload(installer, { sevenZip = resolveSevenZip(), run = runSevenZip } = {}) {
79
+ const result = run(sevenZip, ["l", "-tnsis", "-slt", "--", path.resolve(installer)]);
80
+ if (result.status !== 0) {
81
+ throw new Error(`could not read the NSIS payload of ${installer}: ${(result.stderr || result.stdout || "").trim()}`);
82
+ }
83
+ return parseSevenZipListing(result.stdout);
84
+ }
85
+
86
+ /**
87
+ * Extract one payload entry to a temporary directory and return its bytes.
88
+ * The caller owns nothing: the temporary directory is always removed.
89
+ */
90
+ export function extractNsisEntry(installer, entryName, { sevenZip = resolveSevenZip(), run = runSevenZip, tempRoot = os.tmpdir() } = {}) {
91
+ const resolvedInstaller = path.resolve(installer);
92
+ const base = path.posix.basename(String(entryName).replace(/\\/g, "/"));
93
+ if (!base || base === "." || base === "..") throw new Error(`invalid NSIS payload entry name: ${entryName}`);
94
+ const destination = path.join(tempRoot, `right-nsis-${process.pid}-${Math.random().toString(16).slice(2)}`);
95
+ mkdirSync(destination, { recursive: true });
96
+ try {
97
+ // `e` flattens the entry to the destination root, so the extracted path is
98
+ // known regardless of how deep the entry sits inside the payload.
99
+ const result = run(sevenZip, ["e", "-tnsis", "-y", `-o${destination}`, "--", resolvedInstaller, base]);
100
+ const extracted = path.join(destination, base);
101
+ if (result.status !== 0 || !existsSync(extracted)) {
102
+ throw new Error(
103
+ `could not extract ${base} from ${resolvedInstaller}: ${(result.stderr || result.stdout || "no such entry").trim()}`,
104
+ );
105
+ }
106
+ return { name: base, bytes: readFileSync(extracted) };
107
+ } finally {
108
+ rmSync(destination, { recursive: true, force: true });
109
+ }
110
+ }
111
+
112
+ /**
113
+ * The load-bearing check: the executable inside the installer must be the exact
114
+ * executable we signed. Returns a receipt; throws with the byte-level reason if
115
+ * the bytes drifted.
116
+ */
117
+ export function verifyNsisEmbeddedBinary({
118
+ installer,
119
+ entryName,
120
+ expectedSha256,
121
+ sevenZip = resolveSevenZip(),
122
+ run = runSevenZip,
123
+ tempRoot = os.tmpdir(),
124
+ }) {
125
+ if (!/^[0-9a-f]{64}$/i.test(String(expectedSha256 ?? ""))) {
126
+ throw new Error(`expected a sha256 digest for the signed ${entryName}, received ${JSON.stringify(expectedSha256)}`);
127
+ }
128
+ const { name, bytes } = extractNsisEntry(installer, entryName, { sevenZip, run, tempRoot });
129
+ const embeddedSha256 = createHash("sha256").update(bytes).digest("hex");
130
+ if (embeddedSha256.toLowerCase() !== String(expectedSha256).toLowerCase()) {
131
+ throw new Error(
132
+ `the installer does not embed the signed ${name}: signed sha256 ${expectedSha256}, embedded sha256 ${embeddedSha256}. `
133
+ + "Something rewrote the executable after it was signed, so its Authenticode signature is invalid inside the installer.",
134
+ );
135
+ }
136
+ return {
137
+ installer: path.resolve(installer),
138
+ entry: name,
139
+ sha256: embeddedSha256,
140
+ sizeBytes: bytes.length,
141
+ installerSha256: createHash("sha256").update(readFileSync(path.resolve(installer))).digest("hex"),
142
+ };
143
+ }
144
+
145
+ function runSevenZip(command, args) {
146
+ return spawnSync(command, args, { encoding: "utf8", windowsHide: true, maxBuffer: 32 * 1024 * 1024 });
147
+ }
148
+
149
+ function commandPath(name) {
150
+ const probe = process.platform === "win32" ? "where" : "which";
151
+ const result = spawnSync(probe, [name], { encoding: "utf8", windowsHide: true });
152
+ if (result.status !== 0) return null;
153
+ const first = String(result.stdout).split(/\r?\n/).map((line) => line.trim()).find(Boolean);
154
+ return first && existsSync(first) ? first : null;
155
+ }
@@ -0,0 +1,139 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import test, { after } from "node:test";
7
+ import { listNsisPayload, parseSevenZipListing, resolveSevenZip, verifyNsisEmbeddedBinary } from "./nsis-payload.mjs";
8
+
9
+ const workspace = mkdtempSync(path.join(tmpdir(), "rightkit-nsis-"));
10
+ after(() => rmSync(workspace, { recursive: true, force: true }));
11
+
12
+ // 7-Zip's technical listing: an archive record, a rule, then one record per member.
13
+ // The blank `Attributes` and absent timestamps are what the column-aligned listing
14
+ // gets wrong, so they are reproduced here deliberately.
15
+ const LISTING = [
16
+ "Path = D:\\out\\App_1.2.3_x64-setup.exe",
17
+ "Type = Nsis",
18
+ "",
19
+ "----------",
20
+ "Path = $PLUGINSDIR\\System.dll",
21
+ "Size = 12288",
22
+ "Attributes = ",
23
+ "",
24
+ "Path = app with spaces.exe",
25
+ "Size = 13750736",
26
+ "Attributes = ",
27
+ "",
28
+ "Path = legal",
29
+ "Size = 0",
30
+ "Folder = +",
31
+ "Attributes = D",
32
+ "",
33
+ "Path = legal\\EULA.md",
34
+ "Size = 16723",
35
+ "Attributes = A",
36
+ "",
37
+ ].join("\n");
38
+
39
+ test("parses 7-Zip technical listings, including blank dates and spaced names", () => {
40
+ const entries = parseSevenZipListing(LISTING);
41
+ assert.deepEqual(entries.map((entry) => entry.name), [
42
+ "$PLUGINSDIR/System.dll",
43
+ "app with spaces.exe",
44
+ "legal/EULA.md",
45
+ ]);
46
+ assert.equal(entries[1].sizeBytes, 13_750_736);
47
+ });
48
+
49
+ test("ignores the archive's own record above the rule", () => {
50
+ const entries = parseSevenZipListing(LISTING);
51
+ assert.equal(entries.some((entry) => entry.name.endsWith("setup.exe")), false);
52
+ });
53
+
54
+ test("listNsisPayload surfaces the 7-Zip failure instead of reporting an empty payload", () => {
55
+ assert.throws(
56
+ () => listNsisPayload("installer.exe", {
57
+ sevenZip: "7z",
58
+ run: () => ({ status: 2, stdout: "", stderr: "Cannot open the file as archive" }),
59
+ }),
60
+ /could not read the NSIS payload.*Cannot open the file as archive/s,
61
+ );
62
+ });
63
+
64
+ test("verifyNsisEmbeddedBinary accepts a payload whose bytes match the signed EXE", () => {
65
+ const installer = path.join(workspace, "setup.exe");
66
+ writeFileSync(installer, "installer-container");
67
+ const payload = Buffer.from("signed-executable-bytes");
68
+ const sha256 = createHash("sha256").update(payload).digest("hex");
69
+
70
+ const receipt = verifyNsisEmbeddedBinary({
71
+ installer,
72
+ entryName: "app.exe",
73
+ expectedSha256: sha256,
74
+ sevenZip: "7z",
75
+ run: (_command, args) => {
76
+ const destination = args.find((arg) => arg.startsWith("-o")).slice(2);
77
+ writeFileSync(path.join(destination, "app.exe"), payload);
78
+ return { status: 0, stdout: "Everything is Ok", stderr: "" };
79
+ },
80
+ });
81
+
82
+ assert.equal(receipt.sha256, sha256);
83
+ assert.equal(receipt.entry, "app.exe");
84
+ assert.equal(receipt.sizeBytes, payload.length);
85
+ assert.equal(receipt.installerSha256, createHash("sha256").update(readFileSync(installer)).digest("hex"));
86
+ });
87
+
88
+ test("verifyNsisEmbeddedBinary rejects a payload that drifted from the signed EXE", () => {
89
+ const installer = path.join(workspace, "setup-drifted.exe");
90
+ writeFileSync(installer, "installer-container");
91
+ assert.throws(
92
+ () => verifyNsisEmbeddedBinary({
93
+ installer,
94
+ entryName: "app.exe",
95
+ expectedSha256: createHash("sha256").update("signed-executable-bytes").digest("hex"),
96
+ sevenZip: "7z",
97
+ run: (_command, args) => {
98
+ const destination = args.find((arg) => arg.startsWith("-o")).slice(2);
99
+ writeFileSync(path.join(destination, "app.exe"), "rewritten-after-signing");
100
+ return { status: 0, stdout: "Everything is Ok", stderr: "" };
101
+ },
102
+ }),
103
+ /does not embed the signed app\.exe.*Authenticode signature is invalid/s,
104
+ );
105
+ });
106
+
107
+ test("verifyNsisEmbeddedBinary fails when the entry is absent rather than passing vacuously", () => {
108
+ const installer = path.join(workspace, "setup-missing.exe");
109
+ writeFileSync(installer, "installer-container");
110
+ assert.throws(
111
+ () => verifyNsisEmbeddedBinary({
112
+ installer,
113
+ entryName: "app.exe",
114
+ expectedSha256: "a".repeat(64),
115
+ sevenZip: "7z",
116
+ run: () => ({ status: 2, stdout: "", stderr: "No files to process" }),
117
+ }),
118
+ /could not extract app\.exe/,
119
+ );
120
+ });
121
+
122
+ test("verifyNsisEmbeddedBinary refuses a caller that has no digest to compare against", () => {
123
+ assert.throws(
124
+ () => verifyNsisEmbeddedBinary({ installer: "setup.exe", entryName: "app.exe", expectedSha256: undefined, sevenZip: "7z", run: () => ({ status: 0 }) }),
125
+ /expected a sha256 digest/,
126
+ );
127
+ });
128
+
129
+ test("resolveSevenZip names the fix when 7-Zip is absent", () => {
130
+ assert.throws(
131
+ () => resolveSevenZip({ env: {}, exists: () => false, which: () => null }),
132
+ /install 7-Zip.*RIGHT_RELEASE_SEVENZIP/s,
133
+ );
134
+ });
135
+
136
+ test("resolveSevenZip honours an explicit override and rejects a bad one", () => {
137
+ assert.equal(resolveSevenZip({ env: { RIGHT_RELEASE_SEVENZIP: "C:\\tools\\7z.exe" }, exists: () => true }), "C:\\tools\\7z.exe");
138
+ assert.throws(() => resolveSevenZip({ env: { RIGHT_RELEASE_SEVENZIP: "C:\\gone\\7z.exe" }, exists: () => false }), /does not exist/);
139
+ });
package/package.json CHANGED
@@ -1,31 +1,32 @@
1
- {
2
- "name": "@rightkit/release",
3
- "version": "0.2.56",
4
- "description": "Portable Right Suite release CLI/SDK: signed installers, updater artifacts, patch/update routing, hardening, R2 publish, and RightApps registration.",
5
- "type": "module",
6
- "bin": {
7
- "right-release": "cli/right-release.mjs"
8
- },
9
- "files": [
10
- "cli",
11
- "*.mjs",
12
- "*.json",
13
- "*.sh",
14
- "*.py"
15
- ],
16
- "sideEffects": false,
17
- "publishConfig": {
18
- "registry": "https://registry.npmjs.org/",
19
- "access": "public"
20
- },
21
- "repository": {
22
- "type": "git",
23
- "url": "git+https://github.com/adrdsouza/claude.git",
24
- "directory": "tools/rightkit/packages/release"
25
- },
26
- "scripts": {
27
- "test": "node --test *.test.mjs",
28
- "doctor:all": "node --test right-suite-contract.test.mjs",
29
- "verify:standalone": "node standalone-clone-verify.mjs"
30
- }
31
- }
1
+ {
2
+ "name": "@rightkit/release",
3
+ "version": "0.2.57",
4
+ "description": "Portable Right Suite release CLI/SDK: signed installers, updater artifacts, patch/update routing, hardening, R2 publish, and RightApps registration.",
5
+ "type": "module",
6
+ "bin": {
7
+ "right-release": "cli/right-release.mjs"
8
+ },
9
+ "files": [
10
+ "cli",
11
+ "*.mjs",
12
+ "*.json",
13
+ "*.sh",
14
+ "*.py"
15
+ ],
16
+ "sideEffects": false,
17
+ "scripts": {
18
+ "test": "node --test *.test.mjs",
19
+ "doctor:all": "node --test right-suite-contract.test.mjs",
20
+ "verify:standalone": "node standalone-clone-verify.mjs"
21
+ },
22
+ "publishConfig": {
23
+ "registry": "https://registry.npmjs.org/",
24
+ "access": "public"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/adrdsouza/claude.git",
29
+ "directory": "tools/rightkit/packages/release"
30
+ },
31
+ "packageManager": "pnpm@11.18.0"
32
+ }
package/release.mjs CHANGED
@@ -10,6 +10,8 @@ import { assertQaBackdoorContract } from "./qa-contract.mjs";
10
10
  import { assertLegalReleaseContract } from "./legal-contract.mjs";
11
11
  import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
12
12
  import { collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
13
+ import { patchTauriBundleType } from "./tauri-bundle-marker.mjs";
14
+ import { verifyNsisEmbeddedBinary } from "./nsis-payload.mjs";
13
15
 
14
16
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
15
17
  const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
@@ -193,10 +195,19 @@ if (!opts.skipChecks) {
193
195
  for (const script of config.checks ?? []) await runPackageScript(config.packageManager, script, workdir);
194
196
  }
195
197
 
198
+ let bundleMarkerReceipts = [];
196
199
  if (opts.platform === "win") {
197
200
  const rawFiles = target.sign.prePackageFiles.map((p) => path.resolve(root, p));
198
201
  await runCommand(target.prePackage, root);
199
202
  for (const file of rawFiles) await mustExist(file, `missing raw EXE signing artifact: ${file}`);
203
+ // The marker must reach its FINAL value before signing. Tauri rewrites it during
204
+ // bundling, and doing that to signed bytes invalidates Authenticode — the exact
205
+ // defect that shipped invalid binaries. Idempotent, so an app that still patches
206
+ // in its own prePackage script simply reports alreadyPatched here.
207
+ bundleMarkerReceipts = opts.dryRun ? [] : rawFiles.map((file) => patchTauriBundleType(file, { bundle: target.bundleMarker ?? "nsis" }));
208
+ for (const receipt of bundleMarkerReceipts) {
209
+ console.log(`right-release: bundle marker ${receipt.bundle} ${receipt.alreadyPatched ? "already applied" : "applied"} at offset ${receipt.offset} in ${path.basename(receipt.file)}`);
210
+ }
200
211
  await signWindows(rawFiles, "raw-exe", root);
201
212
  }
202
213
 
@@ -209,6 +220,10 @@ for (const rel of target.artifacts ?? []) {
209
220
  if (opts.platform === "win" && target.sign?.files?.length) {
210
221
  const files = target.sign.files.map((p) => path.resolve(root, p));
211
222
  for (const file of files) await mustExist(file, `missing signing artifact: ${file}`);
223
+ // Before the installer earns its own signature, prove it carries the bytes we
224
+ // signed. An installer-only signature on a mutated payload verifies perfectly
225
+ // and still installs a binary Windows rejects.
226
+ await verifyWindowsEmbedding({ root, target, files, markers: bundleMarkerReceipts });
212
227
  await signWindows(files, "installer", root);
213
228
  if (target.postSign) await runCommand(target.postSign, root);
214
229
  const updaterFiles = [...new Set((target.updater?.artifacts ?? []).map((artifact) => path.resolve(root, artifact.file)))];
@@ -406,6 +421,69 @@ async function signWindows(files, phase, root) {
406
421
  if (!opts.dryRun) verifyWindowsReceipt(receipt, files, phase);
407
422
  }
408
423
 
424
+ function windowsReceiptPath(root, phase) {
425
+ return path.join(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(root, ".right-release", "receipts"), `windows-${phase}.json`);
426
+ }
427
+
428
+ /**
429
+ * Close the gap between "the raw EXE was signed" and "the installer ships that
430
+ * EXE". Three independent facts, each able to fail on its own:
431
+ * 1. the signed EXE on disk is byte-identical to what the signing receipt says
432
+ * (catches an in-place rewrite during bundling),
433
+ * 2. it still verifies as Authenticode-valid (catches a mutation that somehow
434
+ * preserved the hash we recorded), and
435
+ * 3. the executable extracted back out of the installer matches it byte for
436
+ * byte (catches a bundler that patched its own copy and left ours alone).
437
+ * Only the third is proof the shipped artifact is correct; the first two name
438
+ * the cause when it is not.
439
+ */
440
+ async function verifyWindowsEmbedding({ root, target, files, markers }) {
441
+ if (opts.dryRun) {
442
+ console.log("dry-run: verify installer embeds the signed raw EXE");
443
+ return;
444
+ }
445
+ const rawFile = path.resolve(root, target.sign.prePackageFiles[0]);
446
+ const rawReceiptPath = windowsReceiptPath(root, "raw-exe");
447
+ let rawReceipt;
448
+ try { rawReceipt = JSON.parse(readFileSync(rawReceiptPath, "utf8")); } catch { fail(`missing raw EXE signing receipt: ${rawReceiptPath}`); }
449
+ const signed = rawReceipt.files?.find((item) => path.resolve(item.file) === rawFile);
450
+ const signedSha256 = signed?.after?.sha256;
451
+ if (!signedSha256) fail(`raw EXE signing receipt does not record a signed digest for ${rawFile}`);
452
+
453
+ await mustExist(rawFile, `signed raw EXE disappeared before packaging completed: ${rawFile}`);
454
+ const currentSha256 = createHash("sha256").update(readFileSync(rawFile)).digest("hex");
455
+ if (currentSha256 !== signedSha256) {
456
+ fail(
457
+ `packaging rewrote the signed raw EXE: signed sha256 ${signedSha256}, on-disk sha256 ${currentSha256}. `
458
+ + "Its Authenticode signature is now invalid. Apply every deterministic byte patch before signing, never after.",
459
+ );
460
+ }
461
+ // signtool, not inference: a hash match proves the bytes, this proves the chain.
462
+ await run("node", [SIGN_WINDOWS, "--verify-only", rawFile], root);
463
+
464
+ const embedded = [];
465
+ for (const installer of files) {
466
+ let receipt;
467
+ try {
468
+ receipt = verifyNsisEmbeddedBinary({ installer, entryName: path.basename(rawFile), expectedSha256: signedSha256 });
469
+ } catch (error) {
470
+ fail(error.message);
471
+ }
472
+ console.log(`right-release: ${path.basename(installer)} embeds the signed ${receipt.entry} (sha256 ${receipt.sha256.slice(0, 16)}…)`);
473
+ embedded.push(receipt);
474
+ }
475
+
476
+ const receiptPath = windowsReceiptPath(root, "embedding");
477
+ mkdirSync(path.dirname(receiptPath), { recursive: true });
478
+ writeFileSync(receiptPath, `${JSON.stringify({
479
+ schema: 1,
480
+ rawExe: { file: rawFile, signedSha256, verifiedSha256: currentSha256 },
481
+ bundleMarkers: markers ?? [],
482
+ embedded,
483
+ verifiedAt: new Date().toISOString(),
484
+ }, null, 2)}\n`);
485
+ }
486
+
409
487
  function verifyWindowsReceipt(receipt, files, phase) {
410
488
  if (!existsSync(receipt)) fail(`missing ${phase} Windows signing receipt: ${receipt}`);
411
489
  let evidence;
package/release.test.mjs CHANGED
@@ -372,6 +372,17 @@ test("Windows tier-neutral build builds raw EXE, signs it, bundles, signs instal
372
372
  assert.doesNotMatch(result.stdout, /publish-update\.mjs/);
373
373
  });
374
374
 
375
+ test("Windows verifies the installer embeds the signed EXE after bundling and before the installer is signed", () => {
376
+ const result = run(fixture({ publish: true }));
377
+ assert.equal(result.status, 0, result.stderr);
378
+ const rawSignAt = result.stdout.indexOf("signing windows raw-exe");
379
+ const bundleAt = result.stdout.indexOf("node -e", rawSignAt);
380
+ const embeddingAt = result.stdout.indexOf("verify installer embeds the signed raw EXE");
381
+ const installerSignAt = result.stdout.indexOf("signing windows installer");
382
+ assert.ok(embeddingAt > bundleAt, `embedding check must follow bundling:\n${result.stdout}`);
383
+ assert.ok(installerSignAt > embeddingAt, `a mutated payload must never reach the installer signature:\n${result.stdout}`);
384
+ });
385
+
375
386
  test("legacy internal tier does not make the build worker upload", () => {
376
387
  const result = run(fixture({ publish: true }), "--tier=update");
377
388
  assert.equal(result.status, 0, result.stderr);
@@ -464,11 +464,13 @@ test("RightKit exposes one current version manifest", () => {
464
464
  "@rightkit/legal": "0.3.0",
465
465
  "@rightkit/legal-ui": "0.1.1",
466
466
  "@rightkit/license": "0.1.6",
467
- "@rightkit/release": "0.2.56",
467
+ "@rightkit/release": "0.2.57",
468
+ "@rightkit/qa": "0.2.0",
468
469
  });
469
470
  assert.deepEqual(versions.legacyNpm, {
470
471
  "@rightkit/legal-ui": ["0.1.0"],
471
- "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49", "0.2.50", "0.2.51", "0.2.53", "0.2.54", "0.2.55"],
472
+ "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49", "0.2.50", "0.2.51", "0.2.53", "0.2.54", "0.2.55", "0.2.56"],
473
+ "@rightkit/qa": ["0.1.0"],
472
474
  });
473
475
  assert.ok(
474
476
  new Set([versions.npm["@rightkit/release"], ...versions.legacyNpm["@rightkit/release"]]).has("0.2.42"),
@@ -1,43 +1,67 @@
1
- {
2
- "schema": 2,
3
- "packageManager": "pnpm@11.18.0",
4
- "npm": {
5
- "@rightkit/git": "0.2.0",
6
- "@rightkit/legal": "0.2.0",
7
- "@rightkit/license": "0.1.5",
8
- "@rightkit/logs": "0.1.3",
9
- "@rightkit/platform-ui": "0.1.0",
10
- "@rightkit/qa": "0.1.0",
11
- "@rightkit/release": "0.2.47",
12
- "@rightkit/tauri": "0.1.0",
13
- "@rightkit/updates": "0.2.3"
14
- },
15
- "stagedNpm": {
16
- "@rightkit/ax": "0.2.0",
17
- "@rightkit/git": "0.2.0",
18
- "@rightkit/legal": "0.3.0",
19
- "@rightkit/legal-ui": "0.1.1",
20
- "@rightkit/license": "0.1.6",
21
- "@rightkit/release": "0.2.56"
22
- },
23
- "legacyNpm": {
24
- "@rightkit/legal-ui": ["0.1.0"],
25
- "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49", "0.2.50", "0.2.51", "0.2.53", "0.2.54", "0.2.55"]
26
- },
27
- "cargo": {
28
- "rightkit-license": "0.1.2",
29
- "rightkit-logs": "0.1.0",
30
- "rightkit-process": "0.1.0",
31
- "rightkit-tauri": "0.1.0"
32
- },
33
- "stagedCargo": {
34
- "rightkit-license": "0.1.3",
35
- "rightkit-tauri": "0.1.1"
36
- },
37
- "swift": {
38
- "rightkit-swift": {
39
- "url": "https://github.com/bogusyogi/rightkit-swift.git",
40
- "version": "0.1.0"
41
- }
42
- }
43
- }
1
+ {
2
+ "schema": 2,
3
+ "packageManager": "pnpm@11.18.0",
4
+ "npm": {
5
+ "@rightkit/git": "0.2.0",
6
+ "@rightkit/legal": "0.2.0",
7
+ "@rightkit/license": "0.1.5",
8
+ "@rightkit/logs": "0.1.3",
9
+ "@rightkit/platform-ui": "0.1.0",
10
+ "@rightkit/qa": "0.1.0",
11
+ "@rightkit/release": "0.2.47",
12
+ "@rightkit/tauri": "0.1.0",
13
+ "@rightkit/updates": "0.2.3"
14
+ },
15
+ "stagedNpm": {
16
+ "@rightkit/ax": "0.2.0",
17
+ "@rightkit/git": "0.2.0",
18
+ "@rightkit/legal": "0.3.0",
19
+ "@rightkit/legal-ui": "0.1.1",
20
+ "@rightkit/license": "0.1.6",
21
+ "@rightkit/release": "0.2.57",
22
+ "@rightkit/qa": "0.2.0"
23
+ },
24
+ "legacyNpm": {
25
+ "@rightkit/legal-ui": [
26
+ "0.1.0"
27
+ ],
28
+ "@rightkit/release": [
29
+ "0.2.22",
30
+ "0.2.29",
31
+ "0.2.30",
32
+ "0.2.31",
33
+ "0.2.41",
34
+ "0.2.42",
35
+ "0.2.43",
36
+ "0.2.44",
37
+ "0.2.45",
38
+ "0.2.46",
39
+ "0.2.49",
40
+ "0.2.50",
41
+ "0.2.51",
42
+ "0.2.53",
43
+ "0.2.54",
44
+ "0.2.55",
45
+ "0.2.56"
46
+ ],
47
+ "@rightkit/qa": [
48
+ "0.1.0"
49
+ ]
50
+ },
51
+ "cargo": {
52
+ "rightkit-license": "0.1.2",
53
+ "rightkit-logs": "0.1.0",
54
+ "rightkit-process": "0.1.0",
55
+ "rightkit-tauri": "0.1.0"
56
+ },
57
+ "stagedCargo": {
58
+ "rightkit-license": "0.1.3",
59
+ "rightkit-tauri": "0.1.1"
60
+ },
61
+ "swift": {
62
+ "rightkit-swift": {
63
+ "url": "https://github.com/bogusyogi/rightkit-swift.git",
64
+ "version": "0.1.0"
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,165 @@
1
+ // Tauri stamps the bundle kind into the compiled binary as an ASCII marker, and
2
+ // its NSIS bundler rewrites that marker WHILE bundling. Doing that to an EXE we
3
+ // already Authenticode-signed silently invalidates the signature — the installer
4
+ // then ships a binary Windows reports as unsigned/corrupt, which is exactly the
5
+ // failure this module exists to make impossible.
6
+ //
7
+ // The contract: patch the marker to its final value BEFORE signing, so Tauri's
8
+ // own rewrite finds nothing left to change and the signed bytes survive intact.
9
+ // Fail closed on anything ambiguous — a missing marker, more than one marker, or
10
+ // a byte changing outside the marker all mean the assumption no longer holds.
11
+
12
+ import { createHash } from "node:crypto";
13
+ import { readFileSync, writeFileSync } from "node:fs";
14
+ import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const MARKER_PREFIX = "__TAURI_BUNDLE_TYPE_VAR_";
18
+
19
+ export const BUNDLE_MARKERS = Object.freeze({
20
+ unknown: "UNK",
21
+ nsis: "NSS",
22
+ msi: "MSI",
23
+ app: "APP",
24
+ dmg: "DMG",
25
+ deb: "DEB",
26
+ rpm: "RPM",
27
+ appimage: "AIM",
28
+ });
29
+
30
+ export function bundleMarkerBuffer(code) {
31
+ if (typeof code !== "string" || code.length !== 3) {
32
+ throw new Error(`Tauri bundle marker code must be exactly 3 ASCII characters, received ${JSON.stringify(code)}`);
33
+ }
34
+ return Buffer.from(`${MARKER_PREFIX}${code}`, "ascii");
35
+ }
36
+
37
+ /**
38
+ * Rewrite the single unknown bundle marker to the requested bundle kind.
39
+ *
40
+ * Idempotent by design: a binary already carrying the target marker (and no
41
+ * unknown marker) is reported as `alreadyPatched` rather than failing, so a
42
+ * retried build does not have to distinguish "never patched" from "resumed".
43
+ */
44
+ export function patchTauriBundleType(file, { bundle = "nsis", read = readFileSync, write = writeFileSync } = {}) {
45
+ const targetCode = BUNDLE_MARKERS[bundle];
46
+ if (!targetCode) {
47
+ throw new Error(`unsupported Tauri bundle kind: ${bundle} (expected one of ${Object.keys(BUNDLE_MARKERS).join(", ")})`);
48
+ }
49
+ const resolved = path.resolve(file);
50
+ const unknownMarker = bundleMarkerBuffer(BUNDLE_MARKERS.unknown);
51
+ const targetMarker = bundleMarkerBuffer(targetCode);
52
+
53
+ const before = read(resolved);
54
+ const beforeSha256 = sha256(before);
55
+ const unknownOffsets = findAll(before, unknownMarker);
56
+ const targetOffsets = findAll(before, targetMarker);
57
+
58
+ if (unknownOffsets.length === 0) {
59
+ if (targetOffsets.length === 1) {
60
+ return {
61
+ file: resolved,
62
+ bundle,
63
+ offset: targetOffsets[0],
64
+ changedBytes: 0,
65
+ alreadyPatched: true,
66
+ size: before.length,
67
+ beforeSha256,
68
+ afterSha256: beforeSha256,
69
+ };
70
+ }
71
+ throw new Error(
72
+ `no Tauri unknown bundle marker in ${resolved} and ${targetOffsets.length} ${targetCode} markers; refusing to guess the bundle identity`,
73
+ );
74
+ }
75
+ if (unknownOffsets.length !== 1) {
76
+ throw new Error(`expected exactly one Tauri unknown bundle marker in ${resolved}; found ${unknownOffsets.length}`);
77
+ }
78
+
79
+ const offset = unknownOffsets[0];
80
+ const after = Buffer.from(before);
81
+ targetMarker.copy(after, offset);
82
+
83
+ const expectedChanges = markerChangeOffsets(unknownMarker, targetMarker, offset);
84
+ const actualChanges = [];
85
+ for (let index = 0; index < before.length; index += 1) {
86
+ if (before[index] !== after[index]) actualChanges.push(index);
87
+ }
88
+ if (actualChanges.join(",") !== expectedChanges.join(",")) {
89
+ throw new Error(`unexpected Tauri bundle marker mutation in ${resolved}`);
90
+ }
91
+
92
+ write(resolved, after);
93
+ const written = read(resolved);
94
+ if (!written.equals(after) || findAll(written, unknownMarker).length !== 0) {
95
+ throw new Error(`failed to verify Tauri ${targetCode} bundle marker in ${resolved}`);
96
+ }
97
+
98
+ return {
99
+ file: resolved,
100
+ bundle,
101
+ offset,
102
+ changedBytes: actualChanges.length,
103
+ alreadyPatched: false,
104
+ size: written.length,
105
+ beforeSha256,
106
+ afterSha256: sha256(written),
107
+ };
108
+ }
109
+
110
+ /** Back-compat alias for the original app-local helper name. */
111
+ export function patchTauriBundleTypeForNsis(file, options = {}) {
112
+ return patchTauriBundleType(file, { ...options, bundle: "nsis" });
113
+ }
114
+
115
+ /**
116
+ * Report which markers a binary currently carries, without writing. Used by the
117
+ * post-package audit to prove the packaged binary is the one that was signed.
118
+ */
119
+ export function readTauriBundleMarkers(file, { read = readFileSync } = {}) {
120
+ const resolved = path.resolve(file);
121
+ const bytes = read(resolved);
122
+ const markers = {};
123
+ for (const [bundle, code] of Object.entries(BUNDLE_MARKERS)) {
124
+ const offsets = findAll(bytes, bundleMarkerBuffer(code));
125
+ if (offsets.length) markers[bundle] = offsets;
126
+ }
127
+ return { file: resolved, size: bytes.length, sha256: sha256(bytes), markers };
128
+ }
129
+
130
+ function findAll(haystack, needle) {
131
+ const offsets = [];
132
+ for (let offset = haystack.indexOf(needle); offset !== -1; offset = haystack.indexOf(needle, offset + 1)) {
133
+ offsets.push(offset);
134
+ }
135
+ return offsets;
136
+ }
137
+
138
+ function markerChangeOffsets(from, to, offset) {
139
+ const changes = [];
140
+ for (let index = 0; index < from.length; index += 1) {
141
+ if (from[index] !== to[index]) changes.push(offset + index);
142
+ }
143
+ return changes;
144
+ }
145
+
146
+ function sha256(bytes) {
147
+ return createHash("sha256").update(bytes).digest("hex");
148
+ }
149
+
150
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
151
+ const argv = process.argv.slice(2);
152
+ const bundleIndex = argv.indexOf("--bundle");
153
+ const bundle = bundleIndex >= 0 ? argv[bundleIndex + 1] : "nsis";
154
+ const file = argv.filter((arg, index) => arg !== "--bundle" && index !== bundleIndex + 1)[0];
155
+ if (!file) {
156
+ console.error("usage: node tauri-bundle-marker.mjs [--bundle nsis] <binary>");
157
+ process.exit(2);
158
+ }
159
+ try {
160
+ console.log(JSON.stringify(patchTauriBundleType(file, { bundle })));
161
+ } catch (error) {
162
+ console.error(`tauri-bundle-marker: ${error.message}`);
163
+ process.exit(1);
164
+ }
165
+ }
@@ -0,0 +1,81 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { BUNDLE_MARKERS, patchTauriBundleType, patchTauriBundleTypeForNsis, readTauriBundleMarkers } from "./tauri-bundle-marker.mjs";
4
+
5
+ /** A stand-in binary: filler, one marker, filler. */
6
+ function fakeBinary(code = BUNDLE_MARKERS.unknown, { copies = 1 } = {}) {
7
+ const parts = [Buffer.alloc(64, 0x41)];
8
+ for (let index = 0; index < copies; index += 1) {
9
+ parts.push(Buffer.from(`__TAURI_BUNDLE_TYPE_VAR_${code}`, "ascii"), Buffer.alloc(32, 0x42));
10
+ }
11
+ return Buffer.concat(parts);
12
+ }
13
+
14
+ function memoryFs(initial) {
15
+ const store = new Map([["binary.exe", Buffer.from(initial)]]);
16
+ return {
17
+ store,
18
+ read: (file) => {
19
+ const key = file.endsWith("binary.exe") ? "binary.exe" : file;
20
+ const value = store.get(key);
21
+ if (!value) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
22
+ return Buffer.from(value);
23
+ },
24
+ write: (file, bytes) => {
25
+ store.set(file.endsWith("binary.exe") ? "binary.exe" : file, Buffer.from(bytes));
26
+ },
27
+ };
28
+ }
29
+
30
+ test("rewrites the single unknown marker to the NSIS marker and nothing else", () => {
31
+ const fs = memoryFs(fakeBinary());
32
+ const before = fs.read("binary.exe");
33
+ const receipt = patchTauriBundleTypeForNsis("binary.exe", { read: fs.read, write: fs.write });
34
+ const after = fs.read("binary.exe");
35
+
36
+ assert.equal(receipt.alreadyPatched, false);
37
+ assert.equal(receipt.bundle, "nsis");
38
+ assert.equal(receipt.changedBytes, 3, "only the 3-character bundle code may change");
39
+ assert.equal(after.length, before.length, "patching must not resize the binary");
40
+ assert.equal(after.includes(Buffer.from("__TAURI_BUNDLE_TYPE_VAR_NSS")), true);
41
+ assert.equal(after.includes(Buffer.from("__TAURI_BUNDLE_TYPE_VAR_UNK")), false);
42
+ assert.notEqual(receipt.beforeSha256, receipt.afterSha256);
43
+ });
44
+
45
+ test("is idempotent: an already-patched binary is reported, not rewritten", () => {
46
+ const fs = memoryFs(fakeBinary(BUNDLE_MARKERS.nsis));
47
+ const receipt = patchTauriBundleTypeForNsis("binary.exe", { read: fs.read, write: fs.write });
48
+ assert.equal(receipt.alreadyPatched, true);
49
+ assert.equal(receipt.changedBytes, 0);
50
+ assert.equal(receipt.beforeSha256, receipt.afterSha256, "an already-patched binary keeps its signed bytes");
51
+ });
52
+
53
+ test("fails closed when the binary carries more than one unknown marker", () => {
54
+ const fs = memoryFs(fakeBinary(BUNDLE_MARKERS.unknown, { copies: 2 }));
55
+ assert.throws(
56
+ () => patchTauriBundleTypeForNsis("binary.exe", { read: fs.read, write: fs.write }),
57
+ /found 2/,
58
+ );
59
+ });
60
+
61
+ test("fails closed when no marker of any kind is present", () => {
62
+ const fs = memoryFs(Buffer.alloc(128, 0x41));
63
+ assert.throws(
64
+ () => patchTauriBundleTypeForNsis("binary.exe", { read: fs.read, write: fs.write }),
65
+ /refusing to guess the bundle identity/,
66
+ );
67
+ });
68
+
69
+ test("rejects an unsupported bundle kind before touching the file", () => {
70
+ const fs = memoryFs(fakeBinary());
71
+ assert.throws(() => patchTauriBundleType("binary.exe", { bundle: "zip", read: fs.read, write: fs.write }), /unsupported Tauri bundle kind/);
72
+ assert.equal(fs.read("binary.exe").includes(Buffer.from("__TAURI_BUNDLE_TYPE_VAR_UNK")), true);
73
+ });
74
+
75
+ test("readTauriBundleMarkers reports offsets without writing", () => {
76
+ const fs = memoryFs(fakeBinary(BUNDLE_MARKERS.nsis));
77
+ const report = readTauriBundleMarkers("binary.exe", { read: fs.read });
78
+ assert.deepEqual(report.markers.nsis, [64]);
79
+ assert.equal(report.markers.unknown, undefined);
80
+ assert.match(report.sha256, /^[0-9a-f]{64}$/);
81
+ });