macos-seed-store.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. /** Sign Mach-O content in a pnpm CAS without invalidating the store index. */
  2. import { createHash } from 'node:crypto'
  3. import {
  4. chmodSync,
  5. closeSync,
  6. copyFileSync,
  7. existsSync,
  8. mkdirSync,
  9. mkdtempSync,
  10. openSync,
  11. readFileSync,
  12. readSync,
  13. readdirSync,
  14. rmSync,
  15. unlinkSync,
  16. writeFileSync,
  17. } from 'node:fs'
  18. import { availableParallelism, tmpdir } from 'node:os'
  19. import { basename, dirname, join, relative, sep } from 'node:path'
  20. import { DatabaseSync } from 'node:sqlite'
  21. import { Packr } from 'msgpackr'
  22. import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs'
  23. import { signMacOSSeedCode, verifyMacOSSeedCode } from './verify-macos-signature.mjs'
  24. const MACH_O_MAGICS = new Set([
  25. 'cafebabe',
  26. 'cafebabf',
  27. 'cefaedfe',
  28. 'cffaedfe',
  29. 'feedface',
  30. 'feedfacf',
  31. 'bebafeca',
  32. 'bfbafeca',
  33. ])
  34. const CAS_PATH_PATTERN = /^([0-9a-f]{2})\/([0-9a-f]{126})(-exec)?$/u
  35. const MAX_CONCURRENT_CODE_SIGNERS = 4
  36. const packr = new Packr({ moreTypes: true, useRecords: true })
  37. interface PnpmStoreFileRecord {
  38. checkedAt: number
  39. digest: string
  40. mode: number
  41. size: number
  42. }
  43. interface PnpmSideEffectsRecord {
  44. readonly added?: Map<string, PnpmStoreFileRecord>
  45. }
  46. interface PnpmPackageIndexRecord {
  47. readonly algo?: string
  48. readonly files?: Map<string, PnpmStoreFileRecord>
  49. readonly sideEffects?: Map<string, PnpmSideEffectsRecord>
  50. }
  51. interface DecodedIndexRow {
  52. readonly key: string
  53. readonly value: PnpmPackageIndexRecord
  54. changed: boolean
  55. }
  56. interface CasFile {
  57. readonly path: string
  58. readonly digest: string
  59. readonly executable: boolean
  60. }
  61. interface FileReference {
  62. readonly row: DecodedIndexRow
  63. readonly record: PnpmStoreFileRecord
  64. }
  65. interface SigningWork {
  66. readonly file: CasFile
  67. readonly references: readonly FileReference[]
  68. readonly temporaryPath: string
  69. }
  70. /** Summary of native code rewritten in one pnpm store. */
  71. export interface MacOSSeedStoreSigningResult {
  72. readonly signedFiles: number
  73. readonly prunedOrphans: number
  74. readonly updatedIndexRows: number
  75. }
  76. /** A signer used to make one writable Mach-O copy release-valid. */
  77. export type MacOSSeedCodeSigner = (path: string, identifier: string) => Promise<void>
  78. /** A verifier used to check one Mach-O file after packaging transport. */
  79. export type MacOSSeedCodeVerifier = (path: string) => void
  80. /** Optional execution controls for seed-store code signing. */
  81. export interface MacOSSeedStoreSigningOptions {
  82. readonly signer?: MacOSSeedCodeSigner
  83. readonly concurrency?: number
  84. }
  85. function isRecord(value: unknown): value is Record<string, unknown> {
  86. return typeof value === 'object' && value !== null
  87. }
  88. function isStoreFileRecord(value: unknown): value is PnpmStoreFileRecord {
  89. if (!isRecord(value)) return false
  90. return typeof value.checkedAt === 'number'
  91. && typeof value.digest === 'string'
  92. && /^[0-9a-f]{128}$/u.test(value.digest)
  93. && Number.isSafeInteger(value.mode)
  94. && Number.isSafeInteger(value.size)
  95. }
  96. function packageFileMaps(value: unknown, key: string): readonly Map<string, PnpmStoreFileRecord>[] {
  97. if (!isRecord(value)) throw new Error(`desktop seed signing: invalid pnpm index record ${key}`)
  98. const record = value as PnpmPackageIndexRecord
  99. if (record.algo !== undefined && record.algo !== 'sha512') {
  100. throw new Error(`desktop seed signing: unsupported pnpm index algorithm in ${key}`)
  101. }
  102. const maps: Map<string, PnpmStoreFileRecord>[] = []
  103. if (record.files !== undefined) {
  104. if (!(record.files instanceof Map)) throw new Error(`desktop seed signing: invalid pnpm file map in ${key}`)
  105. maps.push(record.files)
  106. }
  107. if (record.sideEffects !== undefined) {
  108. if (!(record.sideEffects instanceof Map)) {
  109. throw new Error(`desktop seed signing: invalid pnpm side-effects map in ${key}`)
  110. }
  111. for (const effect of record.sideEffects.values()) {
  112. if (!isRecord(effect)) throw new Error(`desktop seed signing: invalid pnpm side effect in ${key}`)
  113. if (effect.added === undefined) continue
  114. if (!(effect.added instanceof Map)) {
  115. throw new Error(`desktop seed signing: invalid pnpm side-effect file map in ${key}`)
  116. }
  117. maps.push(effect.added)
  118. }
  119. }
  120. for (const files of maps) {
  121. for (const file of files.values()) {
  122. if (!isStoreFileRecord(file)) throw new Error(`desktop seed signing: invalid pnpm file record in ${key}`)
  123. }
  124. }
  125. return maps
  126. }
  127. function isExecutableMode(mode: number): boolean {
  128. return (mode & 0o111) !== 0
  129. }
  130. function referenceKey(digest: string, executable: boolean): string {
  131. return `${digest}:${executable ? 'exec' : 'nonexec'}`
  132. }
  133. function isMachO(path: string): boolean {
  134. const descriptor = openSync(path, 'r')
  135. try {
  136. const header = Buffer.alloc(4)
  137. return readSync(descriptor, header, 0, header.length, 0) === header.length
  138. && MACH_O_MAGICS.has(header.toString('hex'))
  139. } finally {
  140. closeSync(descriptor)
  141. }
  142. }
  143. function visitFiles(root: string): readonly string[] {
  144. const files: string[] = []
  145. const visit = (directory: string): void => {
  146. for (const entry of readdirSync(directory, { withFileTypes: true })) {
  147. const path = join(directory, entry.name)
  148. if (entry.isSymbolicLink()) {
  149. throw new Error(`desktop seed signing: pnpm store contains a symbolic link: ${relative(root, path)}`)
  150. }
  151. if (entry.isDirectory()) visit(path)
  152. else if (entry.isFile()) files.push(path)
  153. else throw new Error(`desktop seed signing: unsupported pnpm store entry: ${relative(root, path)}`)
  154. }
  155. }
  156. visit(root)
  157. return files.sort((left, right) => left.localeCompare(right))
  158. }
  159. function versionRoots(storeRoot: string): readonly string[] {
  160. return readdirSync(storeRoot, { withFileTypes: true })
  161. .filter(entry => entry.isDirectory() && /^v\d+$/u.test(entry.name))
  162. .map(entry => join(storeRoot, entry.name))
  163. .filter(root => existsSync(join(root, 'files')))
  164. .sort((left, right) => left.localeCompare(right))
  165. }
  166. function casFiles(versionRoot: string): readonly CasFile[] {
  167. const filesRoot = join(versionRoot, 'files')
  168. const result: CasFile[] = []
  169. for (const path of visitFiles(filesRoot)) {
  170. if (!isMachO(path)) continue
  171. const normalized = relative(filesRoot, path).split(sep).join('/')
  172. const match = CAS_PATH_PATTERN.exec(normalized)
  173. if (match === null) {
  174. throw new Error(`desktop seed signing: Mach-O content has an unsupported pnpm CAS path: ${normalized}`)
  175. }
  176. result.push({
  177. path,
  178. digest: `${match[1]}${match[2]}`,
  179. executable: match[3] !== undefined,
  180. })
  181. }
  182. return result
  183. }
  184. function readIndexRows(database: DatabaseSync): readonly DecodedIndexRow[] {
  185. const rows: DecodedIndexRow[] = []
  186. for (const row of database.prepare('SELECT key, data FROM package_index').iterate() as Iterable<{
  187. key: string
  188. data: Uint8Array
  189. }>) {
  190. rows.push({ key: row.key, value: packr.unpack(row.data) as PnpmPackageIndexRecord, changed: false })
  191. }
  192. return rows
  193. }
  194. function fileReferences(rows: readonly DecodedIndexRow[]): ReadonlyMap<string, readonly FileReference[]> {
  195. const references = new Map<string, FileReference[]>()
  196. for (const row of rows) {
  197. for (const files of packageFileMaps(row.value, row.key)) {
  198. for (const record of files.values()) {
  199. const key = referenceKey(record.digest, isExecutableMode(record.mode))
  200. const values = references.get(key) ?? []
  201. values.push({ row, record })
  202. references.set(key, values)
  203. }
  204. }
  205. }
  206. return references
  207. }
  208. function writeCasFile(path: string, body: Buffer, mode: number): void {
  209. mkdirSync(dirname(path), { recursive: true })
  210. try {
  211. writeFileSync(path, body, { flag: 'wx', mode })
  212. } catch (error) {
  213. if (!isRecord(error) || error.code !== 'EEXIST' || !readFileSync(path).equals(body)) throw error
  214. }
  215. chmodSync(path, mode)
  216. }
  217. function signedCasPath(versionRoot: string, digest: string, executable: boolean): string {
  218. return join(
  219. versionRoot,
  220. 'files',
  221. digest.slice(0, 2),
  222. `${digest.slice(2)}${executable ? '-exec' : ''}`,
  223. )
  224. }
  225. async function runConcurrent<T>(
  226. values: readonly T[],
  227. concurrency: number,
  228. run: (value: T) => Promise<void>,
  229. ): Promise<void> {
  230. let next = 0
  231. const failure: { error?: unknown; failed: boolean } = { failed: false }
  232. const worker = async (): Promise<void> => {
  233. while (!failure.failed) {
  234. const index = next
  235. if (index >= values.length) return
  236. next += 1
  237. try {
  238. await run(values[index] as T)
  239. } catch (error) {
  240. if (!failure.failed) {
  241. failure.failed = true
  242. failure.error = error
  243. }
  244. }
  245. }
  246. }
  247. const workers = Array.from(
  248. { length: Math.min(concurrency, values.length) },
  249. async () => worker(),
  250. )
  251. await Promise.all(workers)
  252. if (failure.failed) throw failure.error
  253. }
  254. async function rewriteVersionStore(
  255. versionRoot: string,
  256. appId: string,
  257. signer: MacOSSeedCodeSigner,
  258. concurrency: number,
  259. ): Promise<MacOSSeedStoreSigningResult> {
  260. const databasePath = join(versionRoot, 'index.db')
  261. if (!existsSync(databasePath)) {
  262. throw new Error(`desktop seed signing: pnpm store has no package index: ${databasePath}`)
  263. }
  264. const database = new DatabaseSync(databasePath)
  265. const workRoot = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-signing-'))
  266. const obsoleteFiles = new Set<string>()
  267. let prunedOrphans = 0
  268. let rows: readonly DecodedIndexRow[] = []
  269. try {
  270. rows = readIndexRows(database)
  271. const references = fileReferences(rows)
  272. const signingWork: SigningWork[] = []
  273. for (const file of casFiles(versionRoot)) {
  274. const body = readFileSync(file.path)
  275. const actualDigest = createHash('sha512').update(body).digest('hex')
  276. if (actualDigest !== file.digest) {
  277. throw new Error(`desktop seed signing: pnpm CAS digest mismatch at ${file.path}`)
  278. }
  279. const fileReferences = references.get(referenceKey(file.digest, file.executable)) ?? []
  280. if (fileReferences.length === 0) {
  281. obsoleteFiles.add(file.path)
  282. prunedOrphans += 1
  283. continue
  284. }
  285. const temporary = join(workRoot, `${signingWork.length.toString().padStart(4, '0')}-${basename(file.path)}`)
  286. copyFileSync(file.path, temporary)
  287. chmodSync(temporary, 0o755)
  288. signingWork.push({ file, references: fileReferences, temporaryPath: temporary })
  289. }
  290. await runConcurrent(signingWork, concurrency, async (work) => {
  291. await signer(work.temporaryPath, `${appId}.seed.${work.file.digest.slice(0, 32)}`)
  292. })
  293. for (const work of signingWork) {
  294. const signedBody = readFileSync(work.temporaryPath)
  295. if (!isMachO(work.temporaryPath)) {
  296. throw new Error(`desktop seed signing: signer produced non-Mach-O content for ${work.file.path}`)
  297. }
  298. const signedDigest = createHash('sha512').update(signedBody).digest('hex')
  299. const mode = work.file.executable ? 0o755 : 0o644
  300. const destination = signedCasPath(versionRoot, signedDigest, work.file.executable)
  301. writeCasFile(destination, signedBody, mode)
  302. const checkedAt = Date.now()
  303. for (const reference of work.references) {
  304. reference.record.checkedAt = checkedAt
  305. reference.record.digest = signedDigest
  306. reference.record.mode = mode
  307. reference.record.size = signedBody.length
  308. reference.row.changed = true
  309. }
  310. if (destination !== work.file.path) obsoleteFiles.add(work.file.path)
  311. }
  312. const changedRows = rows.filter(row => row.changed)
  313. database.exec('BEGIN IMMEDIATE')
  314. let committed = false
  315. try {
  316. const statement = database.prepare('INSERT OR REPLACE INTO package_index (key, data) VALUES (?, ?)')
  317. for (const row of changedRows) statement.run(row.key, packr.pack(row.value))
  318. database.exec('COMMIT')
  319. committed = true
  320. } finally {
  321. if (!committed) database.exec('ROLLBACK')
  322. }
  323. for (const path of obsoleteFiles) unlinkSync(path)
  324. database.exec('VACUUM')
  325. return { signedFiles: signingWork.length, prunedOrphans, updatedIndexRows: changedRows.length }
  326. } finally {
  327. database.close()
  328. rmSync(workRoot, { recursive: true, force: true })
  329. }
  330. }
  331. /**
  332. * Replace every Mach-O CAS object with a Developer ID signed object and update pnpm's SHA-512 index.
  333. * A signer rejection leaves the original CAS objects and package index unchanged.
  334. * @param storeRoot - Loose pnpm store prepared for the packaged seed.
  335. * @param appId - Electron application ID used as the signing identifier prefix.
  336. * @param expected - Company Developer ID identity and Team ID.
  337. * @param options - Optional signer and worker bound used by focused tests.
  338. * @returns Counts for release diagnostics after every signer completes and the index transaction commits.
  339. */
  340. export async function signMacOSSeedStore(
  341. storeRoot: string,
  342. appId: string,
  343. expected: MacOSSigningEnvironment,
  344. options: MacOSSeedStoreSigningOptions = {},
  345. ): Promise<MacOSSeedStoreSigningResult> {
  346. const roots = versionRoots(storeRoot)
  347. if (roots.length === 0) throw new Error(`desktop seed signing: no pnpm store versions found in ${storeRoot}`)
  348. const concurrency = options.concurrency ?? Math.min(MAX_CONCURRENT_CODE_SIGNERS, availableParallelism())
  349. if (!Number.isSafeInteger(concurrency) || concurrency < 1) {
  350. throw new Error(`desktop seed signing: concurrency must be a positive integer; received ${String(concurrency)}`)
  351. }
  352. const signer = options.signer ?? (async (path, identifier) => {
  353. await signMacOSSeedCode(path, identifier, expected)
  354. })
  355. const results: MacOSSeedStoreSigningResult[] = []
  356. for (const root of roots) results.push(await rewriteVersionStore(root, appId, signer, concurrency))
  357. return results.reduce((total, current) => ({
  358. signedFiles: total.signedFiles + current.signedFiles,
  359. prunedOrphans: total.prunedOrphans + current.prunedOrphans,
  360. updatedIndexRows: total.updatedIndexRows + current.updatedIndexRows,
  361. }), { signedFiles: 0, prunedOrphans: 0, updatedIndexRows: 0 })
  362. }
  363. /**
  364. * Verify that every Mach-O CAS object has the expected Developer ID, timestamp, and hardened runtime.
  365. * @param storeRoot - Loose or extracted pnpm store.
  366. * @param expected - Company Developer ID identity and Team ID.
  367. * @param verifier - Injectable signature verifier used by focused tests.
  368. * @returns Number of verified Mach-O files.
  369. */
  370. export function verifyMacOSSeedStore(
  371. storeRoot: string,
  372. expected: MacOSSigningEnvironment,
  373. verifier: MacOSSeedCodeVerifier = (path) => { verifyMacOSSeedCode(path, expected) },
  374. ): number {
  375. let count = 0
  376. for (const root of versionRoots(storeRoot)) {
  377. for (const file of casFiles(root)) {
  378. verifier(file.path)
  379. count += 1
  380. }
  381. }
  382. return count
  383. }