Skip to main content

Debugging macOS Notarization: Solving 'Invalid Signature' in Electron Builds

 

The "It Works on My Machine" Trap

You have a green build pipeline. The application runs locally. You’ve successfully uploaded your artifact to the Apple Notary Service, and xcrun notarytool returns status: Accepted. Yet, when you download the DMG and attempt to launch it on a fresh macOS instance, Gatekeeper intervenes: "App is damaged and can't be opened."

Running a manual assessment usually yields the dreaded, ambiguous failure:

spctl --assess --type execute --verbose --ignore-cache /Applications/MyApp.app
# Output: /Applications/MyApp.app: rejected
# source=Unnotarized Developer ID

Or worse, deep in the system logs, you find errSecInternalComponent or Missing Secure Timestamp.

This is rarely a code issue; it is a DevOps architecture issue involving the Mach-O binary structure, nested code signing, and the Hardened Runtime requirements introduced by macOS Catalina and strictly enforced in Sonoma and Sequoia.

Root Cause: The Timestamp and The Sandbox

There are two primary distinct failures that conflate into "Invalid Signature."

1. The Missing Secure Timestamp

A valid Code Signing CMS blob (Cryptographic Message Syntax) must contain a signed timestamp from a trusted Authority (Apple's Timestamp Server). Without this, the signature is validated against the current system time. If the certificate expires, the app dies. With a secure timestamp, the signature is validated against the time of signing.

Apple’s Notary Service now strictly rejects binaries where the CMS blob lacks this timestamp, or if the timestamp does not cryptographically bind to the code directory (CDHash). In CI environments (GitHub Actions, CircleCI), network latency or firewall rules often block the request to http://timestamp.apple.com/ts01, causing codesign to fail silently or fallback to an untimestamped signature.

2. Improper Entitlement Inheritance

Electron is a multi-process architecture. You have the main process (Node.js) and several helper processes (GPU, Renderer, Utilities).

  • The Main Process needs the Hardened Runtime (com.apple.security.cs.*) to pass Notarization.
  • The Helper Processes must inherit the sandbox context from the parent but should not attempt to declare the same hardened runtime exceptions as the main process.

If you sign the Helper executables with the exact same entitlement file as the Main executable, macOS detects a security violation regarding the Just-In-Time (JIT) compiler memory mapping, invalidating the signature chain.

The Solution: Strict Entitlement Segregation and Timestamp Verification

To fix this, we must decouple the entitlements and enforce timestamping during the build phase.

1. Define Segregated Entitlements

Create two distinct files in your build/ or resources/ directory.

File: build/entitlements.mac.plist (For the Main Process) This grants the Hardened Runtime capabilities required by the Electron engine (JIT, unsigned memory for WASM).

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <!-- Required for Electron's V8 Engine -->
    <key>com.apple.security.cs.allow-jit</key>
    <true/>
    <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
    <true/>
    <!-- Required for Notarization -->
    <key>com.apple.security.cs.disable-library-validation</key>
    <true/>
    <!-- Allow loading third-party dylibs (e.g., Audio/Video plugins) -->
    <key>com.apple.security.cs.allow-dyld-environment-variables</key>
    <true/>
  </dict>
</plist>

File: build/entitlements.mac.inherit.plist (For Helper Processes) This is the critical fix. Helpers must inherit the parent's sandbox but typically do not need the broad permissions of the main app.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>com.apple.security.app-sandbox</key>
    <true/>
    <key>com.apple.security.inherit</key>
    <true/>
  </dict>
</plist>

2. Configure Electron Builder

In your electron-builder.yml (or JSON config), map these files explicitly. We also force the timestamp server to ensure codesign doesn't fallback to a local clock.

appId: com.organization.product
mac:
  target:
    - target: dmg
    - target: zip
  category: public.app-category.developer-tools
  # Enforce Hardened Runtime
  hardenedRuntime: true
  # Explicitly map the entitlements
  entitlements: "build/entitlements.mac.plist"
  entitlementsInherit: "build/entitlements.mac.inherit.plist"
  # Optimization: prevent signing unneeded files which causes partial signature errors
  signIgnore:
    - ".*/node_modules/.*"
  # Force Timestamp Server (Critical for CI)
  timestamp: "http://timestamp.apple.com/ts01"
  notarize:
    teamId: ${env.APPLE_TEAM_ID}

# Ensure you are using the modern notarization tool
afterSign: "scripts/notarize.js"

3. The Modern notarize.js Hook

Do not rely on implicit notarization settings. Use a dedicated hook using @electron/notarize to handle the notarytool transition (Apple deprecated altool in late 2023).

File: scripts/notarize.js

const { notarize } = require('@electron/notarize');
const path = require('path');

exports.default = async function notarizing(context) {
  const { electronPlatformName, appOutDir } = context;

  if (electronPlatformName !== 'darwin') {
    return;
  }

  // Skip notarization if not building for production/CI
  if (process.env.SKIP_NOTARIZE === 'true') {
    console.log('  • Skipping Notarization (SKIP_NOTARIZE set)');
    return;
  }

  const appName = context.packager.appInfo.productFilename;
  const appPath = path.resolve(appOutDir, `${appName}.app`);

  console.log(`  • Notarizing ${appName} with Apple Notary Service...`);

  try {
    await notarize({
      appPath,
      // Use "keychain" or "keychainProfile" for local dev, 
      // but specific credentials for CI
      appleId: process.env.APPLE_ID,
      appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD,
      teamId: process.env.APPLE_TEAM_ID,
      tool: 'notarytool', // Explicitly use notarytool
    });
  } catch (error) {
    console.error('  • Notarization Failed:');
    console.error(error);
    throw error;
  }
  
  console.log(`  • Notarization Successful`);
};

4. Verification Script (The Smoke Test)

Never assume "Build Success" means "Signature Valid." Add this verification step to your CI pipeline immediately after the build/sign step. This inspects the deep code signature.

File: scripts/verify-signature.sh

#!/bin/bash
set -e

APP_PATH="dist/mac/YourApp.app"

echo "🔍 Verifying Code Signature for $APP_PATH..."

# 1. Check for the Secure Timestamp (This is the smoking gun)
# We look for "Timestamp" in the verbose output. 
# If it says "Time" (local) instead of "Timestamp" (server), it's invalid.
CODESIGN_OUTPUT=$(codesign -dvvv --deep "$APP_PATH" 2>&1)

if [[ "$CODESIGN_OUTPUT" == *"Timestamp="* ]]; then
  echo "✅ Secure Timestamp found."
else
  echo "❌ ERROR: Missing Secure Timestamp. Check network access to timestamp.apple.com."
  exit 1
fi

# 2. Verify Deep Signature validity
# --strict enforces requirements that Gatekeeper uses
codesign --verify --deep --strict --verbose=2 "$APP_PATH"

# 3. Assess against Gatekeeper policies
echo "🔍 Assessing Gatekeeper acceptance..."
spctl --assess --type execute --verbose --ignore-cache "$APP_PATH"

echo "✅ Signature Verification Passed."

Why This Fix Works

The Timestamp Integrity

By explicitly defining timestamp: "http://timestamp.apple.com/ts01" in the configuration and verifying it with codesign -dvvv, we eliminate the "Signature expires when certificate expires" vector. When the Apple Notary Service scans the binary, it looks for this authenticated timestamp in the CMS blob. If it's missing, the binary is treated as "legacy" or "ad-hoc" and rejected for distribution.

The Nested Signing Hierarchy

macOS Code Signing works "inside out." Frameworks and helper executables (like Electron Helper (GPU).app) are signed first, then the outer Application Bundle is signed.

  1. entitlements.mac.inherit.plist ensures the helpers don't request conflicting permissions (like allow-jit on a process that shouldn't have it).
  2. entitlements.mac.plist applies the Hardened Runtime exception only to the main process where the V8 engine resides.
  3. disable-library-validation allows the main process to load the internal Electron frameworks and native node modules that might be signed by different certificates (or the same certificate but viewed as external libraries).

Conclusion

Gatekeeper rejections are rarely random. They are strict enforcement of trust chains. By segregating your entitlements for the Electron multi-process model and rigorously verifying the presence of a secure timestamp in your CI pipeline, you convert "Invalid Signature" errors from a mystical blocker into a deterministic, solvable configuration task.