Files
hermes-agent/apps/desktop/scripts/payload-sign-cache.mjs
ethernet 276174db7e fix(signing): verify embedded signatures behind Windows catalogs
PowerShell selects the Microsoft catalog signature for some payload DLLs.
That hides the valid Nous signature and rejects the signed payload cache.

For catalog matches, verify the primary embedded signature with signtool.
Require a trusted timestamp and the expected embedded publisher. Keep the
existing content binding and reject catalog-only trust.

The native regression rejects corrupt certificates and missing timestamps.
All 36 focused tests pass. Real Azure signing and duplicate restoration
pass with signer calls forbidden on the warm cache path. Full release
acceptance remains pending.
2026-09-08 01:31:02 -04:00

138 lines
5.9 KiB
JavaScript

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, signtool = '') {
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,
...(signtool ? ['-SignTool', signtool] : [])
], { 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, signtool)
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, signtool)
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)
}
}
}
}