Skip to content

feat: verify Node.js downloads against signed checksums - #1613

Open
michaljuris wants to merge 9 commits into
Schniz:masterfrom
michaljuris:verify-node-downloads
Open

michaljuris wants to merge 9 commits into
Schniz:masterfrom
michaljuris:verify-node-downloads

Conversation

@michaljuris

@michaljuris michaljuris commented Oct 29, 2026 •

Copy link
Copy Markdown

Closes #1609.

fnm installs whatever bytes the mirror serves: it checks no hash and no signature. This PR makes fnm install verify every Node.js download before it is unpacked:

  1. Fetch SHASUMS256.txt.asc (the clearsigned checksum list Node.js publishes for each release).
  2. Verify its OpenPGP signature against the Node.js release keys, which are bundled in fnm.
  3. Download the archive to a temporary file, check its SHA-256 against the verified list, and only then unpack it.
mirror ──► SHASUMS256.txt.asc ──► signature OK? (bundled release keys) ──► checksum list
mirror ──► node-vX-….tar.xz ──► temp file + SHA-256 ──► matches list? ──► unpack ──► install

The SHA-256 part builds on the checksum commit from #1611 by @mentenaz (kept as the first commit, with its author).

Threat model

Check Protects against
SHA-256 only (checksum) corrupt or truncated downloads, broken caches
Signature + SHA-256 (signature, default) the above, plus a compromised or malicious mirror/CDN and TLS interception, unless the attacker also holds a Node.js releaser's private key

Node.js does not publish minisign signatures (the tool #1609 suggests). It signs each release's checksum list with its releasers' OpenPGP keys, listed in nodejs/release-keys. This PR pins those keys.

Setting

--download-verification / FNM_DOWNLOAD_VERIFICATION:

Value Behavior
signature (default) signature and checksum required
checksum checksum required, no signature check (a warning is printed)
none no checks (a warning is printed)

What happens when there is nothing to verify against:

Case Result
Signature or checksum mismatch Always fails. The error does not suggest lowering the setting
.asc missing on nodejs.org Fails as a possible incident or attack: "retry later, don't lower the setting"
Releases that predate signed checksums (most of v0.x) Fails with "the most fnm can check is SHASUMS256.txt (--download-verification=checksum)"
unofficial-builds.nodejs.org (musl and other builds) It publishes no signatures, so fnm checks the checksum only, with a warning. Existing musl setups keep working
Any other mirror without .asc or SHASUMS256.txt Fails. The error names the setting to lower, "if this is a private mirror you control"

On success fnm prints one line, e.g. Verified signature by Antoine du Hamel <…> (5BE8A3F6C8A5C01D106C0AD820B1A390B168D356). A missing line means the check did not run.

Release keys

  • release-keys/keys.asc holds all 29 keys from nodejs/release-keys (MIT, notice included), with the source commit recorded in its header. Retired keys stay in, because old releases are signed with them. .ci/update-release-keys.sh regenerates the file.
  • Key rotation: a releaser who starts signing with a new subkey would break a bundled-keys-only check until the next fnm release, as happened in mise (GPG signature verification error when installing Node.js v24.12.0 jdx/mise#7237). When a release is signed by an unknown key, fnm downloads the release-keys tarball from GitHub once and adds only new signing subkeys of releasers already in the bundle. Each one must be bound by the bundled primary key's signature and carry a valid back-signature. These subkeys are cached in $FNM_DIR/release-keys/. The bundled keys are never changed.
  • New releaser keys and revocations take effect with fnm releases. A release signed by a releaser who isn't in the bundle fails with the key's fingerprint and a cautious way forward: upgrade fnm, or check the key yourself and add it with --node-release-keys <file> / FNM_NODE_RELEASE_KEYS. That setting adds keys from a file to the bundled ones. Accepting new primary keys from GitHub automatically would make the trust root "GitHub over TLS", which is the attack Use some sort of key pinning to verify the binaries downloaded. #1609 is about.
  • Each release is pinned to the key that signed it. release-keys/signers.txt maps each of the 730 signed nodejs.org releases to the primary key behind its signature. A listed release is accepted only from that key or one of its signing subkeys. A release newer than the table is accepted only from an active releaser's key (release-keys/active.txt, from the release-keys README) or a key added with FNM_NODE_RELEASE_KEYS. So a retired or expired key can vouch only for the releases it actually signed, never for a new one. .ci/update-release-keys.sh extends the table incrementally through the same verification code. Limit: if Node.js re-signs an old release with a different key (as in Possibly wrong checksum for node-v4.7.1-headers.tar.gz in SHASUMS256.txt nodejs/node#10630), that release fails until the table is updated.
  • Revocations are hard, and only signing-capable keys and subkeys can sign. Expired keys stay in the bundle, because 9 of the 29 keys are expired and signed real releases from v0.10 to v18. With the pinning above, each one is bounded to exactly those releases.

Implementation notes

  • pgp (rPGP) 0.21, default-features = false: pure Rust (no C toolchain for musl, Windows static or cross builds), MIT/Apache, the same crate mise uses for this. sequoia-openpgp was tested and rejected: its default policy refuses some genuine Node.js releases (e.g. v20.0.0).
  • sha2 0.10.9, which reuses the digest crate already in the tree.
  • Untrusted bytes never reach the extractors: the archive is hashed into a temp file first (the zip path already did this). Limits: 1 MiB for checksum files, 512 MiB per archive, 4 MiB for the keys tarball.
  • Cost: release binary +1.8 MB (stripped, macOS arm64). Verification takes about 40 ms and one extra request per install. fnm use, fnm env and --use-on-cd are unchanged.

Tests

  • Unit tests against real .asc files from v0.10.30, v6.0.0, v20.0.0, v24.12.0 and v25.2.0 (SHA-1/DSA, SHA-512, subkeys, ed25519). Tamper cases: an edited signed text, text outside the signed block, a hash mismatch, non-document signature types. Revoked and non-signing keys. The policy table. The key refresh, with an injected fetcher (no network): a stripped back-signature, a forged subkey, revocations, the cache cap.
  • e2e: the test proxy gains /__test__/corrupt/, /__test__/no-signature/ and /__test__/bare/ prefixes. New tests cover a corrupt archive, a missing signature, checksum and none modes, and a missing user keys file.
  • I verified all 730 nodejs.org releases that publish an .asc against the bundled keys, and all pass. The other 147 (v0.x) are exactly the ones the "predates signed checksums" rule matches.
  • .gitattributes marks *.asc as -text, so the signed fixtures and the key bundle stay byte-identical on Windows checkouts.
  • CI on my fork is green except installs aliases when corepack is enabled (Bash and Zsh). It also fails on master: the test installs the latest Node.js, and v26 no longer ships corepack.

For you to decide

  • The default is strict (signature). Users of private mirrors that carry no .asc must set checksum. The changeset is minor and states this.
  • The success line could go to debug level if you prefer quieter output.
  • A scheduled workflow could open PRs when nodejs/release-keys changes. I left it out, but can add it.

mentenaz and others added 8 commits October 29, 2026 10:33
fnm unpacked whatever the mirror sent. Before unpacking, it now fetches
the release's SHASUMS256.txt from the same mirror, downloads the archive
into a temporary file while hashing it, and unpacks it only if the
SHA-256 matches. A mismatch, or a mirror that publishes no checksum for
the file, stops the install with an error naming the file and both
checksums.

(cherry picked from commit 4a35f31)
Conflicts with Schniz#1605 resolved.
Fetch SHASUMS256.txt once, before trying the archive formats, and look
each archive up by the file name actually requested (after the arch
fallback). A missing list now fails with its own error.

Cap what is read from the mirror: 1 MiB for the checksum list and
512 MiB for an archive, so a hostile mirror can't fill the disk. A
checksum mismatch names the URL of the archive.
Add --download-verification / FNM_DOWNLOAD_VERIFICATION with the values
signature (default), checksum and none, and a policy that maps the
setting and the mirror host to the checks to run. unofficial-builds.nodejs.org
publishes no signatures, so it gets checksums only. A missing checksum
list on an official host is reported as an incident with no bypass
hint; on other mirrors the hint names the setting to lower.

Signature checks follow in the next commit; until then, signature mode
checks checksums only.
In the default signature mode, fetch SHASUMS256.txt.asc and verify it
with rPGP against a bundled snapshot of nodejs/release-keys. Checksums
are taken only from the verified signed text; text outside the signed
block fails the check. Each key block is parsed on its own, so a key
that can't be parsed is skipped. The issuer is looked up by fingerprint
or key ID, and a signature without an issuer is tried against every key.
The number of signatures considered is capped, and only binary and text
document signatures count.

A key signs only if it isn't revoked, for any reason, and its newest
verified self-signature allows signing; a subkey also needs its primary
key's newest binding to allow signing, with the subkey's cross-signature.
Keys without key flags predate them and may sign. A revocation in any
trusted copy of a key applies to every copy. Every key in an armored
block is read. Names are printed without control characters.

release-keys/keys.asc is generated by .ci/update-release-keys.sh, which
records the source commit and key count in its header.
When SHASUMS256.txt.asc is signed by a key fnm doesn't know, fetch the
nodejs/release-keys tarball from GitHub once (never from the mirror)
and check again. The tarball is untrusted: only keys/*.asc entries are
read, within size limits. A refresh only adds new signing subkeys of
bundled keys: bound by the bundled primary key with the sign flag and
the subkey's cross-signature, and not revoked. Bundled keys are never
changed, so new releaser keys and revocations take effect with fnm
releases. A signature by a new releaser's key fails with an error that
names it, and says the name is unverified.

The added subkeys, at most 64, are cached in
$FNM_DIR/release-keys/keys.asc with the source commit. Each refresh
replaces the cache atomically, and each load checks it again.
--node-release-keys / FNM_NODE_RELEASE_KEYS names an armored .asc file
of release signing keys to trust like the bundled ones, for a releaser
key that is newer than this fnm. A file that can't be read, has a key
that can't be read or has no keys stops the install with an error
naming it, and so does fnm's own cache of refreshed keys. A release
signed by such a key says so, and the error for a new releaser's key
now points to this option, with a caution: whoever controls a key in
that file can sign any Node.js download.
Add test-only path prefixes to the e2e proxy: /__test__/corrupt/ flips
a byte in every archive, /__test__/no-signature/ has no
SHASUMS256.txt.asc, and /__test__/bare/ has no SHASUMS256.txt either.
The new tests check that a corrupted archive, a mirror without
signatures and a missing --node-release-keys file fail with a useful
error, and that the checksum and none settings install from such
mirrors.
Regenerate docs/commands.md, describe --download-verification and
--node-release-keys in docs/configuration.md, note how to refresh the
bundled release keys in AGENTS.md, and replace the patch changeset with
a minor one that states the behavior change.
@taoeffect

taoeffect commented Oct 29, 2026 •

Copy link
Copy Markdown

Awesome! Thanks for sending this in! 🎉 (Would you mind sharing what AI model implemented this?)

I had Opus 5.5 check this PR and it found one issue with the key pinning:

All 8 active releaser keys are at least 128-bit.

The catch: any bundled key, retired or not, is accepted for any Node version. Expiry isn't enforced, and there's no rule about which key signed which releases. So the real minimum is DSA-1024, plus the security of 21 retired keyholders' old private keys.

Fix: only accept each retired key for the versions it actually signed (a map from key to its last signed version). The author already checked all 730 signed releases, so building that table is easy.

The DSA-1024 key is not only considered weak but is also expired, so it should be removed (along with any other expired keys). OpenSSH dropped support for it entirely.

@michaljuris

Copy link
Copy Markdown
Author

Awesome! Thanks for sending this in! 🎉 (Would you mind sharing what AI model implemented this?)

Opus 5.5 and Sol 6.1 with some steering from me.

Any bundled key, retired or expired, was accepted for any release, so a
stolen retired key could sign a fake checksum list for a new one. Each
release is now pinned to the primary key that signed it, from a bundled
table (release-keys/signers.txt) of the 730 releases with signed
checksums; any signing subkey of that key is fine. A release that the
table doesn't have is accepted only from an active releaser's key, as
the nodejs/release-keys README lists them (release-keys/active.txt), or
from a --node-release-keys key. A refresh adds subkeys only to those.

.ci/update-release-keys.sh now also writes the active list, and adds
the releases that the table lacks, each checked like a download.
@michaljuris

michaljuris commented Oct 29, 2026 •

Copy link
Copy Markdown
Author

Good catch @taoeffect, thanks. Fixed in 4d17d08: each release is now pinned to the key that signed it.

  • release-keys/signers.txt maps each of the 730 signed nodejs.org releases to the primary key behind its signature. A listed release is accepted only from that key or one of its signing subkeys.
  • A release newer than the table is accepted only from an active releaser's key (per the nodejs/release-keys README) or from a key the user adds with FNM_NODE_RELEASE_KEYS.
  • So a stolen retired key can't sign a checksum list for v24 anymore. The DSA-1024 key 7937DFD2…406D (created 2006, expired 2016-03-26) now vouches only for the v0.10/v0.11 releases it signed in 2013–14.

I kept the expired keys in the bundle on purpose. 9 of the 29 are expired, and they signed real releases from v0.10 to v18. Removing them wouldn't make anything safer: those releases would drop from signature checks to checksum-only. With the pinning, each expired key is limited to exactly the releases it signed. If you'd still rather drop the DSA key, those v0.10/v0.11 releases would fall back to the "predates signed checksums" path. That's a one-line change.

.ci/update-release-keys.sh extends the table incrementally, and it checks each new release with the same verification code. The trade-off: if Node.js ever re-signs an old release with another key (as in nodejs/node#10630), that release fails until the table is updated.

@taoeffect

Copy link
Copy Markdown

@Schniz would be awesome to get this in 🥺

This branch has not been deployed

No deployments
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.

Use some sort of key pinning to verify the binaries downloaded.

3 participants