From 70bbe860f85cf62f0027419848d444dccae98492 Mon Sep 17 00:00:00 2001 From: u8array Date: Tue, 6 Oct 2026 17:35:45 +0200 Subject: [PATCH] fix(msix): the sidecar drops node's Authenticode table before postject --- .github/workflows/msix.yml | 4 ++ packages/mcp-server/scripts/build-sea.mjs | 6 +- packages/mcp-server/scripts/pe.d.mts | 5 ++ packages/mcp-server/scripts/pe.mjs | 44 +++++++++++++ packages/mcp-server/scripts/smoke-sea.mjs | 21 ++++-- scripts/build-msix.mjs | 7 +- src/lib/peScripts.test.ts | 78 +++++++++++++++++++++++ 7 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 packages/mcp-server/scripts/pe.d.mts create mode 100644 packages/mcp-server/scripts/pe.mjs create mode 100644 src/lib/peScripts.test.ts diff --git a/.github/workflows/msix.yml b/.github/workflows/msix.yml index 1925f399..5ee0961f 100644 --- a/.github/workflows/msix.yml +++ b/.github/workflows/msix.yml @@ -12,6 +12,7 @@ on: - 'pnpm-lock.yaml' - 'scripts/build-msix.mjs' - 'scripts/msix.mjs' + - 'packages/mcp-server/scripts/**' - '.github/workflows/msix.yml' concurrency: @@ -51,6 +52,9 @@ jobs: - run: pnpm msix:build + # The sidecar had its Authenticode table cut off, so prove the SEA binary still starts. + - run: pnpm --filter @zplab/mcp-server build:sea && pnpm --filter @zplab/mcp-server smoke:sea + # Launched through its package identity, as a Store install runs. - name: smoke-launch the packaged app # Windows PowerShell, since the UI Automation assemblies are .NET Framework only. diff --git a/packages/mcp-server/scripts/build-sea.mjs b/packages/mcp-server/scripts/build-sea.mjs index de40dc8a..c68f0def 100644 --- a/packages/mcp-server/scripts/build-sea.mjs +++ b/packages/mcp-server/scripts/build-sea.mjs @@ -7,6 +7,7 @@ import { createRequire } from "node:module"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; +import { stripCertificateTable } from "./pe.mjs"; const pkgDir = dirname(dirname(fileURLToPath(import.meta.url))); const { values } = parseArgs({ @@ -86,8 +87,9 @@ if (triple) { } } copyFileSync(values.node, exe); -// postject invalidates the signature and macOS arm64 refuses unsigned -// binaries: strip before injecting, ad-hoc re-sign after. +// postject's appended section would leave the Authenticode entry pointing inside the file. +if (isWindows) stripCertificateTable(exe); +// macOS arm64 refuses unsigned binaries, so the ad-hoc signature goes back on after injecting. if (isMac) run("codesign", ["--remove-signature", exe]); runBin("postject/dist/cli.js", [ exe, diff --git a/packages/mcp-server/scripts/pe.d.mts b/packages/mcp-server/scripts/pe.d.mts new file mode 100644 index 00000000..2f907841 --- /dev/null +++ b/packages/mcp-server/scripts/pe.d.mts @@ -0,0 +1,5 @@ +// Types for the node-only PE helpers, so peScripts.test.ts can import them under the app tsconfig. +export declare function certificateTable(image: Buffer): { offset: number; length: number } | undefined; +export declare function withoutCertificateTable(image: Buffer): Buffer; +export declare function stripCertificateTable(file: string): void; +export declare function hasCertificateTable(file: string): boolean; diff --git a/packages/mcp-server/scripts/pe.mjs b/packages/mcp-server/scripts/pe.mjs new file mode 100644 index 00000000..a77b5702 --- /dev/null +++ b/packages/mcp-server/scripts/pe.mjs @@ -0,0 +1,44 @@ +// Split out of build-sea.mjs so peScripts.test.ts can check the header arithmetic on a synthetic binary. +import { readFileSync, writeFileSync } from "node:fs"; + +const E_LFANEW = 0x3c; +const PE32_PLUS = 0x20b; +// Data directory 4, at optional header + 112 + 4 * 8, holding a file offset rather than an RVA. +const CERTIFICATE_DIRECTORY = 144; + +function certificateDirectoryAt(image) { + const pe = image.length >= E_LFANEW + 4 ? image.readUInt32LE(E_LFANEW) : 0; + if (pe === 0 || image.toString("latin1", pe, pe + 4) !== "PE\0\0") throw new Error("not a PE image"); + const optional = pe + 4 + 20; + if (image.readUInt16LE(optional) !== PE32_PLUS) throw new Error("not a PE32+ image"); + return optional + CERTIFICATE_DIRECTORY; +} + +/** The Authenticode table an image carries, undefined when it has none. */ +export function certificateTable(image) { + const at = certificateDirectoryAt(image); + const offset = image.readUInt32LE(at); + const length = image.readUInt32LE(at + 4); + return offset === 0 && length === 0 ? undefined : { offset, length }; +} + +/** The image without its Authenticode table. The table must close the file, so nothing but the signature is cut. */ +export function withoutCertificateTable(image) { + const table = certificateTable(image); + if (!table) return image; + if (table.offset + table.length !== image.length) { + throw new Error(`certificate table ends at ${table.offset + table.length}, file at ${image.length}`); + } + const stripped = Buffer.from(image.subarray(0, table.offset)); + const at = certificateDirectoryAt(stripped); + stripped.fill(0, at, at + 8); + return stripped; +} + +export function stripCertificateTable(file) { + writeFileSync(file, withoutCertificateTable(readFileSync(file))); +} + +export function hasCertificateTable(file) { + return certificateTable(readFileSync(file)) !== undefined; +} diff --git a/packages/mcp-server/scripts/smoke-sea.mjs b/packages/mcp-server/scripts/smoke-sea.mjs index 638886ee..b686c66b 100644 --- a/packages/mcp-server/scripts/smoke-sea.mjs +++ b/packages/mcp-server/scripts/smoke-sea.mjs @@ -101,7 +101,11 @@ try { const tools = await rpc(2, "tools/list", {}); const names = tools.result?.tools?.map((t) => t.name) ?? []; - check("all 8 tools", names.length === 8, names.join(",")); + const expected = [ + "create_draft", "validate_draft", "patch_design", "export_zpl", "validate_zpl", "import_zpl", + "get_schema", "open_in_app", "edit_design", "raster_image", "get_current_design", + ]; + check("hosted tool set", expected.every((n) => names.includes(n)) && names.length === expected.length, names.join(",")); const created = await rpc(3, "tools/call", { name: "create_draft", @@ -135,10 +139,19 @@ try { }); check("wrong token 401", denied.status === 401); - const oia = await rpc(4, "tools/call", { name: "open_in_app", arguments: { designFile: design } }); - check("open_in_app", JSON.parse(oia.result.content[0].text).ok === true); + // Until a session is announced the window tools answer with a reason, so attach the way the app does on boot. + const attached = await fetch(`${BASE}app-attach`, { method: "POST", headers: H, body: JSON.stringify({ session: "smoke" }) }); + check("app-attach", attached.ok); + + // open_in_app resolves only on the receipt, so hold the call until the event is answered. + const pendingOpen = rpc(4, "tools/call", { name: "open_in_app", arguments: { designFile: design } }); await new Promise((r) => setTimeout(r, 200)); - check("openDraft stdout event", events.some((e) => e.zplabEvent === "openDraft")); + const draft = events.find((e) => e.zplabEvent === "openDraft"); + check("openDraft stdout event", !!draft); + if (draft) { + await fetch(`${BASE}draft-receipt`, { method: "POST", headers: H, body: JSON.stringify({ id: draft.id, ok: true, replacedObjects: 0 }) }); + check("open_in_app", JSON.parse((await pendingOpen).result.content[0].text).ok === true); + } const pendingCall = rpc(5, "tools/call", { name: "get_current_design", arguments: {} }); await new Promise((r) => setTimeout(r, 300)); diff --git a/scripts/build-msix.mjs b/scripts/build-msix.mjs index 134001a1..5bf3f77f 100644 --- a/scripts/build-msix.mjs +++ b/scripts/build-msix.mjs @@ -5,6 +5,7 @@ import { createRequire } from 'node:module'; import { basename, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { manifestExecutable, packageVersion, readManifest, stampVersion, storeVersion } from './msix.mjs'; +import { hasCertificateTable } from '../packages/mcp-server/scripts/pe.mjs'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); const TAURI = join(ROOT, 'src-tauri'); @@ -91,7 +92,11 @@ run(makepri, ['createconfig', '/cf', priconfig, '/dq', 'en-US_scale-100', '/o']) run(process.execPath, [TAURI_CLI, 'build', '--target', TARGET, '--config', join(TAURI, 'tauri.msix.conf.json'), '--config', JSON.stringify({ version })]); const release = join(targetDir, TARGET, 'release'); -for (const exe of executables) copyFileSync(join(release, exe), join(layout, exe)); +for (const exe of executables) { + // A carried-over table makes that signing fail with ERROR_BAD_EXE_FORMAT. + if (hasCertificateTable(join(release, exe))) throw new Error(`${join(release, exe)} carries a certificate table, build:sea must strip it`); + copyFileSync(join(release, exe), join(layout, exe)); +} run(makepri, ['new', '/pr', layout, '/cf', priconfig, '/mn', join(layout, 'AppxManifest.xml'), '/of', join(layout, 'resources.pri'), '/o']); const msix = join(out, `ZPLab_${version}_x64.msix`); diff --git a/src/lib/peScripts.test.ts b/src/lib/peScripts.test.ts new file mode 100644 index 00000000..bbe1ffcf --- /dev/null +++ b/src/lib/peScripts.test.ts @@ -0,0 +1,78 @@ +import { afterAll, describe, it, expect } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { certificateTable, hasCertificateTable, stripCertificateTable, withoutCertificateTable } from "../../packages/mcp-server/scripts/pe.mjs"; + +const PE_AT = 0x80; +const OPTIONAL_AT = PE_AT + 4 + 20; +// PE32+ data directories start at optional header + 112, the Certificate Table is entry 4. +const DIRECTORY_AT = OPTIONAL_AT + 112 + 4 * 8; + +/** A PE32+ image with a body and, when given, an Authenticode table the directory points at. */ +function image(magic: number, body: Buffer, table?: { offset: number; length: number; bytes?: Buffer }): Buffer { + const header = Buffer.alloc(OPTIONAL_AT + 240); + header.write("MZ", 0, "latin1"); + header.writeUInt32LE(PE_AT, 0x3c); + header.write("PE\0\0", PE_AT, "latin1"); + header.writeUInt16LE(magic, OPTIONAL_AT); + if (table) { + header.writeUInt32LE(table.offset, DIRECTORY_AT); + header.writeUInt32LE(table.length, DIRECTORY_AT + 4); + } + return Buffer.concat([header, body, table?.bytes ?? Buffer.alloc(0)]); +} + +const body = Buffer.alloc(64, 0xab); +const signature = Buffer.alloc(24, 0xcd); +const signedAt = OPTIONAL_AT + 240 + body.length; + +const scratch = mkdtempSync(join(tmpdir(), "pe-")); +afterAll(() => rmSync(scratch, { recursive: true, force: true })); + +describe("the Authenticode table of a PE32+ image", () => { + it("is cut off together with its directory entry when it closes the file", () => { + const signed = image(0x20b, body, { offset: signedAt, length: signature.length, bytes: signature }); + expect(certificateTable(signed)).toEqual({ offset: signedAt, length: 24 }); + const stripped = withoutCertificateTable(signed); + expect(stripped.length).toBe(signedAt); + expect(certificateTable(stripped)).toBeUndefined(); + expect(stripped.subarray(OPTIONAL_AT + 240)).toEqual(body); + expect(certificateTable(signed)).toEqual({ offset: signedAt, length: 24 }); + }); + + it("strips a file on disk and reports what it carries", () => { + const file = join(scratch, "x.exe"); + writeFileSync(file, image(0x20b, body, { offset: signedAt, length: signature.length, bytes: signature })); + expect(hasCertificateTable(file)).toBe(true); + stripCertificateTable(file); + expect(hasCertificateTable(file)).toBe(false); + expect(statSync(file).size).toBe(signedAt); + }); + + /** Node's own signed image pins the directory offset the synthetic layout only asserts. */ + it("finds the table at the end of a real signed node.exe", () => { + const real = readFileSync(process.execPath); + const pe = real.length > 0x40 ? real.readUInt32LE(0x3c) : 0; + const pe32Plus = pe > 0 && real.toString("latin1", pe, pe + 4) === "PE\0\0" && real.readUInt16LE(pe + 24) === 0x20b; + const table = pe32Plus ? certificateTable(real) : undefined; + if (!table) return; + expect(table.offset + table.length).toBe(real.length); + }); + + it("leaves an unsigned image as it is", () => { + const unsigned = image(0x20b, body); + expect(certificateTable(unsigned)).toBeUndefined(); + expect(withoutCertificateTable(unsigned)).toBe(unsigned); + }); + + it("refuses to cut when the entry points inside the file, as it does after postject appends a section", () => { + const grown = Buffer.concat([image(0x20b, body, { offset: signedAt, length: 24, bytes: signature }), Buffer.alloc(4096)]); + expect(() => withoutCertificateTable(grown)).toThrow(/ends at/); + }); + + it("refuses images that are not PE32+", () => { + expect(() => certificateTable(image(0x10b, body))).toThrow(/PE32\+/); + expect(() => certificateTable(Buffer.from("not an image"))).toThrow(/not a PE/); + }); +});