Files
hermes-agent/apps/desktop/scripts/sign-nested-chromium.mjs

218 lines
6.9 KiB
JavaScript

// Sign payload Chromium with our Developer ID before osx-sign seals
// Hermes.app. Google's leftover signatures have no timestamp and no
// hardened runtime, so Apple's notary rejects them.
//
// Do not codesign a file that lives inside a .framework: codesign then
// treats Versions/A/Foo as a bundle and dies with "code object is not
// signed at all" (or "bundle format is ambiguous" on the Foo.framework/Foo
// symlink). Sign the enclosing .app with --deep. Loose Mach-O outside
// any .app (such as a loose dylib) is signed as a file.
//
// signIgnore still keeps osx-sign off the chromium trees.
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
const MACHO_MAGICS = new Set([
0xfeedface, 0xcefaedfe, 0xfeedfacf, 0xcffaedfe, 0xcafebabe, 0xbebafeca
])
export function isMachO(file) {
const buf = Buffer.alloc(4)
let fd
try {
fd = fs.openSync(file, 'r')
if (fs.readSync(fd, buf, 0, 4, 0) !== 4) return false
} catch {
return false
} finally {
if (fd != null) fs.closeSync(fd)
}
return MACHO_MAGICS.has(buf.readUInt32BE(0))
}
export function chromiumRoots(payload) {
const tools = path.join(payload, 'tools')
if (!fs.existsSync(tools)) return []
return fs
.readdirSync(tools, { withFileTypes: true })
.filter(ent => ent.isDirectory() && /^chromium-\d+/.test(ent.name))
.map(ent => path.join(tools, ent.name))
}
export function listTopLevelApps(root) {
const apps = []
const walk = dir => {
let entries
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const ent of entries) {
const p = path.join(dir, ent.name)
if (ent.isSymbolicLink()) continue
if (ent.isDirectory() && p.endsWith('.app')) {
apps.push(p)
continue
}
if (ent.isDirectory()) walk(p)
}
}
walk(root)
return apps
}
export function listLooseMachO(root) {
const out = []
const walk = dir => {
let entries
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const ent of entries) {
const p = path.join(dir, ent.name)
if (ent.isSymbolicLink()) continue
if (ent.isDirectory()) {
if (p.endsWith('.app')) continue
walk(p)
continue
}
if (!ent.isFile()) continue
if (isMachO(p)) out.push(p)
}
}
walk(root)
return out
}
// If Foo.framework/Foo is a regular file and Versions/*/Foo exists,
// replace the top-level copy with a symlink. Same for Versions/Current.
export function repairFrameworkLinks(root) {
let repaired = 0
const walk = dir => {
let entries
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const ent of entries) {
const p = path.join(dir, ent.name)
if (ent.isSymbolicLink()) continue
if (ent.isDirectory() && ent.name.endsWith('.framework')) {
repaired += repairOneFramework(p)
continue
}
if (ent.isDirectory()) walk(p)
}
}
walk(root)
return repaired
}
function repairOneFramework(frameworkDir) {
let repaired = 0
const versions = path.join(frameworkDir, 'Versions')
if (!fs.existsSync(versions)) return 0
const versionDirs = fs
.readdirSync(versions, { withFileTypes: true })
.filter(ent => ent.isDirectory() && ent.name !== 'Current')
.map(ent => ent.name)
if (versionDirs.length === 0) return 0
const preferred = versionDirs.includes('A') ? 'A' : versionDirs[0]
const current = path.join(versions, 'Current')
if (!fs.existsSync(current) || !fs.lstatSync(current).isSymbolicLink()) {
if (fs.existsSync(current)) fs.rmSync(current, { recursive: true, force: true })
fs.symlinkSync(preferred, current)
repaired += 1
}
const stem = path.basename(frameworkDir, '.framework')
for (const name of [stem, 'Resources', 'Libraries', 'Helpers']) {
const top = path.join(frameworkDir, name)
const target = path.posix.join('Versions', 'Current', name)
const versioned = path.join(frameworkDir, 'Versions', preferred, name)
if (!fs.existsSync(versioned) && name !== stem) continue
if (fs.existsSync(top) && fs.lstatSync(top).isSymbolicLink()) continue
if (fs.existsSync(top)) fs.rmSync(top, { recursive: true, force: true })
if (fs.existsSync(versioned) || name === stem) {
fs.symlinkSync(target, top)
repaired += 1
}
}
return repaired
}
export function parseDeveloperId(identityList, qualifier = null) {
const line = String(identityList || '')
.split(/\r?\n/)
.find(l => l.includes('Developer ID Application:') && (!qualifier || l.includes(qualifier)))
if (!line) return null
// Match electron-builder's selector: a certificate fingerprint, not the
// display name shared by renewed certificates or identities in other keychains.
const identity = line.match(/^\s*\d+\)\s+([\dA-Fa-f]{40})\s+"Developer ID Application:[^"]+"/)
return identity ? identity[1] : null
}
export function findDeveloperId(exec = execFileSync, keychain = null) {
const args = ['find-identity', '-v', '-p', 'codesigning']
if (keychain) args.push(keychain)
const out = exec('security', args, { encoding: 'utf8' })
const identity = parseDeveloperId(out, process.env.CSC_NAME?.trim())
if (!identity && (keychain || process.env.CSC_NAME)) {
throw new Error(`sign-nested-chromium: no matching Developer ID Application identity in ${keychain || 'the default keychain'}`)
}
return identity
}
export async function resolveSigningIdentity(packager, exec = execFileSync) {
const info = await packager?.codeSigningInfo?.value
const keychain = info?.keychainFile || process.env.CSC_KEYCHAIN || null
return { identity: findDeveloperId(exec, keychain), keychain }
}
function codesign(exec, identity, entitlements, keychain, target, deep) {
const args = [
'--force',
'--sign',
identity,
'--timestamp',
'--options',
'runtime',
'--entitlements',
entitlements
]
if (deep) args.push('--deep')
if (keychain) args.push('--keychain', keychain)
args.push(target)
exec('codesign', args, { stdio: 'pipe' })
}
export function signNestedChromium(payload, opts = {}) {
const identity = opts.identity
if (!identity) return { signed: 0, repaired: 0, identity: null }
const entitlements = opts.entitlements
if (!entitlements || !fs.existsSync(entitlements)) {
throw new Error(`sign-nested-chromium: entitlements missing at ${entitlements}`)
}
const exec = opts.exec ?? execFileSync
const keychain = opts.keychain || null
let signed = 0
let repaired = 0
for (const root of chromiumRoots(payload)) {
repaired += repairFrameworkLinks(root)
for (const app of listTopLevelApps(root)) {
codesign(exec, identity, entitlements, keychain, app, true)
signed += 1
}
for (const file of listLooseMachO(root)) {
codesign(exec, identity, entitlements, keychain, file, false)
signed += 1
}
}
return { signed, repaired, identity }
}