Repository navigation
feat: verify Node.js downloads against signed checksums - #1613
michaljuris wants to merge 9 commits into
Conversation
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.
|
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:
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. |
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.
|
Good catch @taoeffect, thanks. Fixed in 4d17d08: each release is now pinned to the key that signed it.
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.
|
|
@Schniz would be awesome to get this in 🥺 |
Closes #1609.
fnm installs whatever bytes the mirror serves: it checks no hash and no signature. This PR makes
fnm installverify every Node.js download before it is unpacked:SHASUMS256.txt.asc(the clearsigned checksum list Node.js publishes for each release).The SHA-256 part builds on the checksum commit from #1611 by @mentenaz (kept as the first commit, with its author).
Threat model
checksum)signature, default)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:signature(default)checksumnoneWhat happens when there is nothing to verify against:
.ascmissing onnodejs.org--download-verification=checksum)"unofficial-builds.nodejs.org(musl and other builds).ascorSHASUMS256.txtOn 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.ascholds 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.shregenerates the file.$FNM_DIR/release-keys/. The bundled keys are never changed.--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.release-keys/signers.txtmaps 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 withFNM_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.shextends 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.Implementation notes
pgp(rPGP) 0.21,default-features = false: pure Rust (no C toolchain for musl, Windows static orcrossbuilds), MIT/Apache, the same crate mise uses for this.sequoia-openpgpwas tested and rejected: its default policy refuses some genuine Node.js releases (e.g. v20.0.0).sha20.10.9, which reuses thedigestcrate already in the tree.fnm use,fnm envand--use-on-cdare unchanged.Tests
.ascfiles 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./__test__/corrupt/,/__test__/no-signature/and/__test__/bare/prefixes. New tests cover a corrupt archive, a missing signature,checksumandnonemodes, and a missing user keys file..ascagainst the bundled keys, and all pass. The other 147 (v0.x) are exactly the ones the "predates signed checksums" rule matches..gitattributesmarks*.ascas-text, so the signed fixtures and the key bundle stay byte-identical on Windows checkouts.installs aliases when corepack is enabled(Bash and Zsh). It also fails onmaster: the test installs the latest Node.js, and v26 no longer ships corepack.For you to decide
signature). Users of private mirrors that carry no.ascmust setchecksum. The changeset isminorand states this.