# eMudhra CA compromise: curl, pip, Node.js and Claude Code each kept their own trust store on my Mac

> Which CA list curl, pip, certifi, uv, Node.js, Go and Claude Code each read on a Mac after the eMudhra intermediate CA key compromise, and how to patch each.

- Source: https://oharu121.com/blog/emudhra-ca-compromise-trust-store-curl-pip-claude-code/
- Published: 2026-10-24T20:21:23+09:00
- Tags: Security, Python

---
## Introduction

I came across [emudhra.we-have-a-problem.com](https://emudhra.we-have-a-problem.com/), a page that checks whether a client still trusts eight compromised eMudhra certificate authorities, and ran its curl test on my Mac:

```bash
curl https://dv-g3b-emudhra.we-have-a-problem.com/
# Your client successfully connected to the site. That's bad!
```

Chrome and Firefox blocked those authorities the day the keys were reported. Other tools were slower, and the news I had read named only pip and uv as needing an update. I asked Claude Code to check the rest of my machine, and curl, Claude Code itself, and sixteen Python environments turned out to need one too.

**Each of those tools reads its own list of trusted authorities, not the browser's.** Update the tools that bundle a list, bump certifi wherever pip runs, and follow the release announcements for the few that have to wait for a vendor.

## What happened to eMudhra's intermediate CAs

**Eight intermediate certificate authorities run by eMudhra were created with private keys anyone can compute from the public certificate.** Security researcher Scott Helme recovered all eight in under a second, because the keys are whole numbers below 256.

eMudhra traces the fault to its 30 April 2025 key-generation ceremony, the formal procedure that creates CA keys inside a hardware security module (HSM).

A key wrap step there (encrypting a key under another key for storage) produced a second, weak set of keys alongside the strong ones, and the weak set is what went into the intermediate certificates the roots signed.

*Figure — KeyCeremony: eMudhra has not said how the wrap step produced weak keys, only that the weak set is the one the roots signed.*

emSign is eMudhra's own CA brand, and `emSign ECC Root CA - G3` is eMudhra's root certificate. Six of the eight intermediates issue TLS certificates (domain-validated DV, organization-validated OV and extended-validation EV, each in a G3B and a G3C variant) and chain to that root. The other two sign code.

Each of the eight also has a cross-signed copy. Cross-signing means a second CA's root signs the same intermediate key, so one key reaches two roots. Newer CAs use it to be trusted on older devices that only know the older root.

Here the second CA is Sectigo, a large commercial CA formerly called Comodo CA, and its root is the older `AAA Certificate Services`, which Apple, Microsoft and macOS's `/etc/ssl/cert.pem` still ship.

**Removing the emSign root from a trust store therefore leaves the cross-signed chain trusted.** Dropping emSign costs little, since that root backs only eMudhra's own certificates. Dropping the AAA root would break the large number of unrelated sites that chain to it, so browser and OS vendors block the eight intermediate keys themselves.

| Date (2026) | What happened |
| --- | --- |
| 17 Oct | A researcher credited as Wayne reports the keys to Chrome and Mozilla. Chrome's CRLSet blocks the six TLS intermediates by 19:45 UTC; Firefox's OneCRL block lands at 22:59 UTC |
| 18 Oct | eMudhra revokes all eight intermediates |
| 19 Oct | Apple blocks them on its platforms; Android follows through Google Play Services |
| 20 Oct | Microsoft blocks them. Firefox 158.0.1, NSS 3.131.1, certifi 2026.10.19, pip 26.2.2 and Go 1.27.3 / 1.26.10 ship |
| 21 Oct | Node.js 26.12.1, 24.22.1 and 22.23.4 ship with a key blocklist |

**No misuse has been confirmed.** No TLS certificate chaining to the six TLS intermediates had been logged to Certificate Transparency before Chrome's block, and eMudhra says none of the eight had issued a leaf certificate (the end-entity certificate a site or program receives). Whether anyone computed the keys earlier and used them unlogged cannot be known.

> **HEADS UP**
>
> **Why "leaf"?**
>
> Certificates and their signers form a tree drawn upside down: the root at the top, intermediates as branches, and the certificate issued to a site or program at the tips, like a leaf. A leaf cannot sign other certificates, so the chain ends there.

## How a certificate chain works, and what a recovered intermediate key can sign

A TLS client trusts a site's certificate because of who signed it. The site's certificate (the leaf) is signed by an intermediate CA, and the intermediate is signed by a root. The client keeps a trust store, a list of root certificates it accepts, and follows the signatures from the leaf up to one of those roots.

Revocation, a CA publicly declaring a certificate no longer valid, leaves that chain intact: the root and its signature on the intermediate both stay valid. **A client rejects a revoked intermediate only if it checks revocation or carries an explicit block**, and the security firm Endor Labs notes that "default TLS stacks in Python, Ruby, Go, Node.js, and most command-line tools do not check revocation".

Because anyone can compute these private keys, anyone holding one can sign a certificate for any domain, and **every client still trusting the chain accepts it as genuine**. An attacker on a client's connection can then swap what `pip install` or `curl | sh` receives, and the TLS check still passes.

Certificate Transparency (CT) is the safeguard browsers add on top of chain verification: public, append-only logs of issued certificates. Chrome and Safari refuse a certificate that was never logged, so a forged one always leaves a public record. **curl does not check CT**, so a forged certificate used against curl leaves no public trace at all.

### Route 1: intercepting the connection (man-in-the-middle, MITM)

An attacker who sits on the network between a client and the site can intercept the connection itself. Public Wi-Fi is the familiar case, but a compromised home router, an ISP or a corporate proxy sits in the same place.

When the client connects to `pypi.org`, the attacker answers in the site's place with a forged `pypi.org` certificate signed by one of the compromised intermediates. The client follows the chain up to the emSign root in its trust store and accepts it, so **the attacker can read and rewrite everything in both directions**.

*Figure — MitmRoute: The forged certificate passes because the chain ends at a root the client already trusts.*

### Route 2: poisoning DNS

DNS is the lookup service that turns a domain name into an IP address. An attacker who can change that answer need not be on the path. A poisoned resolver, or a DNS server handed out by a compromised router, tells the client that `pypi.org` lives at the attacker's IP address, and the client connects there directly.

Without a compromised key this attack stops at TLS, because the attacker's server has no valid certificate for `pypi.org`. **A forged certificate signed by one of the eMudhra intermediates removes that last check**, and the swapped package installs without a warning.

*Figure — DnsPoisoning: DNS poisoning needs no position on the path; the compromised key is what lets it past TLS.*

### Who is exposed

Intercepting the connection and poisoning DNS both need control over some part of the network between a client and the site. I use Cloudflare WARP, a VPN-like service, on my Mac. It tunnels traffic and DNS lookups to Cloudflare, so while it is on, the local Wi-Fi and router can neither intercept the connection nor change the DNS answer.

**A tunnel narrows the exposure but does not close it.** CI runners, servers and containers do not run behind it, and the gap reopens whenever the tunnel is off. Updating the trust store closes it on every network.

## Where each tool on a Mac gets its CA list

A Mac has at least five separate CA lists, and **each was patched by a different vendor on a different day**.

*Figure — TrustStoreMap: Each CA list is patched separately, so a browser that rejects the chain says nothing about curl or pip.*

- **The macOS platform verifier** serves Safari, apps that verify through the Keychain, and Go programs, which hand verification to the OS on macOS. Apple blocked the intermediates there on 19 October.
- **`/etc/ssl/cert.pem`** is a PEM file (certificates stored as text) that nothing updates automatically, read by the system's `/usr/bin/curl`, `git`, `python3` and `openssl`. They check no revocation, and the file on macOS 26.7.1 still contains `emSign ECC Root CA - G3`.
- **A copy compiled into the binary** ships with Node.js, with Bun, and so with Claude Code, which is built on Bun. Only a new release changes it.
- **certifi**, a Python package holding Mozilla's root list as a PEM file, is what pip and `requests` read. pip also vendors its own copy of certifi inside the pip package.
- **uv** compiles Mozilla's roots in through the Rust crate `webpki-root-certs`.

Chrome and Firefox sit beside these with their own block lists (CRLSet and OneCRL), which is why the browsers were safe first.

## What the "That's bad!" curl test checks

A successful response from a test host means the client trusts a chain through one of the compromised intermediates. The page serves four hosts:

| Host | Chain it serves |
| --- | --- |
| `dv-g3b-emudhra` | Leaf → EM DV TLS CA - G3B → `emSign ECC Root CA - G3`, logged to CT |
| `dv-g3b-aaa-emudhra` | The same intermediate, cross-signed under Sectigo's `AAA Certificate Services` |
| `dv-g3b-noct-emudhra` | The `dv-g3b-emudhra` chain, with a leaf never logged to CT |
| `dv-g3b-aaa-noct-emudhra` | The `dv-g3b-aaa-emudhra` cross-signed chain, with a leaf never logged to CT |

A connection to a `noct` host means the client both trusts the chain and skips the CT check, which the page calls the clients that "can be attacked without anyone noticing". **My `/usr/bin/curl` connected to the `noct` host too**, with the same "That's bad!" reply.

## Checking each tool against its fixed version

A version number says what a tool ships, and a probe against the test hosts says what it accepts, so I asked Claude Code to run both. The Python probe pins the certifi version under test, which makes it reusable for any environment:

```python title="probe.py"
import ssl, certifi, urllib.request

ctx = ssl.create_default_context(cafile=certifi.where())
for host in ["dv-g3b-emudhra", "dv-g3b-aaa-emudhra"]:
    url = f"https://{host}.we-have-a-problem.com/"
    try:
        urllib.request.urlopen(url, context=ctx, timeout=15)
        print(host, "CONNECTED")
    except Exception as e:
        print(host, "ERR", getattr(e, "reason", e))
```

```bash
uv run --no-project --with certifi==2026.7.22 python -I probe.py
```

| Tool on my Mac | Result against `dv-g3b-emudhra` | Meaning |
| --- | --- | --- |
| `/usr/bin/curl` | `200`, connected | Still trusts the eMudhra chain |
| certifi 2026.7.22 | `CONNECTED` | Still trusts the eMudhra chain |
| certifi 2026.10.19 | `CERTIFICATE_VERIFY_FAILED` | Rejects |
| Node.js 26.12.1 | `CERT_REVOKED` | Rejects |
| uv 0.13.5 | `invalid peer certificate: …(CertDistrusted)` | Rejects |
| Go 1.26.4 / 1.27.3 | `x509: … certificate is blocked` | Rejects |

Go 1.26.4 rejects `dv-g3b-emudhra` just as 1.27.3 does. On macOS the `certificate is blocked` error comes from Apple's platform verifier, so **Apple's 19 October block covered every Go version on a Mac**.

Go 1.27.3 and 1.26.10 add a blocklist of the eight public keys to `crypto/x509` itself. The blocklist matters on Linux and in containers, where Go reads the root files directly, so binaries built for Linux and containers need a rebuild with Go 1.27.3 or 1.26.10.

Node.js 26.12.1 and uv 0.13.5 were already on fixed releases. Claude Code was on 2.1.292, behind the 2.1.303 that emudhra.we-have-a-problem.com lists, and I updated Claude Code. Anthropic's changelog for 2.1.303 does not mention certificates, and community tests report 2.1.304 or later rejecting both the emSign chain and the AAA cross-signed chain, which makes 2.1.304 the safer floor.

## Why patching the OS does not reach pip

**pip always loads its own bundled certifi as well as the system trust store, and nothing turns that off.** Setting `--cert` or `REQUESTS_CA_BUNDLE` adds more authorities and leaves certifi's own list in place. Every virtual environment therefore carries its own certifi to update.

I asked Claude Code to find every installed copy of certifi and pip under my home directory, Homebrew and the Command Line Tools' Python:

```bash
find ~ /opt/homebrew /Library/Developer/CommandLineTools/Library/Frameworks \
  \( -path ~/Library/Caches -o -path ~/.cache/uv \) -prune -o \
  -type d \( -name 'certifi-*.dist-info' -o -name 'pip-*.dist-info' \) -print
```

**All 16 certifi copies were older than 2026.10.19, and all 11 pip installs older than 26.2.2.** The copies sat in project virtual environments, a tool installed with `uv tool`, the Google Cloud SDK's private environment, a pre-commit cache, and the Python installs from Homebrew, the Command Line Tools and uv themselves. The fix depends on what owns each environment:

| Environment | Command |
| --- | --- |
| A uv project | `uv lock --upgrade-package certifi && uv sync --inexact` |
| A plain venv | `<venv>/bin/python -m pip install -U pip certifi` |
| Tools installed with `uv tool` | `uv tool upgrade --all` |

`--upgrade-package certifi` moves certifi alone in `uv.lock` and leaves every other pin where it was. `--inexact` keeps packages that were installed into the venv by hand rather than removing them to match the lock. The lockfile change then needs a commit in each repository, and any container image built from those repositories needs a rebuild.

## What waits on Apple, Homebrew, or a manual cleanup

Four installs stay exposed until Apple or Homebrew ships a fix or I clean them up by hand:

1. **`/usr/bin/curl`, `git`, `python3` and `openssl`, the tools macOS ships,** read `/etc/ssl/cert.pem`, and only a macOS update replaces that file. Apple had not shipped one by 21 October, and my Mac was still on 26.7.1 when I wrote this.
2. **The pip 21.2.4 inside Xcode and the Command Line Tools** belongs to Apple and changes only when Apple ships a new Xcode or Command Line Tools, so `/usr/bin/pip3` is best left unused.
3. **The pip 26.0 in Homebrew's Python 3.13 and 3.14** updates only after Homebrew rebuilds the `python@3.13` and `python@3.14` formulas (Homebrew's package definitions).
4. **A hook environment built by pre-commit, a tool that manages git hooks,** holds pip 26.2.1 until it is rebuilt. `pre-commit clean` clears those hook environments, but the `pre-commit` command was not on my `PATH`, so the cache directory under `~/.cache/pre-commit` has to be deleted by hand.

For curl there is a per-call workaround. curl's own CA bundle, extracted from Mozilla on 20 October, no longer contains the emSign root, and `/usr/bin/curl` accepts it through the `CURL_CA_BUNDLE` environment variable:

```bash
curl -sS -o ~/.config/cacert.pem https://curl.se/ca/cacert.pem
CURL_CA_BUNDLE=~/.config/cacert.pem curl https://dv-g3b-emudhra.we-have-a-problem.com/
# curl: (60) SSL certificate problem: unable to get local issuer certificate
# More details here: https://curl.se/docs/sslcerts.html
```

> **CAREFUL**
>
> **The workaround covers curl only**
>
> `CURL_CA_BUNDLE` changes nothing for `git`, `python3` or `openssl`, which read `/etc/ssl/cert.pem` through their own settings. Once a macOS update replaces `/etc/ssl/cert.pem`, I will remove `CURL_CA_BUNDLE`.

## Summary

Anyone could compute the private keys of the eight eMudhra intermediates, and a browser being safe said nothing about curl, pip and the other tools on the same Mac. On a Mac the CA lists that needed separate attention were these:

- `/etc/ssl/cert.pem`, behind `/usr/bin/curl`, which waits for Apple and takes `CURL_CA_BUNDLE` in the meantime.
- The CA list copies compiled into Node.js and Claude Code, fixed by updating each.
- certifi, which has to be bumped in every virtual environment and `uv.lock`, because pip loads it whatever the OS trusts.

Go on macOS was covered by Apple's block, so the Go update matters for the Linux binaries built with Go. **The test hosts on emudhra.we-have-a-problem.com are the quickest way to check any one of these CA lists.**

## References

- [eMudhra intermediate CA compromise: test hosts and patch status per tool](https://emudhra.we-have-a-problem.com/)
- [The Hacker News: eight eMudhra CA keys compromised, with the block timeline](https://thehackernews.com/2026/10/eight-emudhra-ca-keys-compromised.html)
- [BleepingComputer: Mozilla, Google and Apple block the compromised eMudhra certificates](https://www.bleepingcomputer.com/news/security/mozilla-google-apple-block-compromised-emudhra-ca-certificates/)
- [Endor Labs: the trust stores you patched and the ones you didn't know you had](https://www.endorlabs.com/learn/emudhra-intermediate-ca-compromise-the-trust-stores-you-patched-and-the-ones-you-didnt-know-you-had)
- [StepSecurity: what Python, pip and CI/CD users need to update](https://www.stepsecurity.io/blog/emudhra-emsign-ca-compromise-python-pip-ci-cd-update)
- [curl CA certificate bundle extracted from Mozilla](https://curl.se/docs/caextract.html)
