Files
hermes-agent/apps/desktop/scripts/payload-sign-cache.test.mjs
ethernet 54d6771ae9 fix(release): provision payload signing tools on cold caches
The afterPack payload signer runs before the Azure manager downloads its dlib. An empty cache therefore stopped the Windows build before signing.

Use the shared tool provisioner with the actual packager config. Keep the SDK, ATS dlib, and .NET runtime paired and remove the cache walkers.

Verified the cold-cache regression, native SDK execution, signature-cache contracts, and focused signing tests. ESLint passes. Full signed x64 release verification remains pending.
2026-09-08 16:04:58 -04:00

204 lines
9.9 KiB
JavaScript

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 { ensureWindowsBundleTools } from './windows-bundle-tools.mjs'
import { createPayloadSignCache, peContentHash, verifySignedPayloads } from './payload-sign-cache.mjs'
import { readSecurityDirectory } from './sanitize-pe-signatures.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.runIf(process.platform === 'win32')('catalog trust cannot mask the embedded signature', async () => {
const f = fixture()
vi.stubEnv('ELECTRON_BUILDER_CACHE', path.join(f.root, 'sdk-cache'))
try {
const { signtool: nativeSigntool } = await ensureWindowsBundleTools()
const source = path.join(process.env.SystemRoot, 'System32', 'kernel32.dll')
const publisher = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
'[Security.Cryptography.X509Certificates.X509Certificate]::CreateFromSignedFile($env:NATIVE_SIGN_TEST_INPUT).Subject'
], { encoding: 'utf8', windowsHide: true, env: { ...process.env, NATIVE_SIGN_TEST_INPUT: source } }).trim()
expect(publisher).not.toBe('')
const files = ['original.dll', 'renamed.exe'].map(name => path.join(f.root, name))
for (const file of files) fs.copyFileSync(source, file)
const catalog = () => JSON.parse(execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
'$s = Get-AuthenticodeSignature -LiteralPath $env:NATIVE_SIGN_TEST_INPUT; [pscustomobject]@{ Status = [string]$s.Status; Type = [string]$s.SignatureType } | ConvertTo-Json -Compress'
], { encoding: 'utf8', windowsHide: true, env: { ...process.env, NATIVE_SIGN_TEST_INPUT: files[1] } }))
expect(catalog()).toEqual({ Status: 'Valid', Type: 'Catalog' })
expect(await verifySignedPayloads(files, publisher, nativeSigntool)).toEqual(new Set(files))
expect(await verifySignedPayloads(files, 'CN=Wrong publisher', nativeSigntool)).toEqual(new Set())
// Catalog hashes omit the certificate table. Destroy it without changing code.
const bytes = fs.readFileSync(files[1])
const { certOffset, certSize } = readSecurityDirectory(files[1])
expect(certSize).toBeGreaterThan(8)
bytes.fill(0, certOffset + 8, certOffset + certSize)
fs.writeFileSync(files[1], bytes)
expect(catalog()).toEqual({ Status: 'Valid', Type: 'Catalog' })
expect(await verifySignedPayloads(files, publisher, nativeSigntool)).toEqual(new Set([files[0]]))
fs.copyFileSync(source, files[1])
execFileSync(nativeSigntool, ['remove', '/u', files[1]], { windowsHide: true })
expect(catalog()).toEqual({ Status: 'Valid', Type: 'Catalog' })
expect(await verifySignedPayloads(files, publisher, nativeSigntool)).toEqual(new Set([files[0]]))
} finally {
vi.unstubAllEnvs()
f.cleanup()
}
}, 240000)
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() }
})