Skip to content

Commit 24441f3

Browse files
erickzhaonmggithub
andcommitted
feat: asar integrity digest
Co-authored-by: Noah Gregory <noahmgregory@gmail.com>
1 parent 87dabd7 commit 24441f3

4 files changed

Lines changed: 295 additions & 3 deletions

File tree

src/mac.ts

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,38 @@ import { App } from './platform.js';
22
import { debug, sanitizeAppName, subOptionWarning, warning } from './common.js';
33
import fs from 'graceful-fs';
44
import { promisifiedGracefulFs } from './util.js';
5+
import crypto from 'node:crypto';
56
import path from 'node:path';
6-
import plist, { PlistValue } from 'plist';
7+
import plist, { PlistObject, PlistValue } from 'plist';
78
import { notarize, NotarizeOptions } from '@electron/notarize';
89
import { ElectronMacPlatform, sign, SignOptions } from '@electron/osx-sign';
10+
import semver from 'semver';
911
import { ProcessedOptionsWithSinglePlatformArch } from './types.js';
1012
import { generateAssetCatalogForIcon } from './icon-composer.js';
1113

1214
type NSUsageDescription = {
1315
[key in `NS${string}UsageDescription`]: string;
1416
};
1517

18+
type AsarIntegrity = NonNullable<App['asarIntegrity']>;
19+
20+
function isAsarIntegrity(value: unknown): value is AsarIntegrity {
21+
if (typeof value !== 'object' || value === null) return false;
22+
const entries = Object.values(value);
23+
if (entries.length === 0) return false;
24+
return entries.every(
25+
(entry) =>
26+
typeof entry === 'object' &&
27+
entry !== null &&
28+
'algorithm' in entry &&
29+
typeof entry.algorithm === 'string' &&
30+
entry.algorithm.length > 0 &&
31+
'hash' in entry &&
32+
typeof entry.hash === 'string' &&
33+
entry.hash.length > 0,
34+
);
35+
}
36+
1637
type BasePList = {
1738
CFBundleDisplayName: string;
1839
CFBundleExecutable: string;
@@ -510,6 +531,135 @@ export class MacApp extends App implements Plists {
510531
await fs.promises.rename(this.electronAppPath, this.renamedAppPath);
511532
}
512533

534+
/**
535+
* Sentinel string embedded in the Electron Framework binary, used as a marker
536+
* for the integrity digest storage location.
537+
* @see https://github.com/electron/electron/blob/2d5597b1b0fa697905380184e26c9f0947e05c5d/shell/common/asar/integrity_digest.mm#L24
538+
*/
539+
static INTEGRITY_DIGEST_SENTINEL = 'AGbevlPCksUGKNL8TSn7wGmJEuJsXb2A';
540+
541+
async setIntegrityDigest() {
542+
if (
543+
!this.opts.electronVersion ||
544+
!semver.valid(this.opts.electronVersion)
545+
) {
546+
debug(
547+
`Cannot determine Electron version (got "${this.opts.electronVersion}"), skipping integrity digest`,
548+
);
549+
return;
550+
}
551+
if (!semver.gte(this.opts.electronVersion, '41.0.0-alpha.1')) {
552+
return;
553+
}
554+
555+
const appPath = this.renamedAppPath ?? this.electronAppPath;
556+
let integrity = this.asarIntegrity;
557+
558+
// For universal builds, asarIntegrity isn't set on the shell App instance.
559+
// Fall back to reading it from the merged app's Info.plist.
560+
if (!integrity || Object.keys(integrity).length === 0) {
561+
const plistPath = path.join(appPath, 'Contents', 'Info.plist');
562+
if (fs.existsSync(plistPath)) {
563+
try {
564+
const plistData = plist.parse(
565+
(await fs.promises.readFile(plistPath)).toString(),
566+
) as PlistObject;
567+
if (isAsarIntegrity(plistData.ElectronAsarIntegrity)) {
568+
integrity = plistData.ElectronAsarIntegrity;
569+
}
570+
} catch (err) {
571+
warning(
572+
`Failed to read asar integrity from ${plistPath}: ${err}. The integrity digest will not be written.`,
573+
this.opts.quiet,
574+
);
575+
return;
576+
}
577+
}
578+
}
579+
580+
if (!integrity || Object.keys(integrity).length === 0) {
581+
return;
582+
}
583+
const frameworkPath = path.join(
584+
appPath,
585+
'Contents',
586+
'Frameworks',
587+
'Electron Framework.framework',
588+
'Electron Framework',
589+
);
590+
if (!fs.existsSync(frameworkPath)) {
591+
warning(
592+
`Electron Framework binary not found at ${frameworkPath}. The asar integrity digest will not be written, which may cause runtime failures.`,
593+
this.opts.quiet,
594+
);
595+
return;
596+
}
597+
598+
// Calculate v1 integrity digest: SHA256 over sorted (key, algorithm, hash) tuples
599+
// @see https://github.com/electron/electron/blob/2d5597b1b0fa697905380184e26c9f0947e05c5d/shell/common/asar/integrity_digest.mm#L52-L66
600+
const integrityHash = crypto.createHash('SHA256');
601+
for (const key of Object.keys(integrity).sort()) {
602+
const { algorithm, hash } = integrity[key];
603+
integrityHash.update(key);
604+
integrityHash.update(algorithm);
605+
integrityHash.update(hash);
606+
}
607+
const digest = integrityHash.digest();
608+
609+
// Write digest into every sentinel location in the Electron Framework binary
610+
const frameworkBinary = await fs.promises.readFile(frameworkPath);
611+
const sentinel = Buffer.from(MacApp.INTEGRITY_DIGEST_SENTINEL);
612+
let searchOffset = 0;
613+
let found = false;
614+
let written = false;
615+
616+
// eslint-disable-next-line no-constant-condition
617+
while (true) {
618+
const sentinelIndex = frameworkBinary.indexOf(sentinel, searchOffset);
619+
if (sentinelIndex === -1) break;
620+
found = true;
621+
622+
const base = sentinelIndex + sentinel.length;
623+
if (base + 34 > frameworkBinary.length) {
624+
warning(
625+
`Insufficient space after integrity digest sentinel at offset ${sentinelIndex} in Electron Framework binary. The binary may be corrupted or incompatible.`,
626+
this.opts.quiet,
627+
);
628+
searchOffset = base;
629+
continue;
630+
}
631+
frameworkBinary.writeUInt8(1, base); // used = true
632+
frameworkBinary.writeUInt8(1, base + 1); // version = 1
633+
digest.copy(frameworkBinary, base + 2); // 32-byte SHA256 digest
634+
written = true;
635+
636+
searchOffset = base + 2 + 32;
637+
}
638+
639+
if (found && !written) {
640+
throw new Error(
641+
'Found integrity digest sentinel(s) in Electron Framework binary but could not write to any of them. The binary may be corrupted.',
642+
);
643+
}
644+
645+
if (written) {
646+
try {
647+
await fs.promises.writeFile(frameworkPath, frameworkBinary);
648+
} catch (err) {
649+
throw new Error(
650+
`Failed to write integrity digest to Electron Framework binary at ${frameworkPath}: ${err}`,
651+
);
652+
}
653+
debug('Wrote integrity digest to Electron Framework binary');
654+
} else {
655+
warning(
656+
`No integrity digest sentinel found in Electron Framework binary at ${frameworkPath}. ` +
657+
'This is unexpected for Electron >= 41.0.0. The asar integrity digest was not written.',
658+
this.opts.quiet,
659+
);
660+
}
661+
}
662+
513663
async signAppIfSpecified() {
514664
const osxSignOpt = this.opts.osxSign;
515665
const platform = this.opts.platform;
@@ -575,6 +725,7 @@ export class MacApp extends App implements Plists {
575725
await this.renameElectron();
576726
await this.renameAppAndHelpers();
577727
await this.copyExtraResources();
728+
await this.setIntegrityDigest();
578729
await this.signAppIfSpecified();
579730
await this.notarizeAppIfSpecified();
580731
return this.move();

src/universal.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export async function packageUniversalMac(
9494
force: false,
9595
});
9696

97+
await app.setIntegrityDigest();
9798
await app.signAppIfSpecified();
9899
await app.notarizeAppIfSpecified();
99100
await app.move();

test/config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"version": "27.0.0"
2+
"version": "41.1.0"
33
}

test/packager.spec.ts

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
import { OfficialArch, OfficialPlatform, Options } from '../src/types.js';
1818
import { createDownloadOpts, downloadElectronZip } from '../src/download.js';
1919
import plist, { PlistObject } from 'plist';
20-
import { filterCFBundleIdentifier } from '../src/mac.js';
20+
import { filterCFBundleIdentifier, MacApp } from '../src/mac.js';
2121

2222
import { NtExecutable, NtExecutableResource, Resource } from 'resedit';
2323

@@ -1348,6 +1348,146 @@ describe('packager', () => {
13481348
});
13491349
});
13501350

1351+
describe('asar integrity digest', () => {
1352+
it('writes digest into Electron Framework binary when asar is enabled', async ({
1353+
baseOpts,
1354+
}) => {
1355+
const opts = {
1356+
...baseOpts,
1357+
asar: true,
1358+
};
1359+
1360+
const [finalPath] = await packager(opts);
1361+
const appPath = path.join(finalPath, `${opts.name}.app`);
1362+
const frameworkPath = path.join(
1363+
appPath,
1364+
'Contents',
1365+
'Frameworks',
1366+
'Electron Framework.framework',
1367+
'Electron Framework',
1368+
);
1369+
const binary = fs.readFileSync(frameworkPath);
1370+
const sentinel = Buffer.from(MacApp.INTEGRITY_DIGEST_SENTINEL);
1371+
1372+
const sentinelIndex = binary.indexOf(sentinel);
1373+
expect(sentinelIndex).not.toBe(-1);
1374+
1375+
const base = sentinelIndex + sentinel.length;
1376+
expect(binary.readUInt8(base)).toBe(1); // used = true
1377+
expect(binary.readUInt8(base + 1)).toBe(1); // version = 1
1378+
1379+
// Verify the stored digest matches a fresh calculation from the plist
1380+
const infoPlist = parseInfoPlist(finalPath);
1381+
const integrity = infoPlist.ElectronAsarIntegrity as Record<
1382+
string,
1383+
{ algorithm: string; hash: string }
1384+
>;
1385+
const expectedHash = crypto.createHash('SHA256');
1386+
for (const key of Object.keys(integrity).sort()) {
1387+
expectedHash.update(key);
1388+
expectedHash.update(integrity[key].algorithm);
1389+
expectedHash.update(integrity[key].hash);
1390+
}
1391+
const expectedDigest = expectedHash.digest();
1392+
const storedDigest = binary.subarray(base + 2, base + 2 + 32);
1393+
expect(storedDigest).toEqual(expectedDigest);
1394+
});
1395+
1396+
it('does not write digest when asar is disabled', async ({
1397+
baseOpts,
1398+
}) => {
1399+
const opts = {
1400+
...baseOpts,
1401+
asar: false,
1402+
};
1403+
1404+
const [finalPath] = await packager(opts);
1405+
const appPath = path.join(finalPath, `${opts.name}.app`);
1406+
const frameworkPath = path.join(
1407+
appPath,
1408+
'Contents',
1409+
'Frameworks',
1410+
'Electron Framework.framework',
1411+
'Electron Framework',
1412+
);
1413+
const binary = fs.readFileSync(frameworkPath);
1414+
const sentinel = Buffer.from(MacApp.INTEGRITY_DIGEST_SENTINEL);
1415+
1416+
const sentinelIndex = binary.indexOf(sentinel);
1417+
if (sentinelIndex !== -1) {
1418+
const base = sentinelIndex + sentinel.length;
1419+
expect(binary.readUInt8(base)).toBe(0); // used = false
1420+
}
1421+
});
1422+
1423+
it('writes digest from Info.plist fallback when asarIntegrity is not set on the instance', async ({
1424+
baseOpts,
1425+
}) => {
1426+
// This simulates the universal build path where the shell MacApp instance
1427+
// doesn't have asarIntegrity set, but the merged app's Info.plist does.
1428+
const opts = {
1429+
...baseOpts,
1430+
asar: true,
1431+
};
1432+
1433+
const [finalPath] = await packager(opts);
1434+
const appPath = path.join(finalPath, `${opts.name}.app`);
1435+
const frameworkPath = path.join(
1436+
appPath,
1437+
'Contents',
1438+
'Frameworks',
1439+
'Electron Framework.framework',
1440+
'Electron Framework',
1441+
);
1442+
1443+
// Read the expected digest from the plist (written during normal packaging)
1444+
const infoPlist = parseInfoPlist(finalPath);
1445+
const integrity = infoPlist.ElectronAsarIntegrity as Record<
1446+
string,
1447+
{ algorithm: string; hash: string }
1448+
>;
1449+
const expectedHash = crypto.createHash('SHA256');
1450+
for (const key of Object.keys(integrity).sort()) {
1451+
expectedHash.update(key);
1452+
expectedHash.update(integrity[key].algorithm);
1453+
expectedHash.update(integrity[key].hash);
1454+
}
1455+
const expectedDigest = expectedHash.digest();
1456+
1457+
// Zero out the digest area in the binary to simulate a fresh universal merge
1458+
const binary = fs.readFileSync(frameworkPath);
1459+
const sentinel = Buffer.from(MacApp.INTEGRITY_DIGEST_SENTINEL);
1460+
const sentinelIndex = binary.indexOf(sentinel);
1461+
expect(sentinelIndex).not.toBe(-1);
1462+
const base = sentinelIndex + sentinel.length;
1463+
binary.fill(0, base, base + 34);
1464+
fs.writeFileSync(frameworkPath, binary);
1465+
1466+
// Create a MacApp instance without asarIntegrity set, pointing at the packaged app
1467+
const macApp = new MacApp(
1468+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1469+
{ ...opts, platform: 'darwin', arch: 'x64' } as any,
1470+
'',
1471+
);
1472+
// Point stagingPath at the output directory so renamedAppPath resolves correctly
1473+
macApp.cachedStagingPath = finalPath;
1474+
expect(macApp.asarIntegrity).toBeUndefined();
1475+
1476+
await macApp.setIntegrityDigest();
1477+
1478+
// Verify the digest was written correctly from the plist fallback
1479+
const updatedBinary = fs.readFileSync(frameworkPath);
1480+
const updatedBase = updatedBinary.indexOf(sentinel) + sentinel.length;
1481+
expect(updatedBinary.readUInt8(updatedBase)).toBe(1); // used = true
1482+
expect(updatedBinary.readUInt8(updatedBase + 1)).toBe(1); // version = 1
1483+
const storedDigest = updatedBinary.subarray(
1484+
updatedBase + 2,
1485+
updatedBase + 2 + 32,
1486+
);
1487+
expect(storedDigest).toEqual(expectedDigest);
1488+
});
1489+
});
1490+
13511491
describe('codesign', () => {
13521492
it('can sign the app', async ({ baseOpts }) => {
13531493
const opts = {

0 commit comments

Comments
 (0)