macos-seed-store.ts 12 KB

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