Repository: electron/electron
Pull request: feat: validate integrity of ASAR Integrity dictionary on macOS

#### Description of Change

cc @MarshallOfSound 

This closes up a potential risk where an attacker with access to the App Management or Full Disk Access TCC services tampers with both the `app.asar` archive and the `Info.plist` for an app. I have found, through practice, that doing so does not always render the app un-launchable and can essentially bypass ASAR Integrity (however upon reboot, the app _will_ fail to launch). All signs point to Gatekeeper (or some related functionality) caching validation results in a cache that is invalidated upon reboot.

Due to this risk, I believe it is important to do our own validation on launch right before we check the hashes. ~~An alternative mitigation, storing the hashes inside the main executable so they inherit the protections of the executable's code signature, was deemed infeasible due to the flimsiness of injecting new content into an already-built Mach-O binary (the state in which Electron is delivered to end developers in most cases).~~

_**UPDATE: The executable is now used as part of this additional layer of security. The above and below stricken text is no longer relevant.**_

~~The validation is performed with the flags: `kSecCSBasicValidateOnly | kSecCSNoNetworkAccess`. These were chosen in order to minimize the validation overhead. Even under a basic validation, any tampering of `Info.plist` will be caught and an `errSecCSInfoPlistFailed` error code will be returned. As the hypothetical compromise above requires the tampering of `Info.plist`, a basic validation is a sufficient mitigation. The `kSecCSNoNetworkAccess` flag is used to ensure the validation does not perform any network I/O (unneeded for the validation we're performing).~~

~~Overall, when testing this validation code in isolation on my machine, I find it takes around **20 milliseconds**. I personally believe this will have a negligible-to-nonexistent impact on user experience.~~

#### Checklist

- [x] PR description included and stakeholders cc'd

#### Release Notes

Notes: Added additional defence against privileged user modifications to ASAR Integrity protected applications on macOS


Changed files:
filename: filenames.gni
patch: @@ -194,6 +194,8 @@ filenames = {
     "shell/common/api/electron_api_clipboard_mac.mm",
     "shell/common/api/electron_api_native_image_mac.mm",
     "shell/common/asar/archive_mac.mm",
+    "shell/common/asar/integrity_digest.h",
+    "shell/common/asar/integrity_digest.mm",
     "shell/common/application_info_mac.mm",
     "shell/common/language_util_mac.mm",
     "shell/common/mac/main_application_bundle.h",

filename: shell/common/asar/archive_mac.mm
patch: @@ -17,6 +17,7 @@
 #include "base/files/file_util.h"
 #include "base/strings/sys_string_conversions.h"
 #include "shell/common/asar/asar_util.h"
+#include "shell/common/asar/integrity_digest.h"
 
 namespace asar {
 
@@ -39,6 +40,9 @@
   NSDictionary* integrity = [[NSBundle mainBundle]
       objectForInfoDictionaryKey:@"ElectronAsarIntegrity"];
 
+  if (!IsIntegrityDictionaryValid(integrity))
+    return std::nullopt;
+
   // Integrity not provided
   if (!integrity)
     return std::nullopt;

filename: shell/common/asar/integrity_digest.h
patch: @@ -0,0 +1,15 @@
+// Copyright (c) 2025 Noah Gregory
+// Use of this source code is governed by the MIT license that can be
+// found in the LICENSE file.
+
+#ifndef ELECTRON_SHELL_COMMON_ASAR_INTEGRITY_DIGEST_H_
+#define ELECTRON_SHELL_COMMON_ASAR_INTEGRITY_DIGEST_H_
+
+#include <Foundation/Foundation.h>
+namespace asar {
+
+bool IsIntegrityDictionaryValid(NSDictionary* integrity_dict);
+
+}  // namespace asar
+
+#endif  // ELECTRON_SHELL_COMMON_ASAR_INTEGRITY_DIGEST_H_

filename: shell/common/asar/integrity_digest.mm
patch: @@ -0,0 +1,74 @@
+// Copyright (c) 2025 Noah Gregory
+// Use of this source code is governed by the MIT license that can be
+// found in the LICENSE file.
+
+#include "shell/common/asar/integrity_digest.h"
+
+#include <Foundation/Foundation.h>
+
+#include <array>
+#include <cstdint>
+#include <span>
+
+#include "base/strings/sys_string_conversions.h"
+#include "crypto/hash.h"
+
+namespace asar {
+
+constexpr crypto::hash::HashKind kIntegrityDictionaryHashKind =
+    crypto::hash::HashKind::kSha256;
+
+constexpr size_t kIntegrityDictionaryDigestSize =
+    DigestSizeForHashKind(kIntegrityDictionaryHashKind);
+constexpr char kIntegrityDictionaryDigestSentinel[] =
+    "AGbevlPCksUGKNL8TSn7wGmJEuJsXb2A";
+
+struct IntegrityDictionaryDigestSlot {
+  uint8_t sentinel[sizeof(kIntegrityDictionaryDigestSentinel) - 1];
+  uint8_t used;
+  uint8_t version;
+  uint8_t digest[kIntegrityDictionaryDigestSize];
+};
+
+constexpr IntegrityDictionaryDigestSlot MakeDigestSlot(
+    const char (&sentinel)[33]) {
+  IntegrityDictionaryDigestSlot slot{};
+  std::span<uint8_t, 32> slot_sentinel_span(slot.sentinel);
+  std::copy_n(sentinel, slot_sentinel_span.size(), slot_sentinel_span.begin());
+  slot.used = false;  // to be set at package-time
+  slot.version = 0;   // to be set at package-time
+  return slot;
+}
+
+__attribute__((section("__DATA_CONST,__asar_integrity"), used))
+const volatile IntegrityDictionaryDigestSlot kIntegrityDictionaryDigest =
+    MakeDigestSlot(kIntegrityDictionaryDigestSentinel);
+
+bool IsIntegrityDictionaryValid(NSDictionary* integrity) {
+  if (kIntegrityDictionaryDigest.used == false)
+    return true;  // No digest to validate against, fail open
+  if (kIntegrityDictionaryDigest.version != 1)
+    return false;  // Unknown version, fail closed
+  crypto::hash::Hasher integrity_hasher(kIntegrityDictionaryHashKind);
+  for (NSString *relative_path_key in
+       [[integrity allKeys] sortedArrayUsingComparator:^NSComparisonResult(
+                                NSString* s1, NSString* s2) {
+         return [s1 compare:s2 options:NSLiteralSearch];
+       }]) {
+    NSDictionary* file_integrity = [integrity objectForKey:relative_path_key];
+    NSString* algorithm = [file_integrity objectForKey:@"algorithm"];
+    NSString* hash = [file_integrity objectForKey:@"hash"];
+    integrity_hasher.Update(base::SysNSStringToUTF8(relative_path_key));
+    integrity_hasher.Update(base::SysNSStringToUTF8(algorithm));
+    integrity_hasher.Update(base::SysNSStringToUTF8(hash));
+  }
+  std::array<uint8_t, kIntegrityDictionaryDigestSize> digest;
+  integrity_hasher.Finish(digest);
+  if (!std::equal(digest.begin(), digest.end(),
+                  kIntegrityDictionaryDigest.digest)) {
+    return false;
+  }
+  return true;
+}
+
+}  // namespace asar