perf(signing): reuse verified payload signatures by input hash
The Windows release signed 1,106 payload binaries for each of the bundled and Store variants. Both passes repeated remote signing and timestamping. Cache signed payload bytes by exact input SHA256 and signing policy. Paths and release versions do not affect entry identity. Verify content binding, publisher and timestamp before restoring a hit. Sign duplicate inputs once and publish cache entries only after successful verification. Keep product EXEs and package envelopes on the fresh signing path. Persist the cache across release runs and test its native verification in the Windows release lane. Targeted signing tests: 35 passed. Real Azure signing of three mixed binaries took 9.6s cold and 1.8s warm. Warm probes restored identical signed bytes with no signtool calls. Full release performance and cache transfer overhead remain unverified.
This commit is contained in:
15
.github/workflows/desktop-bundled-release.yml
vendored
15
.github/workflows/desktop-bundled-release.yml
vendored
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof createPayloadSignCache> }} [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 })
|
||||
}
|
||||
|
||||
136
apps/desktop/scripts/payload-sign-cache.mjs
Normal file
136
apps/desktop/scripts/payload-sign-cache.mjs
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
163
apps/desktop/scripts/payload-sign-cache.test.mjs
Normal file
163
apps/desktop/scripts/payload-sign-cache.test.mjs
Normal file
@@ -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() }
|
||||
})
|
||||
15
apps/desktop/scripts/verify-signed-payloads.ps1
Normal file
15
apps/desktop/scripts/verify-signed-payloads.ps1
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user