Skip to content

Commit ce16569

Browse files
committed
feat: add integrity digest management commands and functionality
1 parent db33433 commit ce16569

File tree

4 files changed

+357
-1
lines changed

4 files changed

+357
-1
lines changed

bin/asar.mjs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import packageJSON from '../package.json' with { type: 'json' };
44
import { createPackageWithOptions, listPackage, extractFile, extractAll } from '../lib/asar.js';
5+
import { enableIntegrityDigestForApp, disableIntegrityDigestForApp, verifyIntegrityDigestForApp, printStoredIntegrityDigestForApp } from '../lib/integrity-digest.js';
56
import { program } from 'commander';
67
import fs from 'node:fs';
78
import path from 'node:path';
@@ -71,6 +72,26 @@ program.command('extract <archive> <dest>')
7172
extractAll(archive, dest)
7273
})
7374

75+
program.command('integrity-digest <app> <command>')
76+
.alias('id')
77+
.description('manage integrity digest in app binary')
78+
.action(async function (app, command) {
79+
const allowedCommands = ['on', 'off', 'status', 'verify']
80+
switch (command) {
81+
case 'on': await enableIntegrityDigestForApp(app)
82+
break
83+
case 'off': await disableIntegrityDigestForApp(app)
84+
break
85+
case 'status': await printStoredIntegrityDigestForApp(app)
86+
break
87+
case 'verify': await verifyIntegrityDigestForApp(app)
88+
break
89+
default:
90+
console.log('Unknown integrity digest command: %s. Allowed commands are: %s', command, allowedCommands.join(', '))
91+
process.exit(1)
92+
}
93+
})
94+
7495
program.command('*', { hidden: true})
7596
.action(function (_cmd, args) {
7697
console.log('asar: \'%s\' is not an asar command. See \'asar --help\'.', args[0])

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,13 @@
4242
"dependencies": {
4343
"commander": "^13.1.0",
4444
"glob": "^11.0.1",
45-
"minimatch": "^10.0.1"
45+
"minimatch": "^10.0.1",
46+
"plist": "^3.1.0"
4647
},
4748
"devDependencies": {
4849
"@tsconfig/node22": "^22.0.1",
4950
"@types/node": "^22.12.0",
51+
"@types/plist": "^3",
5052
"electron": "^35.7.5",
5153
"prettier": "^3.3.3",
5254
"typedoc": "~0.25.13",

src/integrity-digest.ts

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
import path from 'node:path';
2+
import crypto from 'node:crypto';
3+
import plist from 'plist';
4+
5+
import { wrappedFs as fs } from './wrapped-fs.js';
6+
import { FileRecord } from './disk.js';
7+
8+
9+
// Integrity digest type definitions
10+
11+
type IntegrityDigest<Version extends number, AdditionalParams> =
12+
| { used: false }
13+
| ({ used: true; version: Version } & AdditionalParams);
14+
15+
type IntegrityDigestV1 = IntegrityDigest<1, { sha256Digest: Buffer }>;
16+
17+
type AnyIntegrityDigest = IntegrityDigestV1; // Extend this union type as new versions are added
18+
19+
20+
// Integrity digest calculation functions
21+
22+
type AsarIntegrity = Record<
23+
string,
24+
Pick<FileRecord['integrity'], 'algorithm' | 'hash'>
25+
>;
26+
27+
function calculateIntegrityDigestV1(
28+
asarIntegrity: AsarIntegrity,
29+
): IntegrityDigestV1 {
30+
const integrityHash = crypto.createHash('SHA256');
31+
for (const key of Object.keys(asarIntegrity).sort()) {
32+
const { algorithm, hash } = asarIntegrity[key];
33+
integrityHash.update(key);
34+
integrityHash.update(algorithm);
35+
integrityHash.update(hash);
36+
}
37+
return {
38+
used: true,
39+
version: 1,
40+
sha256Digest: integrityHash.digest(),
41+
}
42+
}
43+
44+
export function calculateIntegrityDigestV1ForApp(
45+
appPath: string,
46+
): IntegrityDigestV1 {
47+
const plistPath = path.join(appPath, 'Contents', 'Info.plist');
48+
const plistBuffer = fs.readFileSync(plistPath);
49+
const plistData = plist.parse(plistBuffer.toString()) as Record<string, any>;
50+
const asarIntegrity = plistData['ElectronAsarIntegrity'] as AsarIntegrity;
51+
return calculateIntegrityDigestV1(asarIntegrity);
52+
}
53+
54+
55+
/// Integrity digest handling errors
56+
57+
const UnknownIntegrityDigestVersionError = class extends Error {
58+
constructor(version: number) {
59+
super(`Unknown integrity digest version: ${version}`);
60+
this.name = 'UnknownIntegrityDigestVersionError';
61+
}
62+
};
63+
64+
65+
// Integrity digest storage and retrieval functions
66+
67+
const INTEGRITY_DIGEST_SENTINEL = 'AGbevlPCksUGKNL8TSn7wGmJEuJsXb2A';
68+
69+
function pathToIntegrityDigestFile(appPath: string) {
70+
if (appPath.endsWith('.app')) {
71+
return path.resolve(
72+
appPath,
73+
'Contents',
74+
'Frameworks',
75+
'Electron Framework.framework',
76+
'Electron Framework',
77+
);
78+
}
79+
throw new Error('App path must be an .app bundle');
80+
}
81+
82+
function forEachSentinelInApp(
83+
appPath: string,
84+
callback: (sentinelIndex: number, integrityFile: Buffer) => void,
85+
writeBack: boolean = false,
86+
) {
87+
const integrityFilePath = pathToIntegrityDigestFile(appPath);
88+
const integrityFile = fs.readFileSync(integrityFilePath);
89+
let searchCursor = 0;
90+
const sentinelAsBuffer = Buffer.from(INTEGRITY_DIGEST_SENTINEL);
91+
do {
92+
const sentinelIndex = integrityFile.indexOf(sentinelAsBuffer, searchCursor);
93+
if (sentinelIndex === -1) break;
94+
callback(sentinelIndex, integrityFile);
95+
searchCursor = sentinelIndex + sentinelAsBuffer.length;
96+
} while (true);
97+
if (writeBack) {
98+
fs.writeFileSync(integrityFilePath, integrityFile);
99+
}
100+
}
101+
102+
export function doDigestsMatch(
103+
digestA: AnyIntegrityDigest,
104+
digestB: AnyIntegrityDigest,
105+
): boolean {
106+
if (digestA.used !== digestB.used) return false;
107+
if (digestA.used && digestB.used) {
108+
if (digestA.version !== digestB.version) return false;
109+
switch (digestA.version) {
110+
case 1:
111+
return digestA.sha256Digest.equals(digestB.sha256Digest);
112+
default:
113+
throw new UnknownIntegrityDigestVersionError(digestA.version);
114+
}
115+
} else return true;
116+
}
117+
118+
function sentinelIndexToDigest<T extends AnyIntegrityDigest>(
119+
integrityFile: Buffer,
120+
sentinelIndex: number,
121+
): T {
122+
const used = integrityFile.readUInt8(sentinelIndex + INTEGRITY_DIGEST_SENTINEL.length) === 1;
123+
if (!used) {
124+
return { used: false } as T;
125+
} else {
126+
const version = integrityFile.readUInt8(sentinelIndex + INTEGRITY_DIGEST_SENTINEL.length + 1);
127+
switch (version) {
128+
case 1: {
129+
const sha256Digest = integrityFile.subarray(
130+
sentinelIndex + INTEGRITY_DIGEST_SENTINEL.length + 2,
131+
sentinelIndex + INTEGRITY_DIGEST_SENTINEL.length + 2 + 32, // SHA256 digest size
132+
);
133+
return {
134+
used: true,
135+
version: 1,
136+
sha256Digest,
137+
} as T;
138+
}
139+
default:
140+
throw new UnknownIntegrityDigestVersionError(version);
141+
}
142+
}
143+
}
144+
145+
export async function getStoredIntegrityDigestForApp<T extends AnyIntegrityDigest>(
146+
appPath: string,
147+
): Promise<T> {
148+
let lastDigestFound: T | null = null;
149+
forEachSentinelInApp(appPath, (sentinelIndex, integrityFile) => {
150+
const currentDigest = sentinelIndexToDigest<T>(
151+
integrityFile,
152+
sentinelIndex,
153+
);
154+
if (lastDigestFound === null) {
155+
lastDigestFound = currentDigest;
156+
} else if (!doDigestsMatch(currentDigest, lastDigestFound)) {
157+
throw new Error('Multiple differing integrity digests found in the binary');
158+
}
159+
lastDigestFound = currentDigest;
160+
});
161+
if (lastDigestFound === null) {
162+
throw new Error('No integrity digest found in the binary');
163+
}
164+
return lastDigestFound;
165+
}
166+
167+
export async function setStoredIntegrityDigestForApp<T extends AnyIntegrityDigest>(
168+
appPath: string,
169+
digest: T,
170+
): Promise<void> {
171+
if (digest.used === true && digest.version !== 1) {
172+
throw new UnknownIntegrityDigestVersionError(digest.version);
173+
}
174+
forEachSentinelInApp(appPath, (sentinelIndex, integrityFile) => {
175+
integrityFile.writeUInt8(digest.used ? 1 : 0, sentinelIndex + INTEGRITY_DIGEST_SENTINEL.length);
176+
const oldVersion = integrityFile.readUInt8(sentinelIndex + INTEGRITY_DIGEST_SENTINEL.length + 1);
177+
switch (oldVersion) {
178+
case 1:
179+
integrityFile.fill(
180+
0,
181+
sentinelIndex + INTEGRITY_DIGEST_SENTINEL.length + 2,
182+
sentinelIndex + INTEGRITY_DIGEST_SENTINEL.length + 2 + 32, // SHA256 digest size
183+
);
184+
break;
185+
}
186+
if (digest.used) {
187+
integrityFile.writeUInt8(
188+
digest.version,
189+
sentinelIndex + INTEGRITY_DIGEST_SENTINEL.length + 1,
190+
);
191+
switch (digest.version) {
192+
case 1: {
193+
const v1Digest = digest as IntegrityDigestV1 & { used: true };
194+
v1Digest.sha256Digest.copy(
195+
integrityFile,
196+
sentinelIndex + INTEGRITY_DIGEST_SENTINEL.length + 2,
197+
);
198+
break;
199+
}
200+
default:
201+
throw new UnknownIntegrityDigestVersionError(digest.version);
202+
}
203+
}
204+
}, true);
205+
}
206+
207+
208+
// High-level integrity digest management functions
209+
210+
export function printDigest(digest: AnyIntegrityDigest, prefix: string = '') {
211+
const digestLogger = prefix ? (s: string, ...args: any[]) => console.log(prefix + s, ...args) : console.log;
212+
if (!digest.used) {
213+
digestLogger('Integrity digest is OFF');
214+
return;
215+
}
216+
digestLogger('Integrity digest is ON (version: %d)', digest.version);
217+
switch (digest.version) {
218+
case 1:
219+
digestLogger('\tDigest (SHA256): %s', digest.sha256Digest.toString('hex'));
220+
break;
221+
default:
222+
digestLogger('\tUnknown metadata for digest version: %d', digest.version);
223+
}
224+
}
225+
226+
export async function enableIntegrityDigestForApp(
227+
appPath: string,
228+
): Promise<void> {
229+
try {
230+
console.log('Calculating integrity digest...');
231+
const digest = calculateIntegrityDigestV1ForApp(appPath);
232+
console.log('Turning integrity digest ON...');
233+
await setStoredIntegrityDigestForApp(appPath, digest);
234+
console.log('Integrity digest turned ON');
235+
} catch (e) {
236+
const errorMessage = e instanceof Error ? e.message : String(e);
237+
console.log('Failed to turn ON integrity digest: %s', errorMessage);
238+
}
239+
}
240+
241+
export async function disableIntegrityDigestForApp(
242+
appPath: string,
243+
): Promise<void> {
244+
try {
245+
console.log('Turning integrity digest OFF...');
246+
await setStoredIntegrityDigestForApp(appPath, { used: false });
247+
console.log('Integrity digest turned OFF');
248+
} catch (e) {
249+
const errorMessage = e instanceof Error ? e.message : String(e);
250+
console.log('Failed to turn OFF integrity digest: %s', errorMessage);
251+
}
252+
}
253+
254+
export async function printStoredIntegrityDigestForApp(
255+
appPath: string,
256+
): Promise<void> {
257+
try {
258+
const storedDigest = await getStoredIntegrityDigestForApp(appPath);
259+
printDigest(storedDigest);
260+
} catch (e) {
261+
const errorMessage = e instanceof Error ? e.message : String(e);
262+
console.log('Failed to read integrity digest: %s', errorMessage);
263+
}
264+
}
265+
266+
export async function verifyIntegrityDigestForApp(
267+
appPath: string,
268+
): Promise<void> {
269+
try {
270+
const storedDigest = await getStoredIntegrityDigestForApp(appPath);
271+
if (!storedDigest.used) {
272+
console.log('Integrity digest is off, verification SKIPPED');
273+
return;
274+
}
275+
const calculatedDigest = calculateIntegrityDigestV1ForApp(appPath);
276+
if (doDigestsMatch(storedDigest, calculatedDigest)) {
277+
console.log('Integrity digest verification PASSED');
278+
} else {
279+
console.log('Integrity digest verification FAILED');
280+
console.log('Expected digest:');
281+
printDigest(calculatedDigest, '\t');
282+
console.log('Actual digest:');
283+
printDigest(storedDigest, '\t');
284+
}
285+
} catch (e) {
286+
const errorMessage = e instanceof Error ? e.message : String(e);
287+
console.log('Failed to verify integrity digest: %s', errorMessage);
288+
}
289+
}

0 commit comments

Comments
 (0)