Skip to content

feat: signed update manifests (Ed25519) and gated Linux unsigned-package installs - #9877

Open
mmaietta wants to merge 10 commits into
masterfrom
feat/crypto-signed-update-manifest
Open

feat: signed update manifests (Ed25519) and gated Linux unsigned-package installs#9877
mmaietta wants to merge 10 commits into
masterfrom
feat/crypto-signed-update-manifest

Conversation

@mmaietta

Copy link
Copy Markdown
Collaborator

Summary

Auto-update integrity previously rested on TLS plus a self-referential checksum: latest*.yml carries each artifact's sha512, 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:

  • A1 — Ed25519-signed update manifests. Optionally sign latest*.yml at publish time and verify the signature in electron-updater before any download (fail-closed). This authenticates the manifest, which in turn makes the existing sha512 artifact check cryptographically meaningful end-to-end.
  • A2 — Gated Linux unsigned-package installs. Stop silently passing --allow-unauthenticated / --nogpgcheck / --allow-unsigned-rpm / rpm --nodeps; gate them behind an explicit flag and warn loudly when used. With A1 in place, the manifest sha512 already guarantees artifact integrity even when distro GPG is skipped.

A1 — Signed update manifests

Design

  • Signature scheme: Ed25519 via Node's built-in crypto (no new dependencies). The signature is a base64 signature field embedded directly in latest*.yml — one file, works with every provider/CDN, immune to YAML formatting drift.
  • What is signed: a deterministic canonical serialization (canonicalizeForSigning) of the integrity- and rollout-critical fields only: version, each file's url/sha512/size, and stagingPercentage. Cosmetic/operational fields (releaseDate, releaseNotes, releaseName, minimumSystemVersion) are excluded so they can change post-signing. The payload is prefixed EBUM1 as a versioned wire contract shared byte-identically by signer and verifier.
  • Keys: one secret to manage. The private key is supplied at publish time; the matching public key is derived automatically and embedded into app-update.yml (the same path publisherName already travels).

Build side

  • Signing key resolved (in precedence order) from updateManifest.signingKey / signingKeyFile config, or EP_UPDATE_SIGN_KEY / EP_UPDATE_SIGN_KEY_FILE env vars (preferred for CI secrets).
  • writeUpdateInfoFiles signs each manifest after all multi-arch/zip files merges so the signed array is final.
  • PublishManager embeds the derived updateManifestPublicKey into app-update.yml.
// electron-builder config
"updateManifest": {
  "signingKey": "...",        // or "signingKeyFile": "./update-private-key.pem"
  // "publicKey" optional — derived from the private key when omitted
}

Runtime side (electron-updater)

  • AppUpdater.updateManifestPublicKey property (overrides the value in app-update.yml).
  • Verification runs in getUpdateInfoAndProvider immediately after the manifest is fetched — provider-agnostic (Generic, GitHub, GitLab, Keygen, Bitbucket, S3, Spaces all covered):
    • No key configured → verification skipped, one-time warning (opt-in phase).
    • Key configured, manifest unsigned → throws ERR_UPDATER_MANIFEST_NOT_SIGNED.
    • Key configured, signature invalid → throws ERR_UPDATER_MANIFEST_SIGNATURE_INVALID.
  • A throw propagates before any download starts (fail-closed).

Tooling

  • New CLI: electron-builder create-update-key [--out <path>] — generates an Ed25519 keypair, writes the private key 0600, and prints the public key.

A2 — Linux unsigned-install gating

  • New LinuxUpdater.requireSignedLinuxPackages (default false), threaded into the DebUpdater and RpmUpdater command builders.
  • false (default): existing bypass flags retained, but each install logs a warning noting that artifact integrity is still enforced via the manifest sha512.
  • true: bypass flags omitted so the package manager enforces its own signature policy:
PM Permissive (default) Secure (requireSignedLinuxPackages: true)
apt --allow-unauthenticated omitted
dnf / yum --nogpgcheck omitted
zypper --allow-unsigned-rpm omitted
rpm --nodeps omitted (replace flags kept)

Pacman is unchanged — it already enforces its own SigLevel.


Rollout & compatibility

  • Opt-in everywhere. Signing is off unless a key is configured; verification is off unless a public key is present; Linux flags are unchanged but warned. Zero breakage for existing apps.
  • Forward/backward compatible. An older updater ignores the unknown signature field; a newer updater against an unsigned manifest with no configured key behaves exactly as before.
  • Next major (tracked, not in this PR): default signing on when a key is available, verification throws (instead of warns) when a key is configured but the signature is absent, and requireSignedLinuxPackages defaults true with a disableManifestSignatureVerification escape hatch.

…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-bot

changeset-bot Bot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 61f93da

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
builder-util-runtime Minor
builder-util Major
app-builder-lib Major
electron-updater Minor
electron-builder Major
dmg-builder Major
electron-publish Major
electron-builder-squirrel-windows Major
electron-forge-maker-appimage Major
electron-forge-maker-nsis-web Major
electron-forge-maker-nsis Major
electron-forge-maker-snap Major

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 updateManifestPublicKey into app-update.yml when signing is configured; add CLI electron-builder create-update-key for key generation.
  • Add LinuxUpdater.requireSignedLinuxPackages and 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.

Comment thread packages/app-builder-lib/src/publish/updateInfoBuilder.ts Outdated
Comment thread packages/electron-updater/src/RpmUpdater.ts Outdated
claude added 4 commits August 30, 2026 03:13
…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.
@claude
claude Bot marked this pull request as ready for review August 30, 2026 03:21
Comment on lines +227 to +245
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
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@claude please use MemoLazy here so that we don't need to maintain 3 properties. MemoLazy will:

@github-actions github-actions Bot removed the Stale label Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants