diff --git a/bin/asar.mjs b/bin/asar.mjs index 9724964..d54d1a3 100755 --- a/bin/asar.mjs +++ b/bin/asar.mjs @@ -2,6 +2,7 @@ import packageJSON from '../package.json' with { type: 'json' }; import { createPackageWithOptions, listPackage, extractFile, extractAll } from '../lib/asar.js'; +import { enableIntegrityDigestForApp, disableIntegrityDigestForApp, verifyIntegrityDigestForApp, printStoredIntegrityDigestForApp } from '../lib/integrity-digest.js'; import { program } from 'commander'; import fs from 'node:fs'; import path from 'node:path'; @@ -71,6 +72,26 @@ program.command('extract ') extractAll(archive, dest) }) +program.command('integrity-digest ') + .alias('id') + .description('manage integrity digest in app binary') + .action(async function (app, command) { + const allowedCommands = ['on', 'off', 'status', 'verify'] + switch (command) { + case 'on': await enableIntegrityDigestForApp(app) + break + case 'off': await disableIntegrityDigestForApp(app) + break + case 'status': await printStoredIntegrityDigestForApp(app) + break + case 'verify': await verifyIntegrityDigestForApp(app) + break + default: + console.log('Unknown integrity digest command: %s. Allowed commands are: %s', command, allowedCommands.join(', ')) + process.exit(1) + } + }) + program.command('*', { hidden: true}) .action(function (_cmd, args) { console.log('asar: \'%s\' is not an asar command. See \'asar --help\'.', args[0]) diff --git a/package.json b/package.json index 6993b8a..177b474 100644 --- a/package.json +++ b/package.json @@ -42,11 +42,13 @@ "dependencies": { "commander": "^13.1.0", "glob": "^11.0.1", - "minimatch": "^10.0.1" + "minimatch": "^10.0.1", + "plist": "^3.1.0" }, "devDependencies": { "@tsconfig/node22": "^22.0.1", "@types/node": "^22.12.0", + "@types/plist": "^3", "electron": "^35.7.5", "prettier": "^3.3.3", "typedoc": "~0.25.13", diff --git a/src/integrity-digest.ts b/src/integrity-digest.ts new file mode 100644 index 0000000..83a169d --- /dev/null +++ b/src/integrity-digest.ts @@ -0,0 +1,274 @@ +import path from 'node:path'; +import crypto from 'node:crypto'; +import plist from 'plist'; + +import { wrappedFs as fs } from './wrapped-fs.js'; +import { FileRecord } from './disk.js'; + +// Integrity digest type definitions + +type IntegrityDigest = + | { used: false } + | ({ used: true; version: Version } & AdditionalParams); + +type IntegrityDigestV1 = IntegrityDigest<1, { sha256Digest: Buffer }>; + +type AnyIntegrityDigest = IntegrityDigestV1; // Extend this union type as new versions are added + +// Integrity digest calculation functions + +type AsarIntegrity = Record>; + +function calculateIntegrityDigestV1(asarIntegrity: AsarIntegrity): IntegrityDigestV1 { + const integrityHash = crypto.createHash('SHA256'); + for (const key of Object.keys(asarIntegrity).sort()) { + const { algorithm, hash } = asarIntegrity[key]; + integrityHash.update(key); + integrityHash.update(algorithm); + integrityHash.update(hash); + } + return { + used: true, + version: 1, + sha256Digest: integrityHash.digest(), + }; +} + +function calculateIntegrityDigestV1ForApp(appPath: string): IntegrityDigestV1 { + const plistPath = path.join(appPath, 'Contents', 'Info.plist'); + const plistBuffer = fs.readFileSync(plistPath); + const plistData = plist.parse(plistBuffer.toString()) as Record; + const asarIntegrity = plistData['ElectronAsarIntegrity'] as AsarIntegrity; + return calculateIntegrityDigestV1(asarIntegrity); +} + +/// Integrity digest handling errors + +const UnknownIntegrityDigestVersionError = class extends Error { + constructor(version: number) { + super(`Unknown integrity digest version: ${version}`); + this.name = 'UnknownIntegrityDigestVersionError'; + } +}; + +// Integrity digest storage and retrieval functions + +const INTEGRITY_DIGEST_SENTINEL = 'AGbevlPCksUGKNL8TSn7wGmJEuJsXb2A'; + +function pathToIntegrityDigestFile(appPath: string) { + if (appPath.endsWith('.app')) { + return path.resolve( + appPath, + 'Contents', + 'Frameworks', + 'Electron Framework.framework', + 'Electron Framework', + ); + } + throw new Error('App path must be an .app bundle'); +} + +function forEachSentinelInApp( + appPath: string, + callback: (sentinelIndex: number, integrityFile: Buffer) => void, + writeBack: boolean = false, +) { + const integrityFilePath = pathToIntegrityDigestFile(appPath); + const integrityFile = fs.readFileSync(integrityFilePath); + let searchCursor = 0; + const sentinelAsBuffer = Buffer.from(INTEGRITY_DIGEST_SENTINEL); + do { + const sentinelIndex = integrityFile.indexOf(sentinelAsBuffer, searchCursor); + if (sentinelIndex === -1) break; + callback(sentinelIndex, integrityFile); + searchCursor = sentinelIndex + sentinelAsBuffer.length; + } while (true); + if (writeBack) { + fs.writeFileSync(integrityFilePath, integrityFile); + } +} + +function doDigestsMatch(digestA: AnyIntegrityDigest, digestB: AnyIntegrityDigest): boolean { + if (digestA.used !== digestB.used) return false; + if (digestA.used && digestB.used) { + if (digestA.version !== digestB.version) return false; + switch (digestA.version) { + case 1: + return digestA.sha256Digest.equals(digestB.sha256Digest); + default: + throw new UnknownIntegrityDigestVersionError(digestA.version); + } + } else return true; +} + +function sentinelIndexToDigest( + integrityFile: Buffer, + sentinelIndex: number, +): T { + const used = integrityFile.readUInt8(sentinelIndex + INTEGRITY_DIGEST_SENTINEL.length) === 1; + if (!used) { + return { used: false } as T; + } else { + const version = integrityFile.readUInt8(sentinelIndex + INTEGRITY_DIGEST_SENTINEL.length + 1); + switch (version) { + case 1: { + const sha256Digest = integrityFile.subarray( + sentinelIndex + INTEGRITY_DIGEST_SENTINEL.length + 2, + sentinelIndex + INTEGRITY_DIGEST_SENTINEL.length + 2 + 32, // SHA256 digest size + ); + return { + used: true, + version: 1, + sha256Digest, + } as T; + } + default: + throw new UnknownIntegrityDigestVersionError(version); + } + } +} + +async function getStoredIntegrityDigestForApp( + appPath: string, +): Promise { + let lastDigestFound: T | null = null; + forEachSentinelInApp(appPath, (sentinelIndex, integrityFile) => { + const currentDigest = sentinelIndexToDigest(integrityFile, sentinelIndex); + if (lastDigestFound === null) { + lastDigestFound = currentDigest; + } else if (!doDigestsMatch(currentDigest, lastDigestFound)) { + throw new Error('Multiple differing integrity digests found in the binary'); + } + lastDigestFound = currentDigest; + }); + if (lastDigestFound === null) { + throw new Error('No integrity digest found in the binary'); + } + return lastDigestFound; +} + +async function setStoredIntegrityDigestForApp( + appPath: string, + digest: T, +): Promise { + if (digest.used === true && digest.version !== 1) { + throw new UnknownIntegrityDigestVersionError(digest.version); + } + forEachSentinelInApp( + appPath, + (sentinelIndex, integrityFile) => { + integrityFile.writeUInt8( + digest.used ? 1 : 0, + sentinelIndex + INTEGRITY_DIGEST_SENTINEL.length, + ); + const oldVersion = integrityFile.readUInt8( + sentinelIndex + INTEGRITY_DIGEST_SENTINEL.length + 1, + ); + switch (oldVersion) { + case 1: + integrityFile.fill( + 0, + sentinelIndex + INTEGRITY_DIGEST_SENTINEL.length + 2, + sentinelIndex + INTEGRITY_DIGEST_SENTINEL.length + 2 + 32, // SHA256 digest size + ); + break; + } + if (digest.used) { + integrityFile.writeUInt8( + digest.version, + sentinelIndex + INTEGRITY_DIGEST_SENTINEL.length + 1, + ); + switch (digest.version) { + case 1: { + const v1Digest = digest as IntegrityDigestV1 & { used: true }; + v1Digest.sha256Digest.copy( + integrityFile, + sentinelIndex + INTEGRITY_DIGEST_SENTINEL.length + 2, + ); + break; + } + default: + throw new UnknownIntegrityDigestVersionError(digest.version); + } + } + }, + true, + ); +} + +// High-level integrity digest management functions + +function printDigest(digest: AnyIntegrityDigest, prefix: string = '') { + const digestLogger = prefix + ? (s: string, ...args: any[]) => console.log(prefix + s, ...args) + : console.log; + if (!digest.used) { + digestLogger('Integrity digest is OFF'); + return; + } + digestLogger('Integrity digest is ON (version: %d)', digest.version); + switch (digest.version) { + case 1: + digestLogger('\tDigest (SHA256): %s', digest.sha256Digest.toString('hex')); + break; + default: + digestLogger('\tUnknown metadata for digest version: %d', digest.version); + } +} + +export async function enableIntegrityDigestForApp(appPath: string): Promise { + try { + console.log('Calculating integrity digest...'); + const digest = calculateIntegrityDigestV1ForApp(appPath); + console.log('Turning integrity digest ON...'); + await setStoredIntegrityDigestForApp(appPath, digest); + console.log('Integrity digest turned ON'); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + console.log('Failed to turn ON integrity digest: %s', errorMessage); + } +} + +export async function disableIntegrityDigestForApp(appPath: string): Promise { + try { + console.log('Turning integrity digest OFF...'); + await setStoredIntegrityDigestForApp(appPath, { used: false }); + console.log('Integrity digest turned OFF'); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + console.log('Failed to turn OFF integrity digest: %s', errorMessage); + } +} + +export async function printStoredIntegrityDigestForApp(appPath: string): Promise { + try { + const storedDigest = await getStoredIntegrityDigestForApp(appPath); + printDigest(storedDigest); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + console.log('Failed to read integrity digest: %s', errorMessage); + } +} + +export async function verifyIntegrityDigestForApp(appPath: string): Promise { + try { + const storedDigest = await getStoredIntegrityDigestForApp(appPath); + if (!storedDigest.used) { + console.log('Integrity digest is off, verification SKIPPED'); + return; + } + const calculatedDigest = calculateIntegrityDigestV1ForApp(appPath); + if (doDigestsMatch(storedDigest, calculatedDigest)) { + console.log('Integrity digest verification PASSED'); + } else { + console.log('Integrity digest verification FAILED'); + console.log('Expected digest:'); + printDigest(calculatedDigest, '\t'); + console.log('Actual digest:'); + printDigest(storedDigest, '\t'); + } + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + console.log('Failed to verify integrity digest: %s', errorMessage); + } +} diff --git a/yarn.lock b/yarn.lock index 7e76957..cbd9feb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11,10 +11,12 @@ __metadata: dependencies: "@tsconfig/node22": "npm:^22.0.1" "@types/node": "npm:^22.12.0" + "@types/plist": "npm:^3" commander: "npm:^13.1.0" electron: "npm:^35.7.5" glob: "npm:^11.0.1" minimatch: "npm:^10.0.1" + plist: "npm:^3.1.0" prettier: "npm:^3.3.3" typedoc: "npm:~0.25.13" typescript: "npm:^5.5.4" @@ -531,6 +533,16 @@ __metadata: languageName: node linkType: hard +"@types/plist@npm:^3": + version: 3.0.5 + resolution: "@types/plist@npm:3.0.5" + dependencies: + "@types/node": "npm:*" + xmlbuilder: "npm:>=11.0.1" + checksum: 10c0/2a929f4482e3bea8c3288a46ae589a2ae2d01df5b7841ead7032d7baa79d79af6c875a5798c90705eea9306c2fb1544d7ed12ab3c905c5626d5dd5dc9f464b94 + languageName: node + linkType: hard + "@types/responselike@npm:^1.0.0": version: 1.0.0 resolution: "@types/responselike@npm:1.0.0" @@ -632,6 +644,13 @@ __metadata: languageName: node linkType: hard +"@xmldom/xmldom@npm:^0.8.8": + version: 0.8.11 + resolution: "@xmldom/xmldom@npm:0.8.11" + checksum: 10c0/e768623de72c95d3dae6b5da8e33dda0d81665047811b5498d23a328d45b13feb5536fe921d0308b96a4a8dd8addf80b1f6ef466508051c0b581e63e0dc74ed5 + languageName: node + linkType: hard + "abbrev@npm:^3.0.0": version: 3.0.1 resolution: "abbrev@npm:3.0.1" @@ -697,6 +716,13 @@ __metadata: languageName: node linkType: hard +"base64-js@npm:^1.5.1": + version: 1.5.1 + resolution: "base64-js@npm:1.5.1" + checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf + languageName: node + linkType: hard + "boolean@npm:^3.0.1": version: 3.2.0 resolution: "boolean@npm:3.2.0" @@ -1907,6 +1933,17 @@ __metadata: languageName: node linkType: hard +"plist@npm:^3.1.0": + version: 3.1.0 + resolution: "plist@npm:3.1.0" + dependencies: + "@xmldom/xmldom": "npm:^0.8.8" + base64-js: "npm:^1.5.1" + xmlbuilder: "npm:^15.1.1" + checksum: 10c0/db19ba50faafc4103df8e79bcd6b08004a56db2a9dd30b3e5c8b0ef30398ef44344a674e594d012c8fc39e539a2b72cb58c60a76b4b4401cbbc7c8f6b028d93d + languageName: node + linkType: hard + "postcss@npm:^8.5.6": version: 8.5.6 resolution: "postcss@npm:8.5.6" @@ -2665,6 +2702,13 @@ __metadata: languageName: node linkType: hard +"xmlbuilder@npm:>=11.0.1, xmlbuilder@npm:^15.1.1": + version: 15.1.1 + resolution: "xmlbuilder@npm:15.1.1" + checksum: 10c0/665266a8916498ff8d82b3d46d3993913477a254b98149ff7cff060d9b7cc0db7cf5a3dae99aed92355254a808c0e2e3ec74ad1b04aa1061bdb8dfbea26c18b8 + languageName: node + linkType: hard + "xvfb-maybe@npm:^0.2.1": version: 0.2.1 resolution: "xvfb-maybe@npm:0.2.1"