feat: signed update manifests (Ed25519) and gated Linux unsigned-package installs - #9877
feat: signed update manifests (Ed25519) and gated Linux unsigned-package installs#9877mmaietta wants to merge 10 commits into
Conversation
…igned-package installs A1 — Optional Ed25519 signing of auto-update manifests (`latest*.yml`). When a signing key is configured (`updateManifest.signingKey`/`signingKeyFile` in config, or `EP_UPDATE_SIGN_KEY`/`EP_UPDATE_SIGN_KEY_FILE` env vars), each manifest is signed over its integrity-critical fields and the matching public key is embedded into `app-update.yml`. electron-updater verifies the signature before downloading and refuses to update on tamper/missing-signature (fail-closed). Opt-in: when no public key is configured, verification is skipped with a one-time warning. New CLI: `electron-builder create-update-key`. A2 — `LinuxUpdater.requireSignedLinuxPackages` (default `false`) gates the package-manager flags that bypass distro signature checks (`--allow-unauthenticated`, `--nogpgcheck`, `--allow-unsigned-rpm`, rpm `--nodeps`). When `false`, those flags are kept but a warning is logged (artifact integrity is still enforced via the manifest sha512); when `true`, they are omitted so the package manager enforces its own signatures.
🦋 Changeset detectedLatest commit: 61f93da The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
Pull request overview
This PR strengthens the auto-update security model by (1) adding optional Ed25519 signatures for latest*.yml update manifests and enforcing verification in electron-updater before any download, and (2) gating Linux package-manager “unsigned install” bypass flags behind an explicit requireSignedLinuxPackages toggle (with warnings when bypassing).
Changes:
- Add build-time signing (
builder-util/app-builder-lib) and runtime verification (builder-util-runtime/electron-updater) for update manifests. - Embed a derived
updateManifestPublicKeyintoapp-update.ymlwhen signing is configured; add CLIelectron-builder create-update-keyfor key generation. - Add
LinuxUpdater.requireSignedLinuxPackagesand gate apt/dnf/yum/zypper/rpm bypass flags; add unit tests for these behaviors.
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| test/src/updater/manifestSignatureUpdaterTest.ts | Verifies updater-side signature enforcement behaviors and error codes |
| test/src/updater/linuxUpdaterUnitTest.ts | Tests gating of Linux package-manager bypass flags |
| test/src/updateManifestSignatureTest.ts | Tests canonicalization + sign/verify round-trip in runtime utilities |
| test/src/updateInfoBuilderTest.ts | Tests manifest signing output during update info generation |
| packages/electron-updater/src/RpmUpdater.ts | Gates rpm-family bypass flags; adds warning/info logging |
| packages/electron-updater/src/LinuxUpdater.ts | Introduces requireSignedLinuxPackages option with documentation |
| packages/electron-updater/src/DebUpdater.ts | Gates apt --allow-unauthenticated and logs warnings/info |
| packages/electron-updater/src/AppUpdater.ts | Fetch-time manifest signature verification (fail-closed when key present) |
| packages/electron-builder/src/cli/create-update-key.ts | Implements create-update-key CLI helper |
| packages/electron-builder/src/cli/cli.ts | Wires create-update-key command into CLI |
| packages/builder-util/src/util.ts | Exports signing/key helper functions |
| packages/builder-util/src/updateManifestSigner.ts | Build-time signing/key loading + keypair generation |
| packages/builder-util-runtime/src/updateManifestSignature.ts | Canonicalization + public key parsing + signature verification |
| packages/builder-util-runtime/src/updateInfo.ts | Adds signature?: string to UpdateInfo |
| packages/builder-util-runtime/src/publishOptions.ts | Adds updateManifestPublicKey to publish configuration |
| packages/builder-util-runtime/src/index.ts | Exports manifest signature utilities |
| packages/app-builder-lib/src/publish/updateInfoBuilder.ts | Signs latest*.yml manifests when a signing key is configured |
| packages/app-builder-lib/src/publish/PublishManager.ts | Embeds derived updateManifestPublicKey into app-update.yml |
| packages/app-builder-lib/src/options/PlatformSpecificBuildOptions.ts | Adds updateManifest signing configuration options |
| packages/app-builder-lib/scheme.json | Extends JSON schema for updateManifest and embedded public key |
| .changeset/signed-update-manifests.md | Changeset entry describing the new security features |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…update-manifest # Conflicts: # packages/app-builder-lib/scheme.json
…update-manifest # Conflicts: # packages/electron-updater/src/DebUpdater.ts # packages/electron-updater/src/RpmUpdater.ts # test/src/updater/linuxUpdaterUnitTest.ts
Build a fresh tampered UpdateInfo via object spread instead of assigning to the readonly UpdateFileInfo.sha512 property, which failed typecheck:test (TS2540).
… from env vars only PublishManager only embedded updateManifestPublicKey into app-update.yml when an updateManifest config block was present, while updateInfoBuilder also resolves EP_UPDATE_SIGN_KEY / EP_UPDATE_SIGN_KEY_FILE from the environment — so env-var-only users published signed manifests that clients never verified. Use the same key resolution (loadUpdateSigningKey falls back to the env vars) on the embedding side so signing and embedding cannot diverge, and cover the env-only path with tests for both signing and public-key embedding.
Covers the updateManifest config block, EP_UPDATE_SIGN_KEY / EP_UPDATE_SIGN_KEY_FILE, the create-update-key CLI, fail-closed verification behavior and error codes in electron-updater, key rotation, and the related allowUnverifiedLinuxPackages option; cross-linked from the Auto Update and Security & Hardening pages.
| const signingKeyCache = new Map<string, KeyObject>() | ||
| let loggedSigning = false | ||
| const resolveSigningKey = (taskPackager: PlatformPackager<any>): KeyObject | null => { | ||
| const updateManifestConfig = taskPackager.platformOptions?.updateManifest ?? packager.config?.updateManifest | ||
| const signingKeyPem = loadUpdateSigningKey(updateManifestConfig ?? undefined) | ||
| if (signingKeyPem == null) { | ||
| return null | ||
| } | ||
| if (!loggedSigning) { | ||
| loggedSigning = true | ||
| log.info(null, "signing update manifests with Ed25519 key") | ||
| } | ||
| let signingKey = signingKeyCache.get(signingKeyPem) | ||
| if (signingKey == null) { | ||
| signingKey = parsePrivateKey(signingKeyPem) | ||
| signingKeyCache.set(signingKeyPem, signingKey) | ||
| } | ||
| return signingKey | ||
| } |
There was a problem hiding this comment.
@claude please use MemoLazy here so that we don't need to maintain 3 properties. MemoLazy will:
- only regenerate value on provided a new
updateManifestargument and log only during the setting of the Lazy value. https://github.com/electron-userland/electron-builder/blob/master/packages/builder-util-runtime/src/MemoLazy.ts
Summary
Auto-update integrity previously rested on TLS plus a self-referential checksum:
latest*.ymlcarries each artifact'ssha512, and the updater validates the download against it — but nothing authenticated the manifest itself. Anyone able to serve the manifest (compromised host/CDN, stolen publish token, MITM) could ship a malicious artifact with a matching hash. On Linux this was compounded by package installs that explicitly bypass distro signature checks.This PR closes both gaps:
latest*.ymlat publish time and verify the signature inelectron-updaterbefore any download (fail-closed). This authenticates the manifest, which in turn makes the existingsha512artifact check cryptographically meaningful end-to-end.--allow-unauthenticated/--nogpgcheck/--allow-unsigned-rpm/ rpm--nodeps; gate them behind an explicit flag and warn loudly when used. With A1 in place, the manifestsha512already guarantees artifact integrity even when distro GPG is skipped.A1 — Signed update manifests
Design
crypto(no new dependencies). The signature is a base64signaturefield embedded directly inlatest*.yml— one file, works with every provider/CDN, immune to YAML formatting drift.canonicalizeForSigning) of the integrity- and rollout-critical fields only:version, each file'surl/sha512/size, andstagingPercentage. Cosmetic/operational fields (releaseDate,releaseNotes,releaseName,minimumSystemVersion) are excluded so they can change post-signing. The payload is prefixedEBUM1as a versioned wire contract shared byte-identically by signer and verifier.app-update.yml(the same pathpublisherNamealready travels).Build side
updateManifest.signingKey/signingKeyFileconfig, orEP_UPDATE_SIGN_KEY/EP_UPDATE_SIGN_KEY_FILEenv vars (preferred for CI secrets).writeUpdateInfoFilessigns each manifest after all multi-arch/zipfilesmerges so the signed array is final.PublishManagerembeds the derivedupdateManifestPublicKeyintoapp-update.yml.Runtime side (
electron-updater)AppUpdater.updateManifestPublicKeyproperty (overrides the value inapp-update.yml).getUpdateInfoAndProviderimmediately after the manifest is fetched — provider-agnostic (Generic, GitHub, GitLab, Keygen, Bitbucket, S3, Spaces all covered):ERR_UPDATER_MANIFEST_NOT_SIGNED.ERR_UPDATER_MANIFEST_SIGNATURE_INVALID.Tooling
electron-builder create-update-key [--out <path>]— generates an Ed25519 keypair, writes the private key0600, and prints the public key.A2 — Linux unsigned-install gating
LinuxUpdater.requireSignedLinuxPackages(defaultfalse), threaded into theDebUpdaterandRpmUpdatercommand builders.false(default): existing bypass flags retained, but each install logs a warning noting that artifact integrity is still enforced via the manifestsha512.true: bypass flags omitted so the package manager enforces its own signature policy:requireSignedLinuxPackages: true)--allow-unauthenticated--nogpgcheck--allow-unsigned-rpm--nodepsPacman is unchanged — it already enforces its own
SigLevel.Rollout & compatibility
signaturefield; a newer updater against an unsigned manifest with no configured key behaves exactly as before.requireSignedLinuxPackagesdefaultstruewith adisableManifestSignatureVerificationescape hatch.