diff --git a/.github/workflows/desktop-bundled-release.yml b/.github/workflows/desktop-bundled-release.yml index bdefc1e73a..3b033ff4e2 100644 --- a/.github/workflows/desktop-bundled-release.yml +++ b/.github/workflows/desktop-bundled-release.yml @@ -275,6 +275,16 @@ jobs: version: ${{ steps.pins.outputs.uv }} enable-cache: false + - name: Cache verified payload signatures + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ github.workspace }}/.cache/electron-builder-payload-signatures + # Each run saves additions. Content and signing policy select entries. + key: payload-signatures-v1-${{ runner.os }}-${{ matrix.target.label }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + payload-signatures-v1-${{ runner.os }}-${{ matrix.target.label }}- + payload-signatures-v1-${{ runner.os }}- + - name: Cache vcpkg OpenSSL (arm64) if: matrix.target.label == 'win32-arm64' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -427,6 +437,11 @@ jobs: # they also land in the tag archive, never a feed dir). uv run --no-project --python 3.11 python scripts/bundles/desktop.py --tag="$HERMES_PAYLOAD_TAG" --variant=store + - name: Verify native signature cache contracts + shell: bash + working-directory: apps/desktop + run: node ../../node_modules/vitest/vitest.mjs run --project electron scripts/payload-sign-cache.test.mjs scripts/batch-sign-binaries.test.mjs + - name: Audit bundle architecture shell: bash run: | diff --git a/apps/desktop/BUILDING.md b/apps/desktop/BUILDING.md index 5ebeeb4780..aebcca4b43 100644 --- a/apps/desktop/BUILDING.md +++ b/apps/desktop/BUILDING.md @@ -74,11 +74,24 @@ AZURE_CLIENT_ID (the OIDC app id) `electron-builder.config.cjs` reads these variables and composes the `win.sign` configuration itself. Do not pass the values as `-c` arguments: the publisher name contains spaces, and spaces do not survive the cmd.exe -argument hop. Signing runs through `scripts/sign-msix.mjs`, a plain hook -that signs ONLY the .msix package: Windows validates the package signature -(AppxSignature.p7x over AppxBlockMap.xml), and inner binaries are covered -by the block-map hashes — per-file Authenticode is neither required nor -validated. +argument hop. `scripts/batch-sign-binaries.mjs` signs and timestamps payload +EXEs/DLLs after packing. The product EXE is signed after resource edits. +`scripts/sign-msix.mjs` signs the package, except Store packages that +Partner Center signs on ingestion. + +Unchanged payload files reuse signatures from +`${ELECTRON_BUILDER_CACHE}-payload-signatures`. The cache key combines the exact +pre-sign bytes with the Azure profile, publisher, signing tools and timestamp +policy. Paths, filenames and release versions do not affect the key. +Each hit must match the input's executable content and pass Windows +Authenticode verification with the expected publisher and a timestamp. +Invalid entries become misses. Only verified, signed and timestamped results +enter the cache. Product EXEs and package envelopes still receive fresh signatures. + +Bundled and Store builds share the cache. CI restores the most recent snapshot +and saves additions under a new run key. Dispatch on the default branch to +share GitHub's cache scope across release tags. Delete the cache to force +fresh signatures. The signer logs hits, misses, duplicate copies and time. On win32, `scripts/after-pack.mjs` runs `sanitize-pe-signatures.mjs` before the MSIX pack: python-build-standalone's `llvm-strip` can leave dangling PE diff --git a/apps/desktop/scripts/batch-sign-binaries.mjs b/apps/desktop/scripts/batch-sign-binaries.mjs index a57994ec61..dc4b21d82c 100644 --- a/apps/desktop/scripts/batch-sign-binaries.mjs +++ b/apps/desktop/scripts/batch-sign-binaries.mjs @@ -42,6 +42,7 @@ import { execFile } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' import { isMain } from './utils.mjs' +import { createPayloadSignCache } from './payload-sign-cache.mjs' export const CHUNK_SIZE = 100 // How many signtool children may run at once. Azure Trusted Signing and the @@ -376,12 +377,11 @@ export async function timestampChunk(files, opts) { * * Two passes: (1) Azure Authenticode sign — concurrent signtool children, * no timestamp; (2) RFC3161 timestamp — concurrent, no Azure/dlib, retried - * per chunk. Parallelism is the whole speed story: both Azure and the - * timestamp server are per-file network round-trips, so N concurrent children - * multiply throughput ~Nx. + * per chunk. A verified content cache removes unchanged inputs from both + * passes. Identical cacheable inputs share one signing operation. * * @param {string[]} binaries file list from getBinaries - * @param {{ env?: NodeJS.ProcessEnv, exec?: typeof execFile, chunkSize?: number, concurrency?: number, mkdtemp?: typeof fs.mkdtempSync, signtool?: string, dlib?: string, timestampUrl?: string, timestampAttempts?: number, timestampRetryDelayMs?: number }} [opts] + * @param {{ env?: NodeJS.ProcessEnv, exec?: typeof execFile, chunkSize?: number, concurrency?: number, mkdtemp?: typeof fs.mkdtempSync, signtool?: string, dlib?: string, dotnetRoot?: string, timestampUrl?: string, timestampAttempts?: number, timestampRetryDelayMs?: number, cache?: ReturnType }} [opts] * @returns {Promise<{ signed: number, chunks: number, skipped: boolean }>} * skipped=true when Azure signing is not configured (caller warns). */ @@ -401,6 +401,14 @@ export async function batchSignBinaries(binaries, opts = {}) { if (!signtool) { throw new Error('batch-sign-binaries: signtool.exe not found under the electron-builder cache (or SIGNTOOL_PATH)') } + const started = performance.now() + const cache = opts.cache === undefined ? createPayloadSignCache({ + root: env.ELECTRON_BUILDER_CACHE ? `${env.ELECTRON_BUILDER_CACHE}-payload-signatures` : null, + env, signtool, dlib, timestampUrl: opts.timestampUrl ?? TIMESTAMP_URL + }) : opts.cache + const plan = cache ? await cache.prepare(binaries) : null + const toSign = plan?.files ?? binaries + console.log(`[batch-sign] ${plan?.restored ?? 0} cache hits, ${toSign.length} to sign, ${plan?.duplicates ?? 0} duplicate copies`) // The ATS dlib is a .NET assembly; Ijwhost.dll finds hostfxr.dll via // DOTNET_ROOT. Mirror app-builder-lib's WindowsSignAzureManager and point it // at the bundled runtime so the dlib initializes (a missing runtime reads as @@ -417,7 +425,7 @@ export async function batchSignBinaries(binaries, opts = {}) { CertificateProfileName: env.AZURE_SIGN_PROFILE })) const concurrency = opts.concurrency ?? DEFAULT_CONCURRENCY - const batches = chunk(binaries, opts.chunkSize ?? CHUNK_SIZE) + const batches = chunk(toSign, opts.chunkSize ?? CHUNK_SIZE) const execOptions = { stdio: 'inherit', env: signEnv } try { // Pass 1: Azure Authenticode sign — concurrent, no timestamp. @@ -435,7 +443,9 @@ export async function batchSignBinaries(binaries, opts = {}) { timestampRetryDelayMs: opts.timestampRetryDelayMs }) ) - return { signed: binaries.length, chunks: batches.length, skipped: false } + if (cache) await cache.publish(plan) + console.log(`[batch-sign] completed in ${((performance.now() - started) / 1000).toFixed(1)}s`) + return { signed: toSign.length, chunks: batches.length, skipped: false } } finally { fs.rmSync(tmpDir, { recursive: true, force: true }) } diff --git a/apps/desktop/scripts/payload-sign-cache.mjs b/apps/desktop/scripts/payload-sign-cache.mjs new file mode 100644 index 0000000000..16d020f9e3 --- /dev/null +++ b/apps/desktop/scripts/payload-sign-cache.mjs @@ -0,0 +1,136 @@ +import { createHash } from 'node:crypto' +import { execFile } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { promisify } from 'node:util' +import { readSecurityDirectory } from './sanitize-pe-signatures.mjs' + +const exec = promisify(execFile) +const sha256 = bytes => createHash('sha256').update(bytes).digest('hex') + +// Only checksum, security-directory entry and a trailing certificate can change. +// Keep every other byte, including overlays. Unknown layouts are not cacheable. +export function peContentHash(file) { + const entry = readSecurityDirectory(file) + if (!entry) return null + const bytes = fs.readFileSync(file) + const pe = bytes.readUInt32LE(0x3c) + const checksum = pe + 24 + 64 + if (checksum + 4 > entry.offsetInFile) return null + let end = bytes.length + if (entry.certSize || entry.certOffset) { + if (!entry.certSize || entry.certOffset < entry.offsetInFile + 8 || + entry.certOffset % 8 || entry.certOffset + entry.certSize !== bytes.length) return null + end = entry.certOffset + } + const content = Buffer.from(bytes.subarray(0, end)) + content.fill(0, checksum, checksum + 4) + content.fill(0, entry.offsetInFile, entry.offsetInFile + 8) + const hash = createHash('sha256').update(content) + // Signing aligns the certificate table to eight bytes. + hash.update(Buffer.alloc((8 - content.length % 8) % 8)) + return hash.digest('hex') +} + +export async function verifySignedPayloads(files, publisher) { + if (!files.length) return new Set() + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'verify-signatures-')) + try { + const manifest = path.join(temp, 'files.json') + fs.writeFileSync(manifest, JSON.stringify(files.map(file => ({ path: file, publisher })))) + const { stdout } = await exec('powershell.exe', [ + '-NoProfile', '-NonInteractive', '-File', + path.join(import.meta.dirname, 'verify-signed-payloads.ps1'), manifest + ], { windowsHide: true, timeout: 600000, maxBuffer: 4 * 1024 * 1024 }) + const results = JSON.parse(stdout.replace(/^\uFEFF/, '')) + if (!Array.isArray(results) || results.length !== files.length || + results.some((r, i) => r.path !== files[i] || typeof r.valid !== 'boolean')) { + throw new Error('Invalid Authenticode verification response') + } + return new Set(results.filter(r => r.valid).map(r => r.path)) + } finally { + fs.rmSync(temp, { recursive: true, force: true }) + } +} + +export function createPayloadSignCache({ root, env, signtool, dlib, timestampUrl, verify = verifySignedPayloads }) { + const publisher = env.AZURE_SIGN_PUBLISHER + if (!root || !publisher) return null + const policy = sha256(JSON.stringify({ + schema: 1, endpoint: env.AZURE_SIGN_ENDPOINT, account: env.AZURE_SIGN_ACCOUNT, + profile: env.AZURE_SIGN_PROFILE, publisher, timestampUrl, + digest: 'SHA256', timestampDigest: 'SHA256', + signtool: sha256(fs.readFileSync(signtool)), dlib: sha256(fs.readFileSync(dlib)) + })) + const directory = path.join(root, policy) + const entryPath = key => path.join(directory, key) + return { + async prepare(files) { + const groups = new Map() + const uncached = [] + for (const file of files) { + const content = peContentHash(file) + if (!content) { uncached.push(file); continue } + const key = sha256(fs.readFileSync(file)) + if (groups.has(key)) groups.get(key).files.push(file) + else groups.set(key, { key, content, files: [file] }) + } + const candidates = [] + for (const group of groups.values()) { + const entry = entryPath(group.key) + if (!fs.existsSync(entry)) continue + try { + const receipt = JSON.parse(fs.readFileSync(path.join(entry, 'receipt.json'), 'utf8')) + const binary = path.join(entry, 'signed.exe') + if (receipt.signedHash !== sha256(fs.readFileSync(binary)) || peContentHash(binary) !== group.content) { + throw new Error('Cache content mismatch') + } + candidates.push({ group, binary }) + } catch { + fs.rmSync(entry, { recursive: true, force: true }) + } + } + const valid = await verify(candidates.map(c => c.binary), publisher) + let restored = 0 + for (const { group, binary } of candidates) { + if (!valid.has(binary)) { + fs.rmSync(entryPath(group.key), { recursive: true, force: true }) + continue + } + for (const file of group.files) fs.copyFileSync(binary, file) + restored += group.files.length + groups.delete(group.key) + } + const pending = [...groups.values()] + return { + files: [...uncached, ...pending.map(g => g.files[0])], + pending, restored, + duplicates: pending.reduce((n, g) => n + g.files.length - 1, 0) + } + }, + async publish(plan) { + const files = plan.pending.map(g => g.files[0]) + const valid = await verify(files, publisher) + for (const group of plan.pending) { + const file = group.files[0] + if (!valid.has(file) || peContentHash(file) !== group.content) { + throw new Error(`Signed payload failed verification: ${file}`) + } + const signed = fs.readFileSync(file) + fs.mkdirSync(directory, { recursive: true }) + const temp = fs.mkdtempSync(path.join(directory, '.tmp-')) + try { + fs.writeFileSync(path.join(temp, 'signed.exe'), signed) + fs.writeFileSync(path.join(temp, 'receipt.json'), JSON.stringify({ signedHash: sha256(signed) })) + const dest = entryPath(group.key) + // A complete entry appears in one rename, never a half-written pair. + if (!fs.existsSync(dest)) fs.renameSync(temp, dest) + } finally { + fs.rmSync(temp, { recursive: true, force: true }) + } + for (const duplicate of group.files.slice(1)) fs.copyFileSync(file, duplicate) + } + } + } +} diff --git a/apps/desktop/scripts/payload-sign-cache.test.mjs b/apps/desktop/scripts/payload-sign-cache.test.mjs new file mode 100644 index 0000000000..d4fc01a76d --- /dev/null +++ b/apps/desktop/scripts/payload-sign-cache.test.mjs @@ -0,0 +1,163 @@ +import { createHash } from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { expect, test, vi } from 'vitest' +import { batchSignBinaries } from './batch-sign-binaries.mjs' +import { createPayloadSignCache, peContentHash, verifySignedPayloads } from './payload-sign-cache.mjs' + +const hash = bytes => createHash('sha256').update(bytes).digest('hex') + +// Minimal PE input for byte-binding tests, not a signature-validity fixture. +function pe(marker = 1) { + const bytes = Buffer.alloc(1024) + bytes.write('MZ') + bytes.writeUInt32LE(128, 0x3c) + bytes.writeUInt32LE(0x4550, 128) + bytes.writeUInt16LE(240, 148) + bytes.writeUInt16LE(0x20b, 152) + bytes.writeUInt32LE(16, 260) + bytes[600] = marker + return bytes +} + +function signatureBytes(input) { + const result = Buffer.concat([input, Buffer.alloc(32, 42)]) + result.writeUInt32LE(12345, 216) + result.writeUInt32LE(input.length, 296) + result.writeUInt32LE(32, 300) + return result +} + +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'payload-sign-cache-test-')) + const tool = path.join(root, 'tool') + fs.writeFileSync(tool, 'test tool bytes') + const env = { AZURE_SIGN_ENDPOINT: 'https://test.invalid', AZURE_SIGN_ACCOUNT: 'account', + AZURE_SIGN_PROFILE: 'profile', AZURE_SIGN_PUBLISHER: 'CN=Test', TEMP: root } + const verify = vi.fn(async files => new Set(files)) + const opts = { root: path.join(root, 'cache'), env, signtool: tool, dlib: tool, + timestampUrl: 'http://timestamp.test.invalid', verify } + return { root, tool, env, verify, opts, cache: createPayloadSignCache(opts), + cleanup: () => fs.rmSync(root, { recursive: true, force: true }) } +} + +test.runIf(process.platform === 'win32')('native verification binds cached bytes to the publisher and timestamp', async () => { + const f = fixture() + try { + const publisher = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', + '(Get-AuthenticodeSignature -LiteralPath $env:NATIVE_SIGN_TEST_INPUT).SignerCertificate.Subject' + ], { encoding: 'utf8', windowsHide: true, env: { ...process.env, NATIVE_SIGN_TEST_INPUT: process.execPath } }).trim() + expect(publisher).not.toBe('') + const files = ['original.exe', 'renamed.dll'].map(name => path.join(f.root, name)) + for (const file of files) fs.copyFileSync(process.execPath, file) + expect((await verifySignedPayloads(files, publisher)).size).toBe(2) + expect((await verifySignedPayloads(files, 'CN=Wrong publisher')).size).toBe(0) + const cache = createPayloadSignCache({ ...f.opts, verify: verifySignedPayloads, + env: { ...f.env, AZURE_SIGN_PUBLISHER: publisher } }) + const cold = await cache.prepare(files) + expect(cold.files).toHaveLength(1) + await cache.publish(cold) + const warm = await cache.prepare(files) + expect(warm.restored).toBe(2) + expect(warm.files).toEqual([]) + const corrupted = fs.readFileSync(files[1]) + corrupted[0x200] ^= 1 + fs.writeFileSync(files[1], corrupted) + expect(await verifySignedPayloads(files, publisher)).toEqual(new Set([files[0]])) + } finally { f.cleanup() } +}, 30000) + +test('input bytes select entries across paths, while policy and executable content stay binding', async () => { + const f = fixture() + try { + const first = path.join(f.root, 'one.exe') + const duplicate = path.join(f.root, 'different.dll') + const input = pe() + fs.writeFileSync(first, input) + fs.writeFileSync(duplicate, input) + const plan = await f.cache.prepare([first, duplicate]) + expect(plan.files).toEqual([first]) + expect(plan.duplicates).toBe(1) + fs.writeFileSync(first, signatureBytes(input)) + await f.cache.publish(plan) + expect(fs.readFileSync(duplicate)).toEqual(fs.readFileSync(first)) + fs.writeFileSync(duplicate, input) + const warm = await createPayloadSignCache(f.opts).prepare([duplicate]) + expect(warm.files).toEqual([]) + expect(warm.restored).toBe(1) + expect(fs.readFileSync(duplicate)).toEqual(signatureBytes(input)) + + fs.writeFileSync(duplicate, input) + const changedPolicy = createPayloadSignCache({ ...f.opts, env: { ...f.env, AZURE_SIGN_PROFILE: 'other' } }) + expect((await changedPolicy.prepare([duplicate])).restored).toBe(0) + fs.writeFileSync(duplicate, pe(2)) + expect((await f.cache.prepare([duplicate])).restored).toBe(0) + + const policyDir = path.join(f.opts.root, fs.readdirSync(f.opts.root)[0]) + const entry = path.join(policyDir, hash(input)) + const candidate = path.join(entry, 'signed.exe') + // A different signed program with a matching receipt must not replace input. + const substituted = signatureBytes(pe(2)) + fs.writeFileSync(candidate, substituted) + fs.writeFileSync(path.join(entry, 'receipt.json'), JSON.stringify({ signedHash: hash(substituted) })) + fs.writeFileSync(duplicate, input) + expect((await f.cache.prepare([duplicate])).restored).toBe(0) + expect(fs.readFileSync(duplicate)).toEqual(input) + expect(fs.existsSync(entry)).toBe(false) + + for (const mode of ['corrupt', 'untrusted']) { + const pending = await f.cache.prepare([duplicate]) + fs.writeFileSync(duplicate, signatureBytes(input)) + await f.cache.publish(pending) + fs.writeFileSync(duplicate, input) + if (mode === 'corrupt') fs.appendFileSync(candidate, 'corruption') + else f.verify.mockResolvedValueOnce(new Set()) + expect((await f.cache.prepare([duplicate])).restored).toBe(0) + expect(fs.readFileSync(duplicate)).toEqual(input) + } + } finally { f.cleanup() } +}) + +test('batch cache publishes only after successful signing and timestamping, and warm hits skip both', async () => { + const f = fixture() + try { + const file = path.join(f.root, 'input.exe') + const input = pe() + const options = { env: f.env, signtool: f.tool, dlib: f.tool, cache: f.cache, + timestampAttempts: 1, timestampRetryDelayMs: 0 } + for (const failAt of ['sign', 'timestamp']) { + fs.writeFileSync(file, input) + await expect(batchSignBinaries([file], { ...options, exec: async (_, args) => { + if (args[0] === failAt) throw new Error(failAt) + fs.writeFileSync(file, signatureBytes(input)) + } })).rejects.toThrow(failAt) + expect(fs.existsSync(f.opts.root)).toBe(false) + } + fs.writeFileSync(file, input) + f.verify.mockImplementation(async () => new Set()) + await expect(batchSignBinaries([file], { ...options, exec: async (_, args) => { + if (args[0] === 'sign') fs.writeFileSync(file, signatureBytes(input)) + } })).rejects.toThrow('failed verification') + expect(fs.existsSync(f.opts.root)).toBe(false) + f.verify.mockImplementation(async files => new Set(files)) + fs.writeFileSync(file, input) + const calls = [] + const cold = await batchSignBinaries([file], { ...options, exec: async (_, args) => { + calls.push(args[0]) + if (args[0] === 'sign') fs.writeFileSync(file, signatureBytes(input)) + } }) + expect(cold.signed).toBe(1) + expect(calls).toEqual(['sign', 'timestamp']) + fs.writeFileSync(file, input) + const warm = await batchSignBinaries([file], { ...options, exec: () => { throw new Error('not a hit') } }) + expect(warm.signed).toBe(0) + expect(fs.readFileSync(file)).toEqual(signatureBytes(input)) + + const malformed = path.join(f.root, 'not-pe.exe') + fs.writeFileSync(malformed, 'not a PE') + expect(peContentHash(malformed)).toBe(null) + expect((await f.cache.prepare([malformed])).files).toEqual([malformed]) + } finally { f.cleanup() } +}) diff --git a/apps/desktop/scripts/verify-signed-payloads.ps1 b/apps/desktop/scripts/verify-signed-payloads.ps1 new file mode 100644 index 0000000000..3a656084da --- /dev/null +++ b/apps/desktop/scripts/verify-signed-payloads.ps1 @@ -0,0 +1,15 @@ +param([Parameter(Mandatory = $true)][string]$Manifest) +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) +$items = Get-Content -Raw -LiteralPath $Manifest | ConvertFrom-Json +$results = @( + foreach ($item in $items) { + $signature = Get-AuthenticodeSignature -LiteralPath $item.path + $valid = $signature.Status -eq 'Valid' -and + $signature.SignatureType -eq 'Authenticode' -and + $null -ne $signature.TimeStamperCertificate -and + $signature.SignerCertificate.Subject -eq $item.publisher + [PSCustomObject]@{ path = $item.path; valid = [bool]$valid } + } +) +ConvertTo-Json -InputObject $results -Compress