Skip to content

feat: asar integrity digest#1890

Open
erickzhao wants to merge 11 commits into
mainfrom
feat/asar-integrity-digest
Open

feat: asar integrity digest#1890
erickzhao wants to merge 11 commits into
mainfrom
feat/asar-integrity-digest

Conversation

@erickzhao
Copy link
Copy Markdown
Member

@erickzhao erickzhao commented Mar 30, 2026

Description

This PR implements the logic from electron/asar#380 and electron/forge#4159 directly into the Packager pipeline.

The pivot to implementing this logic in @electron/packager means that we get to extend the existing AsarIntegrity handling logic and provide a zero-config way of implementing the integrity digest for consumers. Packager and Forge users will need to just bump up the version of Packager they're consuming to get this feature.

For more details, see the original Electron PR: electron/electron#48587.

@erickzhao erickzhao force-pushed the feat/asar-integrity-digest branch 2 times, most recently from 99dcc60 to 9700342 Compare March 30, 2026 23:47
Co-authored-by: Noah Gregory <noahmgregory@gmail.com>
@erickzhao erickzhao force-pushed the feat/asar-integrity-digest branch from 9700342 to 24441f3 Compare March 30, 2026 23:48
@erickzhao erickzhao marked this pull request as ready for review March 31, 2026 19:50
@erickzhao erickzhao requested a review from a team as a code owner March 31, 2026 19:50
MarshallOfSound and others added 5 commits March 31, 2026 16:25
The "does not write digest when asar is disabled" test wrapped its
only assertion in `if (sentinelIndex !== -1)`, silently passing with
zero assertions if the sentinel was missing. Both sibling tests in the
same describe block explicitly assert the sentinel exists — this one
drifted. With test/config.json on 41.1.0 the sentinel is guaranteed
present, so the guard only masked failures.
Previously setIntegrityDigest loaded the entire Electron Framework
binary (150-500 MB) into memory, scanned it, modified 34 bytes, and
wrote the whole thing back. Now it scans in 4 MB chunks with two
concurrent workers (I/O overlaps Buffer.indexOf CPU) and patches only
the 34-byte digest slot(s) via handle.write at the found position(s).
Each chunk overreads sentinel.length-1 bytes so a sentinel straddling
a boundary is still detected. Peak memory drops from ~binary-size to
~8 MB. Same approach as electron/fuses#96, which benched 10-15x faster
on 150-300 MB binaries.

Adds two synthetic-binary tests: one plants the sentinel across a 4 MB
boundary (verified load-bearing via mutation), one plants a sentinel in
each of two chunks to cover the universal-binary multi-write path. Both
skip packager() and run in ~10ms.
setIntegrityDigest had grown to ~155 lines after the chunked-scan
change. Pull the scan/validate/write block out to a module-level
helper alongside isAsarIntegrity; the method now just gates on
version, resolves integrity, computes the hash, and calls through.
Sentinel is passed as a parameter rather than referenced via
MacApp.INTEGRITY_DIGEST_SENTINEL to avoid a no-use-before-define
disable comment.
Copy link
Copy Markdown

@nmggithub nmggithub left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tentatively approving this. As long as it works, I think it's good. My only concern would be how difficult this may be to refactor if we ever add a new version, but we might just have to cross that bridge when we come to it.

Comment thread src/mac.ts
await this.setIntegrityDigest();
await this.signAppIfSpecified();
await this.notarizeAppIfSpecified();
return this.move();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, but would it be worth extracting a helper function or a comment on the order of operations here? I see the duplicated code in universal. Asking as I think it'd require contextual knowledge (or AI cross-referencing?) to know that the app can't be modified post-signing.

Comment thread src/mac.ts
);
return;
}
if (!semver.gte(this.opts.electronVersion, '41.0.0-alpha.1')) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor nit: Can we add a debug log here to match the log pattern for "Cannot determine Electron version"?

Comment thread src/mac.ts
Comment on lines +119 to +121
for (const base of writePositions) {
await handle.write(payload, 0, payload.length, base);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Full disclosure: Claude is a close comrade.


Based on this loop, multiple sentinels would be patched, not specifically selected, when the runtime only reads one.

The runtime declares exactly one kIntegrityDictionaryDigest in __DATA_CONST,__asar_integrity, so the expected match count is 1 (or 2 for fat binaries containing both slices).

Calling this out as maybe we could also have a sentinels.length check to mirror the functionality/expectations/contract that electron/fuses L148-L154 utilizes

    if (sentinels.length > 2) {
      throw new Error(
        `Found ${sentinels.length} copies of the fuse sentinel in the provided Electron binary. ` +
          'At most 2 are expected (one per slice of a universal macOS binary). ' +
          'This may indicate a corrupted binary or an unsupported build configuration.',
      );
    }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I much prefer an open loop. Fat Mach binaries can technically support any number of binary images. I've seen some with three: 1 x86 image and 2 ARM ones (each for slightly different CPU configurations). Even if that would never happen with us, limiting it to 2 is completely unnecessary.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point! I guess the callout was to mirror the logic approach - should fuses restriction be relaxed then or are they completely decoupled? Maybe I misunderstood something here.
The high entropy sentinel should effectively minimize a chance of a stay collision, but if one were to happen, it'd be a fun adventure for someone to trace back. Maybe could worth validating expected account versus slice count to avoid setting a hard limit? Or maybe just adding a debug log here to call out how many slices were identified?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the sentinel is high entropy enough to where it won't matter. I don't think the limit of 2 for the Fuses was a collision(false positive)-reduction feature, but just a simplification of the logic. If it were a collision-reduction feature, it, as a count, wouldn't even be a good one as it's would technically be possible for two matches in the same binary image.

I understand your paranoia, though. I'd prefer simply debug logging each time we write a digest instead of once at the end of the loop. I thought we already did the former, but perhaps not.

Comment thread src/mac.ts
Comment on lines +581 to +590
async setIntegrityDigest() {
if (!this.opts.electronVersion || !semver.valid(this.opts.electronVersion)) {
debug(
`Cannot determine Electron version (got "${this.opts.electronVersion}"), skipping integrity digest`,
);
return;
}
if (!semver.gte(this.opts.electronVersion, '41.0.0-alpha.1')) {
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting callout from Claude. There are multiple instances of warning(...); return; blocks, but that doesn't mirror the approach of writePositions.length === 0 that throws an Error. These three preconditions warn-and-continue and also can be --quieted to be fully suppressed from dev logs.
Calling this out as there's no throw, so signAppIfSpecified can then sign and notarize an app whose framework slot still has used = 0.

    await this.setIntegrityDigest();
    await this.signAppIfSpecified();
    await this.notarizeAppIfSpecified();

According to Claude, the runtime explicitly fails open in that case (w/ // No digest to validate against, fail open in integrity_digest.mm), creating a no-op that might be difficult for devs to debug.

  if (kIntegrityDictionaryDigest.used == false)
    return true;  // No digest to validate against, fail open

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Runtime fails open on purpose because this is an optional feature. If it fails to apply, it's up to the packager to decide if it wants to continue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants