522 lines
14 KiB
JavaScript
522 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Codesign PearData macOS artifacts so Gatekeeper does not report
|
|
* "is damaged and can't be opened. You should move it to the Trash."
|
|
*
|
|
* Targets:
|
|
* - Electron .app bundles (deep sign / nested helpers)
|
|
* - Standalone peardata-server Mach-O binaries (Bare)
|
|
*
|
|
* Usage:
|
|
* node scripts/sign-macos-app.cjs path/to/PearData.app
|
|
* node scripts/sign-macos-app.cjs path/to/out/peardata-darwin-arm64
|
|
* node scripts/sign-macos-app.cjs path/to/out/peardata-server-darwin-arm64
|
|
* node scripts/sign-macos-app.cjs path/to/peardata-server
|
|
*
|
|
* Identity (first match wins):
|
|
* MAC_CODESIGN_IDENTITY / CSC_NAME — "Developer ID Application: …" or team identity
|
|
* otherwise ad-hoc (`-`) which is enough to make the artifact *valid* (not "damaged")
|
|
*
|
|
* Tools:
|
|
* macOS: /usr/bin/codesign (required for production identities)
|
|
* Linux CI: rcodesign (apple-codesign) for self-signed seal when present
|
|
*/
|
|
'use strict'
|
|
|
|
const fs = require('fs')
|
|
const path = require('path')
|
|
const { spawnSync, execFileSync } = require('child_process')
|
|
|
|
const ENTITLEMENTS = path.join(__dirname, 'entitlements.mac.plist')
|
|
const SERVER_BIN_NAMES = new Set(['peardata-server', 'peardata-server.exe'])
|
|
|
|
function log(...a) {
|
|
console.log('[sign-macos]', ...a)
|
|
}
|
|
|
|
function findApps(input) {
|
|
const st = fs.statSync(input)
|
|
if (st.isFile() && input.endsWith('.app')) return [input]
|
|
if (st.isDirectory() && input.endsWith('.app')) return [input]
|
|
if (st.isDirectory()) {
|
|
return fs
|
|
.readdirSync(input)
|
|
.filter((n) => n.endsWith('.app'))
|
|
.map((n) => path.join(input, n))
|
|
}
|
|
return []
|
|
}
|
|
|
|
/**
|
|
* Find standalone peardata-server Mach-O binaries under a path.
|
|
* @param {string} input
|
|
* @returns {string[]}
|
|
*/
|
|
function findServerBinaries(input) {
|
|
if (!fs.existsSync(input)) return []
|
|
const st = fs.statSync(input)
|
|
if (st.isFile()) {
|
|
const base = path.basename(input)
|
|
if (SERVER_BIN_NAMES.has(base) || base === 'peardata-server') return [input]
|
|
if (!base.endsWith('.app') && !base.endsWith('.dmg') && !base.endsWith('.pkg')) {
|
|
if (base.includes('peardata-server')) return [input]
|
|
}
|
|
return []
|
|
}
|
|
if (!st.isDirectory()) return []
|
|
const found = []
|
|
const stack = [input]
|
|
while (stack.length) {
|
|
const dir = stack.pop()
|
|
let entries
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
} catch {
|
|
continue
|
|
}
|
|
for (const ent of entries) {
|
|
const p = path.join(dir, ent.name)
|
|
if (ent.isDirectory()) {
|
|
if (ent.name === 'node_modules' || ent.name.endsWith('.app') || ent.name.startsWith('.')) {
|
|
continue
|
|
}
|
|
stack.push(p)
|
|
} else if (ent.isFile() && SERVER_BIN_NAMES.has(ent.name)) {
|
|
found.push(p)
|
|
}
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
|
|
function which(cmd) {
|
|
try {
|
|
const r = spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], {
|
|
encoding: 'utf8',
|
|
})
|
|
if (r.status === 0) return r.stdout.trim().split(/\r?\n/)[0]
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return null
|
|
}
|
|
|
|
function identity() {
|
|
const id = process.env.MAC_CODESIGN_IDENTITY || process.env.CSC_NAME || ''
|
|
if (id && id !== '-' && id.toLowerCase() !== 'null') return id
|
|
return '-'
|
|
}
|
|
|
|
function ensureEntitlements() {
|
|
if (fs.existsSync(ENTITLEMENTS)) return ENTITLEMENTS
|
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>com.apple.security.cs.allow-jit</key>
|
|
<true/>
|
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
|
<true/>
|
|
<key>com.apple.security.cs.disable-library-validation</key>
|
|
<true/>
|
|
<key>com.apple.security.network.client</key>
|
|
<true/>
|
|
<key>com.apple.security.network.server</key>
|
|
<true/>
|
|
</dict>
|
|
</plist>
|
|
`
|
|
fs.writeFileSync(ENTITLEMENTS, xml)
|
|
return ENTITLEMENTS
|
|
}
|
|
|
|
function listSignTargets(appPath) {
|
|
const targets = []
|
|
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.isDirectory()) {
|
|
if (ent.name === 'node_modules' || ent.name.startsWith('.')) {
|
|
if (ent.name === 'node_modules') walk(p)
|
|
else if (!ent.name.startsWith('.')) walk(p)
|
|
continue
|
|
}
|
|
if (ent.name.endsWith('.app') || ent.name.endsWith('.framework')) {
|
|
walk(p)
|
|
targets.push(p)
|
|
continue
|
|
}
|
|
walk(p)
|
|
} else if (ent.isFile() || ent.isSymbolicLink()) {
|
|
const base = ent.name
|
|
if (
|
|
base.endsWith('.dylib') ||
|
|
base.endsWith('.so') ||
|
|
base.endsWith('.node') ||
|
|
base.endsWith('.bare') ||
|
|
base === 'peardata-client' ||
|
|
base.startsWith('PearData Helper') ||
|
|
base.startsWith('peardata Helper') ||
|
|
base === 'Electron Framework' ||
|
|
base === 'Squirrel' ||
|
|
base === 'ReactiveObjC' ||
|
|
base === 'Mantle' ||
|
|
base === 'chrome_crashpad_handler'
|
|
) {
|
|
targets.push(p)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
walk(appPath)
|
|
const uniq = [...new Set(targets)]
|
|
uniq.sort((a, b) => {
|
|
const da = a.split(path.sep).length
|
|
const db = b.split(path.sep).length
|
|
if (da !== db) return db - da
|
|
return b.length - a.length
|
|
})
|
|
const rootIdx = uniq.indexOf(appPath)
|
|
if (rootIdx >= 0) uniq.splice(rootIdx, 1)
|
|
uniq.push(appPath)
|
|
return uniq
|
|
}
|
|
|
|
function codesignDarwin(appPath, id) {
|
|
const entitlements = ensureEntitlements()
|
|
const hardened = id !== '-'
|
|
|
|
try {
|
|
execFileSync('xattr', ['-cr', appPath], { stdio: 'pipe' })
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
log(`deep codesign identity=${id === '-' ? 'ad-hoc' : id}`)
|
|
const rootArgs = [
|
|
'--force',
|
|
'--deep',
|
|
'--sign',
|
|
id,
|
|
'--entitlements',
|
|
entitlements,
|
|
]
|
|
if (hardened) rootArgs.push('--options', 'runtime', '--timestamp')
|
|
else rootArgs.push('--timestamp=none')
|
|
rootArgs.push(appPath)
|
|
|
|
let root = spawnSync('codesign', rootArgs, { encoding: 'utf8' })
|
|
if (root.status !== 0) {
|
|
throw new Error(`codesign failed for app:\n${root.stderr || root.stdout}`)
|
|
}
|
|
|
|
let v = spawnSync('codesign', ['--verify', '--deep', '--strict', '--verbose=2', appPath], {
|
|
encoding: 'utf8',
|
|
})
|
|
if (v.status !== 0) {
|
|
log('strict verify failed — signing nested code then app…')
|
|
const targets = listSignTargets(appPath)
|
|
for (const target of targets) {
|
|
if (target === appPath) continue
|
|
const args = ['--force', '--sign', id]
|
|
if (hardened) args.push('--options', 'runtime', '--timestamp')
|
|
else args.push('--timestamp=none')
|
|
args.push(target)
|
|
spawnSync('codesign', args, { encoding: 'utf8' })
|
|
}
|
|
root = spawnSync('codesign', rootArgs, { encoding: 'utf8' })
|
|
if (root.status !== 0) {
|
|
throw new Error(`codesign failed for app (retry):\n${root.stderr || root.stdout}`)
|
|
}
|
|
v = spawnSync('codesign', ['--verify', '--deep', '--strict', '--verbose=2', appPath], {
|
|
encoding: 'utf8',
|
|
})
|
|
if (v.status !== 0) {
|
|
throw new Error(`codesign verify failed:\n${v.stderr || v.stdout}`)
|
|
}
|
|
}
|
|
log('verify ok:', (v.stderr || v.stdout || '').trim().split('\n').slice(0, 3).join(' | '))
|
|
}
|
|
|
|
function ensureRcodesignSelfSignedP12(bin) {
|
|
const certDir =
|
|
process.env.PEARDATA_RCODESIGN_CERT_DIR ||
|
|
path.join(__dirname, '..', 'tools', 'rcodesign', 'ci-cert')
|
|
const p12Path = path.join(certDir, 'peardata-ci.p12')
|
|
const password = process.env.PEARDATA_RCODESIGN_P12_PASSWORD || 'peardata-ci-sign'
|
|
fs.mkdirSync(certDir, { recursive: true })
|
|
if (fs.existsSync(p12Path) && fs.statSync(p12Path).size > 100) {
|
|
return { p12Path, password }
|
|
}
|
|
|
|
log('generating self-signed signing cert for rcodesign…')
|
|
const gen = spawnSync(
|
|
bin,
|
|
[
|
|
'generate-self-signed-certificate',
|
|
'--p12-file',
|
|
p12Path,
|
|
'--p12-password',
|
|
password,
|
|
'--person-name',
|
|
'peardata-ci',
|
|
'--country-name',
|
|
'US',
|
|
'--validity-days',
|
|
'3650',
|
|
'--team-id',
|
|
'NONE',
|
|
'--profile',
|
|
'developer-id-application',
|
|
],
|
|
{ encoding: 'utf8', stdio: 'pipe' }
|
|
)
|
|
if (gen.status !== 0 || !fs.existsSync(p12Path)) {
|
|
const gen2 = spawnSync(
|
|
bin,
|
|
[
|
|
'generate-self-signed-certificate',
|
|
'--p12-file',
|
|
p12Path,
|
|
'--p12-password',
|
|
password,
|
|
'--person-name',
|
|
'peardata-ci',
|
|
'--country-name',
|
|
'US',
|
|
'--validity-days',
|
|
'3650',
|
|
],
|
|
{ encoding: 'utf8', stdio: 'pipe' }
|
|
)
|
|
if (gen2.status !== 0 || !fs.existsSync(p12Path)) {
|
|
throw new Error(
|
|
`rcodesign generate-self-signed-certificate failed:\n` +
|
|
`${gen.stderr || gen.stdout}\n${gen2.stderr || gen2.stdout}`
|
|
)
|
|
}
|
|
}
|
|
log('wrote', p12Path)
|
|
return { p12Path, password }
|
|
}
|
|
|
|
function codesignRcodesign(targetPath, id) {
|
|
const bin = which('rcodesign')
|
|
if (!bin) {
|
|
throw new Error(
|
|
'rcodesign not found (needed to sign macOS artifacts on Linux). ' +
|
|
'CI installs tools/rcodesign/<host>/rcodesign, or build/sign on macOS.'
|
|
)
|
|
}
|
|
if (id !== '-') {
|
|
log(
|
|
'WARN: Linux rcodesign path uses self-signed cert only; ' +
|
|
'Developer ID needs codesign on macOS + Apple certs'
|
|
)
|
|
}
|
|
|
|
const entitlements = ensureEntitlements()
|
|
const { p12Path, password } = ensureRcodesignSelfSignedP12(bin)
|
|
|
|
const attempts = [
|
|
[
|
|
'sign',
|
|
'--p12-file',
|
|
p12Path,
|
|
'--p12-password',
|
|
password,
|
|
'--code-signature-flags',
|
|
'runtime',
|
|
'--entitlements-xml-file',
|
|
entitlements,
|
|
targetPath,
|
|
],
|
|
[
|
|
'sign',
|
|
'--p12-file',
|
|
p12Path,
|
|
'--p12-password',
|
|
password,
|
|
'--code-signature-flags',
|
|
'runtime',
|
|
targetPath,
|
|
],
|
|
['sign', '--p12-file', p12Path, '--p12-password', password, targetPath],
|
|
]
|
|
|
|
let lastErr = ''
|
|
for (const args of attempts) {
|
|
log(
|
|
'rcodesign',
|
|
args.map((a) => (a === password ? '***' : a)).join(' ')
|
|
)
|
|
const r = spawnSync(bin, args, { encoding: 'utf8', stdio: 'pipe' })
|
|
if (r.status === 0) {
|
|
log('rcodesign sign complete (self-signed / sealed)')
|
|
return
|
|
}
|
|
lastErr += `${r.stderr || r.stdout || ''}\n`
|
|
}
|
|
throw new Error(`rcodesign failed:\n${lastErr}`)
|
|
}
|
|
|
|
function codesignDarwinBinary(binPath, id) {
|
|
const entitlements = ensureEntitlements()
|
|
const hardened = id !== '-'
|
|
|
|
try {
|
|
execFileSync('xattr', ['-cr', binPath], { stdio: 'pipe' })
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
log(`codesign binary identity=${id === '-' ? 'ad-hoc' : id}`)
|
|
const args = ['--force', '--sign', id, '--entitlements', entitlements]
|
|
if (hardened) args.push('--options', 'runtime', '--timestamp')
|
|
else args.push('--timestamp=none')
|
|
args.push('--identifier', 'com.peardata.server', binPath)
|
|
|
|
const r = spawnSync('codesign', args, { encoding: 'utf8' })
|
|
if (r.status !== 0) {
|
|
throw new Error(`codesign failed for binary:\n${r.stderr || r.stdout}`)
|
|
}
|
|
|
|
const v = spawnSync('codesign', ['--verify', '--strict', '--verbose=2', binPath], {
|
|
encoding: 'utf8',
|
|
})
|
|
if (v.status !== 0) {
|
|
throw new Error(`codesign verify failed for binary:\n${v.stderr || v.stdout}`)
|
|
}
|
|
log('verify ok:', (v.stderr || v.stdout || '').trim().split('\n').slice(0, 3).join(' | '))
|
|
}
|
|
|
|
function signBinary(binPath) {
|
|
if (!fs.existsSync(binPath)) throw new Error(`Binary not found: ${binPath}`)
|
|
const id = identity()
|
|
log('binary:', binPath)
|
|
log('identity:', id === '-' ? 'ad-hoc (-)' : id)
|
|
|
|
if (process.platform === 'darwin') {
|
|
codesignDarwinBinary(binPath, id)
|
|
try {
|
|
execFileSync('xattr', ['-cr', binPath], { stdio: 'pipe' })
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return Promise.resolve()
|
|
}
|
|
|
|
codesignRcodesign(binPath, id)
|
|
return Promise.resolve()
|
|
}
|
|
|
|
function signApp(appPath) {
|
|
if (!fs.existsSync(appPath)) throw new Error(`App not found: ${appPath}`)
|
|
const id = identity()
|
|
log('app:', appPath)
|
|
log('identity:', id === '-' ? 'ad-hoc (-)' : id)
|
|
|
|
if (process.platform === 'darwin') {
|
|
if (id === '-') {
|
|
codesignDarwin(appPath, id)
|
|
try {
|
|
execFileSync('xattr', ['-cr', appPath], { stdio: 'pipe' })
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return Promise.resolve()
|
|
}
|
|
|
|
try {
|
|
const { signAsync } = require('@electron/osx-sign')
|
|
return signAsync({
|
|
app: appPath,
|
|
identity: id,
|
|
platform: 'darwin',
|
|
hardenedRuntime: true,
|
|
gatekeeperAssess: false,
|
|
optionsForFile: () => ({
|
|
entitlements: ensureEntitlements(),
|
|
hardenedRuntime: true,
|
|
}),
|
|
}).then(() => {
|
|
try {
|
|
execFileSync('xattr', ['-cr', appPath], { stdio: 'pipe' })
|
|
} catch {
|
|
// ignore
|
|
}
|
|
const v = spawnSync(
|
|
'codesign',
|
|
['--verify', '--deep', '--strict', '--verbose=2', appPath],
|
|
{ encoding: 'utf8' }
|
|
)
|
|
if (v.status !== 0) {
|
|
log('osx-sign verify soft-fail, falling back to codesign deep…')
|
|
codesignDarwin(appPath, id)
|
|
} else {
|
|
log('osx-sign + verify ok')
|
|
}
|
|
})
|
|
} catch (err) {
|
|
log('osx-sign unavailable or failed, using codesign:', err.message || err)
|
|
codesignDarwin(appPath, id)
|
|
try {
|
|
execFileSync('xattr', ['-cr', appPath], { stdio: 'pipe' })
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return Promise.resolve()
|
|
}
|
|
}
|
|
|
|
codesignRcodesign(appPath, id)
|
|
return Promise.resolve()
|
|
}
|
|
|
|
async function main() {
|
|
const input = process.argv[2]
|
|
if (!input) {
|
|
console.error(
|
|
'Usage: node scripts/sign-macos-app.cjs <path-to.app|peardata-server|dir>'
|
|
)
|
|
process.exit(2)
|
|
}
|
|
const resolved = path.resolve(input)
|
|
if (!fs.existsSync(resolved)) {
|
|
console.error('Path not found:', resolved)
|
|
process.exit(1)
|
|
}
|
|
|
|
const apps = findApps(resolved)
|
|
const bins = findServerBinaries(resolved)
|
|
|
|
if (!apps.length && !bins.length) {
|
|
console.error('No .app or peardata-server binary found at', resolved)
|
|
process.exit(1)
|
|
}
|
|
|
|
for (const app of apps) {
|
|
await signApp(app)
|
|
}
|
|
for (const bin of bins) {
|
|
if (apps.some((a) => bin === a || bin.startsWith(a + path.sep))) continue
|
|
await signBinary(bin)
|
|
}
|
|
log('done')
|
|
}
|
|
|
|
module.exports = { signApp, signBinary, findApps, findServerBinaries, identity }
|
|
|
|
if (require.main === module) {
|
|
main().catch((err) => {
|
|
console.error('[sign-macos] FAILED:', err.message || err)
|
|
process.exit(1)
|
|
})
|
|
}
|