Skip to content

build(nix): distrust compromised eMudhra CAs via Node 24.22.1 and Bun 1.4.4 - #1218

Merged
elucid merged 7 commits into
mainfrom
emudhra-node-bun-roots
Oct 23, 2026
Merged

elucid merged 7 commits into
mainfrom
emudhra-node-bun-roots

Conversation

@elucid

@elucid elucid commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Problem

On 2026-10-19 the private keys of eight eMudhra (emSign) intermediate CAs were found to be compromised (Cloudflare write-up). OS and browser root stores have been updated, but Node and Bun ship their own root stores, so the usual system-level fix (security.pki.caCertificateBlacklist on nix-darwin, etc.) never reaches them.

The first upstream releases that distrust the chain are Node v24.22.1 (NSS 3.131.1 roots + blocked-SPKI check) and Bun v1.4.4. Today the repo's nix develop shell gives Node 24.14.1 / Bun 1.3.13 (the pinned nixpkgs), the repo pins Bun 1.4.2, and the compiled hunk embeds that Bun — all of which still connect to a site chained through a compromised intermediate.

Approach

Dev shell (temporary, self-retiring)

  • nix/nodejs-bin.nix: official nodejs.org 24.22.1 tarball as a derivation (darwin only).
  • nix/emudhra-overlay.nix: substitutes Bun 1.4.4 for bun on all four systems (official GitHub release zips — the same asset flavor nixpkgs picks per system — reusing nixpkgs' bun derivation hooks via overrideAttrs), and that node for nodejs* on darwin only (Linux keeps nixpkgs' node, which already distrusts the chain through the distro trust store; see Why node is only replaced on macOS). Each replacement applies only while the pinned nixpkgs is older than the fixed release (lib.versionOlder). Attribute names never depend on prev, and the node gate reads prev.nodejs-slim_24 because nodejs_24 is a symlinkJoin whose version resolves through final.
  • Wired into the single import nixpkgs in flake.nix.

Bun pin → 1.4.4 (this is what reaches users: the published binary embeds this runtime, so hunk gh and extension fetches go through it)

  • packageManager, bun, @types/bun (1.4.3 is the latest) in both package.jsons; bun.lock; nix/bun.lock.nix (via nix run .#update-bun-lock); nix/package.nix bunCompilerArchives with the @oven/bun-*@1.4.4 integrity hashes (x64 → -baseline, matching build(nix): compile through build-bin.ts with Bun's baseline x64 runtime #1214), all re-verified with npm view … dist.integrity; oven-sh/setup-bun pins in workflows; CONTRIBUTING.md.
  • Changeset: hunkdiff patch.
  • Requires a one-time security exception to the 7-day minimumReleaseAge gate: Bun 1.4.4 is a same-day release. Scope and justification are in Security exception to minimumReleaseAge.

Non-goals: not changing the minimumReleaseAge policy in bunfig.toml (this PR makes a one-time, scoped security exception to it; see Security exception to minimumReleaseAge), not replacing node on Linux (not needed; see Why node is only replaced on macOS), not bumping other deps.

Why node is only replaced on macOS

Linux shells keep nixpkgs' node: it is built with --openssl-use-def-ca-store, so it trusts the distro's /etc/ssl/certs bundle and inherits the distro's eMudhra distrust (e.g. Ubuntu's ca-certificates 20260816ubuntu0.24.04.1 blacklists "emSign ECC Root CA - G3"). On macOS nix's OpenSSL reads Nix's own bundle instead of the Keychain, so only Node ≥ 24.22.1's built-in SPKI block protects it.

Probed against the canary site (https://dv-g3b-emudhra.we-have-a-problem.com/) with the flake's pinned nixpkgs nodejs_24 (24.14.1):

pinned nixpkgs node 24.14.1 same node with --use-bundled-ca
Ubuntu 24.04 x86_64, ca-certificates 20260816ubuntu0.24.04.1 REJECTED UNABLE_TO_GET_ISSUER_CERT_LOCALLY CONNECTED (bad)
macOS (aarch64-darwin) CONNECTED (bad) CONNECTED (bad)
macOS with this PR's node 24.22.1 REJECTED CERT_REVOKED —

The Linux result depends on the host's CA bundle having the distrust: Linux machines whose bundle hasn't caught up (an older NixOS cacert, a distro without the update yet) stay exposed until it does, or until nix flake update brings nixpkgs' node to ≥ 24.22.1 (it merged into nixpkgs staging on 2026-10-21). That window is narrow and dev-shell only, so this PR documents it rather than packaging Linux node tarballs.

Bun 1.4.3+ regression this bump exposed (fixed in the second commit)

The first CI run failed on every stdin-pipe pager path: after q, git diff | hunk pager stayed alive until the user pressed Enter. main on Bun 1.4.2 is fine; the same source hangs on 1.4.3 and 1.4.4 (bisected by swapping only the Bun binary).

Root cause: Bun implements tty.ReadStream(fd) as fs.ReadStream, so each read on the /dev/tty descriptor Hunk opens for keyboard input (when stdin is a pipe) is a blocking read(2) on a worker thread, and destroy() waits for it. Hunk tears down on a later tick than the keypress that requested it; from 1.4.3 Bun issues the next blocking read before that tick, so teardown parks behind a read that only the next line of input can satisfy. Only process.stdin gets Bun's poll-driven native reader (Bun's own source notes tty.ReadStream "is supposed to extend from net.Socket, but we haven't made that work yet"). Minimal repro (hangs on 1.4.3+, exits on 1.4.2):

// echo | bun run repro.ts   — then press q
import fs from "node:fs"; import tty from "node:tty";
await new Response(Bun.stdin.stream()).text();
const s = new tty.ReadStream(fs.openSync("/dev/tty", "r"));
s.setRawMode(true);
s.on("data", () => Promise.resolve().then(() => { s.setRawMode(false); s.destroy(); }));

Fix (packages/hunk/src/core/process/terminal.ts): ControllingTerminalInput, a Readable fed by Bun.file(<real device>).stream() — poll-driven reads, and cancel() releases the event loop — with raw mode delegated to a dormant tty.ReadStream on a second descriptor (termios is per device). The device has to be the real node (/dev/ttys003, /dev/pts/4) because macOS kqueue rejects the /dev/tty alias with EINVAL; resolveControllingTerminalDevice follows /proc/self/fd on Linux and matches rdev in /dev elsewhere. If nothing resolves, startup keeps its existing "no interactive terminal" fallback. Verified in the PTY harness on Bun 1.4.2 and 1.4.4: git diff | hunk pager, echo | hunk diff, hunk pager < file, hunk diff all exit on q; test/pty/pager.test.ts 12/12 on both. Worth an upstream Bun issue; happy to file it.

Remove when nixpkgs catches up

Once nix flake update gives nodejs_24 >= 24.22.1 and bun >= 1.4.4, delete nix/emudhra-overlay.nix, nix/nodejs-bin.nix, and the overlays line in flake.nix. Keep the bun@1.4.4 pin. Nothing breaks if it's left in place — the gates just evaluate to the nixpkgs packages. Watch:

curl -s https://raw.githubusercontent.com/NixOS/nixpkgs/nixpkgs-unstable/pkgs/development/web/nodejs/v24.nix | grep version
curl -s https://raw.githubusercontent.com/NixOS/nixpkgs/nixpkgs-unstable/pkgs/by-name/bu/bun/package.nix | grep version

Verification (aarch64-darwin, macOS 26.5.1)

Dev shell probe against the canary site:

$ nix develop -c bash -c 'node --version; bun --version; which node bun; <fetch probe>'
v24.22.1
1.4.4
/nix/store/…-nodejs-bin-24.22.1/bin/node
/nix/store/…-bun-1.4.4/bin/bun
node REJECTED (GOOD) CERT_REVOKED
bun REJECTED (GOOD) certificate revoked

Baseline for comparison, the pinned nixpkgs bun 1.3.13: CONNECTED (BAD) Your client successfully connected to the site. That's bad!

Nix package: nix build .#hunk ✅ (compiles with bun-compiler-1.4.4); the embedded runtime of the resulting binary (BUN_BE_BUN=1 …/bin/.hunk-wrapped probe.ts) → REJECTED (GOOD) certificate revoked.

Overlay gates: nix eval of devShells.{x86_64,aarch64}-linux.default.buildInputs → bun-1.4.4 nodejs-24.14.1 (bun from bun-linux-{x64,aarch64}.zip, hashes checked against the release SHASUMS256.txt; node untouched); aarch64-darwin → bun-1.4.4 nodejs-bin-24.22.1. Simulating a caught-up nixpkgs (pre-overlay that sets bun/nodejs-slim_24 to the fixed versions) yields nixpkgs' own derivations (bun src hash = nixpkgs', node name nodejs-24.22.1 not nodejs-bin), i.e. the overlay turns itself off.

Repo checks (with the worktree's Bun 1.4.4):

  • bun run typecheck ✅
  • bun run deps:check ✅
  • bun install --frozen-lockfile from a clean node_modules under the default bunfig.toml ✅ (see note)
  • bun run test (after the fix): 5145 pass / 55 skip / 0 fail. Two local environment artifacts are unrelated to this change: a symlinked TMPDIR (/var/folders vs /private/var/…) breaks realpath-equality assertions in 9 tests, and nix's bash 5.3.9 fails the 5 bash-completion tests identically on Bun 1.4.2 (they pass with HUNK_TEST_BASH_EXECUTABLE=/bin/bash); the run above used a canonical TMPDIR and /bin/bash.
  • bun run test:integration (PTY): 266 pass / 1 skip / 1 fail — the failure (Neovim accepts j without Enter) is nvim 0.12.1's --clean DSR warning under the test PTY and fails identically on Bun 1.4.2.
  • TTY smoke needs util-linux script (Linux-only) — relying on CI for it. test:session-broker-node not run (no broker changes).
  • Not built locally: the Linux and x86_64-darwin overlay derivations (no Linux builder here; verified by eval + release hashes, and they reuse nixpkgs' own unzip/autoPatchelf recipe). CI covers the Linux package build and the repo-pinned Bun, not nix develop on Linux.

Security exception to minimumReleaseAge

This PR makes a deliberate, one-time exception to the repo's 7-day package age gate in order to ship the eMudhra fix. The policy itself is unchanged: bunfig.toml is not modified and minimumReleaseAge = 604800 still applies to every future resolution.

Why. The age gate exists to give the ecosystem time to catch a compromised or broken release before we adopt it. Here the trade-off runs the other way: Bun 1.4.4 (published 2026-10-22) is the first Bun release whose root store rejects the compromised eMudhra intermediates, and the published hunk binary embeds whatever Bun we pin. Waiting 7 days means shipping a runtime that still trusts a known-compromised CA for that whole window. The risk we accept in exchange is narrow: a same-day release from the Bun maintainers' own bun / @oven/bun-* packages, with integrity hashes pinned in both bun.lock and nix/bun.lock.nix.

How. The lockfile was resolved once with bun install --minimum-release-age=0 (a command-line override, not a config change). CI's bun install --frozen-lockfile passes under the default policy because the gate applies only to new resolutions, not to entries already in the lockfile. Re-verified from a clean node_modules at the PR head with the unmodified bunfig.toml.

Scope. --minimum-release-age=0 disables the gate for the whole resolution, not just Bun, so the lockfile was audited to confirm nothing else came along:

  • Inside the 7-day window (the actual exception): bun@1.4.4 and its 12 platform packages @oven/bun-{darwin,linux,freebsd,windows}-*@1.4.4, plus the @oven/bun-{darwin,linux}-x64-baseline@1.4.4 compiler archives that nix/package.nix fetches directly. All published 2026-10-22 by the Bun release.
  • Changed but already older than 7 days (no exception needed): @types/bun 1.4.1 → 1.4.3 (published 2026-10-12) and its dependency bun-types 1.4.1 → 1.4.3 (2026-10-10).
  • Nothing else. bun.lock has 375 package entries before and after: none added, none removed, no other version or integrity changed, no top-level overrides / patchedDependencies / trustedDependencies changes. bun-types@1.4.3 declares a new undici-types: "*" dependency, which resolves to the undici-types@7.18.2 already in the lockfile, so it adds no package.
  • Reproducible. Re-resolving from main with only this PR's package.json changes produces the same 375 keys with identical versions and integrity hashes. Every changed integrity hash matches registry.npmjs.org, and all 364 registry entries in bun.lock match nix/bun.lock.nix.

If reviewers prefer to wait out the 7 days instead, the Nix overlay half of this PR is independent and still protects the dev shell in the meantime.

… 1.4.4

On 2026-10-19 the private keys of eight eMudhra (emSign) intermediate CAs
were found to be compromised. Root stores have shipped updates, but Node
and Bun carry their own root stores, so the nix-darwin
`security.pki.caCertificateBlacklist` fix never reaches them. The pinned
nixpkgs still ships Node 24.14.1 and Bun 1.3.13, and the repo pinned Bun
1.4.2; all three still connect to the test site that chains through a
compromised intermediate.

Dev shell / Nix package:
- Add nix/nodejs-bin.nix (official nodejs.org 24.22.1 tarball, darwin-only)
  and nix/emudhra-overlay.nix, which substitutes that node and Bun 1.4.4
  (official GitHub release zip) for `nodejs*`/`bun` on macOS. Linux keeps
  nixpkgs' packages untouched.
- The overlay gates each replacement on `lib.versionOlder` against the
  pinned nixpkgs version, so it retires itself once `nix flake update`
  brings nodejs_24 >= 24.22.1 and bun >= 1.4.4. Attribute names never
  depend on `prev`, and the node gate reads `prev.nodejs-slim_24` because
  `nodejs_24` is a symlinkJoin that resolves `version` through `final`.
- Wire the overlay into flake.nix's single `import nixpkgs` call.

Bun pin:
- Bump `packageManager`, the `bun` dependency, and `@types/bun` (1.4.3,
  the latest) in both package.json files; refresh bun.lock and
  nix/bun.lock.nix; update nix/package.nix's bunCompilerArchives to the
  @oven/bun-*@1.4.4 integrity hashes (x64 uses the -baseline archives, as
  since #1214); bump oven-sh/setup-bun pins and CONTRIBUTING.
- bunfig's `minimumReleaseAge` (7 days) blocks resolving a same-day Bun
  release, so the lockfile was resolved once with
  `--minimum-release-age=0`; `bun install --frozen-lockfile` (what CI runs)
  passes under the default policy because only new resolutions are gated.

Removal: once nixpkgs' nodejs_24 >= 24.22.1 and bun >= 1.4.4, delete
nix/emudhra-overlay.nix, nix/nodejs-bin.nix, and the `overlays` line in
flake.nix. Keep the bun@1.4.4 pin.

Refs:
- https://blog.cloudflare.com/emudhra-certificate-compromise/
- https://github.com/nodejs/node/releases/tag/v24.22.1
- https://github.com/oven-sh/bun/releases/tag/bun-v1.4.4
@vercel

vercel Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
hunk-web Ignored Ignored Preview Oct 22, 2026 10:44pm UTC

Request Review

@socket-security

socket-security Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updated@​types/​bun@​1.4.1 ⏵ 1.4.31001004892 -1100
Updatedbun@​1.4.2 ⏵ 1.4.4921007997100

View full report

@greptile-apps

greptile-apps Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no actionable defect was established.

Summary

Updates Bun to 1.4.4 across dependencies, locks, CI, and the compiler embedded in published binaries. Adds a temporary macOS Nix overlay for Bun 1.4.4 and Node 24.22.1.

  • Hunk's bundled Bun runtime rejects compromised eMudhra certificate chains.
  • Mac Nix shells use runtimes that reject compromised eMudhra chains.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  Pins["Bun 1.4.4 package pins"] --> CI["CI and release builds"]
  Pins --> Compiler["Nix compiler archive"]
  Compiler --> Binary["Hunk binary with embedded Bun"]
  Flake["Nix package import"] --> Gate{"macOS and older package?"}
  Gate -->|Yes| Replacement["Node 24.22.1 / Bun 1.4.4"]
  Gate -->|No| Original["Original nixpkgs packages"]
  Replacement --> Shell["Development shell"]
  Original --> Shell
Loading

Reviews (1) · Last reviewed commit: "build(nix): distrust compromised eMudhra..." · Reviewed by Greptile

…g exits on Bun 1.4.3+

With Bun 1.4.3 and later, `git diff | hunk pager` (and any run whose stdin
is not a terminal) no longer exited on `q`: the terminal looked stuck until
the user pressed Enter. CI caught it on the Bun 1.4.4 bump; `main` on Bun
1.4.2 was unaffected.

Bun implements `tty.ReadStream(fd)` as `fs.ReadStream`, so every read on
the `/dev/tty` descriptor Hunk opened for keyboard input is a blocking
`read(2)` on a worker thread, and `destroy()` waits for that read to return.
The app tears down on a later tick than the keypress that requested it;
Bun 1.4.3 started issuing the next blocking read before that tick, so the
teardown parked behind a read that only the next line of input could
satisfy. Only `process.stdin` gets Bun's poll-driven native reader.

`ControllingTerminalInput` rebuilds that path for an arbitrary terminal
device: bytes come from `Bun.file(devicePath).stream()`, whose pending
read is poll-driven and whose `cancel()` releases the event loop, while raw
mode goes through a dormant `tty.ReadStream` on a second descriptor since
termios state belongs to the device. The device must be the real node
(`/dev/ttys003`, `/dev/pts/4`): macOS kqueue rejects the `/dev/tty` alias
with EINVAL, so `resolveControllingTerminalDevice` follows `/proc/self/fd`
on Linux and matches `rdev` among `/dev` terminal entries elsewhere. When no
device resolves, startup keeps its existing "no interactive terminal"
fallback.

Verified in the PTY harness on Bun 1.4.2 and 1.4.4: `git diff | hunk pager`,
`echo | hunk diff`, `hunk pager < file`, and `hunk diff` all exit on `q`;
`test/pty/pager.test.ts` passes on both.
Bun bundles its own root store on every platform, so a Linux `nix develop`
shell was still handing out nixpkgs' bun 1.3.13, which trusts the
compromised eMudhra intermediates. Add the bun-linux-x64 and
bun-linux-aarch64 release assets (the same flavors nixpkgs selects, hashes
from the bun-v1.4.4 SHASUMS256.txt) and gate only the node override on
darwin, where the prebuilt nodejs.org tarball applies.

Verified by evaluation: devShells.{x86_64,aarch64}-linux resolve bun-1.4.4
from the matching asset while keeping nixpkgs' nodejs-24.14.1; the darwin
shell is unchanged (bun 1.4.4, node 24.22.1, probe still rejects the chain).
No Linux builder is available locally, so the Linux derivation itself was
not built here; it reuses nixpkgs' unzip + autoPatchelf recipe unchanged.
…erminal

The comment on the renderer-stdin cast said OpenTUI only uses "the Readable
surface plus setRawMode", which leaves out `isTTY`. OpenTUI gates its
terminal palette (OSC) queries on `stdin.isTTY`, and Hunk's disconnect
watcher reads it too, so dropping it from the controlling-terminal input
would silently disable palette detection rather than fail.

Name the members OpenTUI 0.5.x actually reads (audited in the Bun build of
`CliRenderer` and `TerminalPalette`) and note that the cast cannot catch
upstream drift, so `CliRenderer`'s stdin use needs a re-audit on
`@opentui/core` bumps.
`runInteractiveApp` cast the controlling-terminal input to
`NodeJS.ReadStream` before handing it to the session runner. Because the
cast covered the whole union, removing `setRawMode` (or `isTTY`) from
`ControllingTerminalInput` still compiled, and OpenTUI guards its raw-mode
calls with `if (stdin.setRawMode)`, so the regression would have shipped as
a terminal silently left in cooked mode.

Add `RendererInput`, the members OpenTUI 0.5.x and the disconnect watcher
actually read, and use it for `HunkSessionRunnerOptions.stdin`. Both
`process.stdin` and `ControllingTerminalInput` satisfy it without a cast;
the one remaining cast sits at the `createCliRenderer` call, where
OpenTUI's declared type is wider than what it uses.

Verified the guard: renaming `setRawMode` on the class now fails
`bun run typecheck` at `runInteractiveApp.tsx`. The type still cannot catch
OpenTUI starting to read a new stdin member, so its doc comment asks for a
re-audit on `@opentui/core` bumps.
…aw-mode invariants

The `ref()` comment said the device stream keeps the event loop alive
"only while a read is pending". In Bun 1.4.4 it holds the loop from the
first read until `destroy()` cancels the reader; completed reads, `pause()`,
and `unref()` do not release it. That is what keeps `hunk pager` alive while
idle (OpenTUI's keep-alive timer only runs for `process.stdin`), and it
means exit depends on `ControllingTerminal.close`, so say so instead.

Also record the two invariants the dormant raw-mode `tty.ReadStream`
relies on, both verified against Bun 1.4.3/1.4.4 source and in a PTY: it
must never be read from (a flowing handle reintroduces the blocking read
and steals keystrokes), and its descriptor is closed with `closeSync`, so
raw mode must be left before destroy and the handle not touched after.

Comment-only; no behavior change.
The header said the flake's nixpkgs pin carries Node 24.21.0; it carries
24.14.1 (`nodejs_24` and `nodejs-slim_24` both evaluate to 24.14.1 at the
locked revision), matching the PR description and the Linux dev shell.

Comment-only; no evaluation change.
@elucid
elucid merged commit 8da0984 into main Oct 23, 2026
22 of 24 checks passed
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.

1 participant