Tech14 min read

Recovering eMudhra's Weak P-384 CA Keys and Testing curl, Node.js, and uv on macOS

IkesanContents

TL;DR

What happened Eight intermediate CA certificates from the Indian CA eMudhra (six TLS, two code signing) had P-384 private keys between 1 and 255. Anyone can compute the keys from the public certificates alone.
Browsers Chrome and Firefox blocked them on October 17, 2026 (UTC). Apple and Microsoft followed on October 19 and 20. No matching certificates appeared in Certificate Transparency (CT) logs before the block, so CT-enforcing browsers could not have been attacked.
Who is exposed TLS clients outside the browser. On my Mac, macOS’s built-in /usr/bin/curl, Node.js v25.3.0, and uv 0.13.3 and earlier connected to the compromised test chains without any error.
What to do

  1. Upgrade uv to 0.13.4, certifi to 2026.10.18 or later, pip to 26.2.2, and Go to 1.27.3 or 1.26.10
  2. Update your distribution’s ca-certificates package
  3. Node.js has no fix yet as of October 21, 2026

On October 17, 2026, a researcher reported that the private keys of eight intermediate CA certificates issued by the Indian certificate authority (CA) eMudhra could be computed by anyone.
A CA signs a website’s certificate to say “the holder of this key controls this domain,” and browsers and operating systems trust that signature to decide that a server is genuine.
Anyone holding an intermediate CA’s private key can issue certificates for any domain within the scope that intermediate is allowed to sign.

P-384 itself was not broken. The problem was how the keys were generated.
A P-384 (384-bit elliptic curve) private key should be a random integer with roughly 2^384 possible values. Mozilla found that all eight keys were zero in every byte except the last of their 48 bytes, which makes each one an integer below 256 (Mozilla Bugzilla 2083449).
With only 255 candidates, brute force finishes instantly.

Browsers blocked the certificates within hours of the report, but I wanted to know how package managers and command-line tools fared.
I recovered the keys on my M4 Mac mini and checked whether several TLS clients would accept the compromised certificate chains on the researcher’s test site.

The eight affected intermediate CAs

Browsers and operating systems follow the chain of signatures from a site’s certificate through an intermediate CA up to a root certificate, and accept the connection only if that root is on their trusted list.
An intermediate CA sits below the root and is the certificate that actually signs site certificates. Which roots are trusted is decided by the root programs that Chrome, Mozilla, Microsoft, and Apple each run.

On April 30, 2025, eMudhra held a key generation ceremony that created 24 intermediate CAs in one batch. The eight affected certificates are the P-384 keys from its B series (G3B) and C series (G3C).

TypeIntermediate CAUseMain root programs that trusted it
TLSEM DV / OV / EV TLS CA - G3BWebsite certificatesChrome, Mozilla, Microsoft, Apple
TLSEM DV / OV / EV TLS CA - G3CWebsite certificatesChrome, Mozilla, Microsoft, Apple
Code signingEM OV CS CA - G3B / G3CSoftware signingMicrosoft

The TLS intermediates chain through eMudhra’s emSign Root TLS CA - G3 to emSign ECC Root CA - G3.
Separately, Sectigo’s AAA Certificate Services root had signed the same public keys in eight cross-signed certificates. A cross-sign lets a different root vouch for the same intermediate, and because the key is the same, the cross-signed certificates are compromised too.
Sectigo revoked its cross-signs around October 19 (Bugzilla 2083449 comment 13).

graph TD
    A["emSign ECC Root CA - G3<br/>(eMudhra root)"] --> B["emSign Root TLS CA - G3"]
    B --> C["EM DV/OV/EV TLS CA<br/>G3B and G3C (6 certs)"]
    D["AAA Certificate Services<br/>(Sectigo root)"] --> E["Cross-signed certs<br/>with the same keys"]
    C --> F["Private key is an<br/>integer from 1 to 255"]
    E --> F

eMudhra says its RSA intermediates went through a different process and are not affected.
The P-384 keys in the A and A-1 series came from the same ceremony, but Mozilla could not recover them despite considerable effort.

How the weak keys were created

According to eMudhra’s explanation in Bugzilla comment 11, the keys were generated on a hardware security module (HSM), a device that stores keys and performs cryptographic operations without letting the keys leave it. The HSMs were Utimaco units certified to FIPS 140-2 Level 3.
eMudhra had approved a deviation from the usual ceremony to test wrap/unwrap for key export. A flaw in that code created a second, weaker pair of keys inside the HSM.
Because the key labels were similar, the weak keys were used by mistake, signed by the root, and sent to Sectigo for cross-signing.

eMudhra acknowledged that “There were human errors in multiple steps of the process” and is re-verifying all of its key ceremonies.
A weak key like this is still a mathematically valid elliptic curve private key, so public key format checks and sign-and-verify tests pass without errors.

eMudhra and the reporter describe issuance differently: eMudhra says no end-entity certificates (the certificates handed to websites) were issued from these intermediates. The reporter’s timeline says G3B issued nothing beyond OCSP (revocation status responses), while EM DV TLS CA - G3C was live until it was disabled on October 17.

Timeline from report to block

From the April 2025 key generation to the October 17, 2026 block, anyone could compute the keys for about a year and a half.
By contrast, the major browsers blocked them less than five hours after the report. All times are UTC.

Time (UTC)EventSource
April 30, 2025Key generation ceremony for 24 intermediate CAsBugzilla 2083449
October 17, 2026 18:05A researcher reports to the Chrome Root Programdev-security-policy (Chrome)
October 17, 19:45Chrome blocks the public keys of the six TLS intermediates via CRLSet, Chrome’s pushed revocation listSame
October 17, 20:06Mozilla receives the reportMozilla Security Blog
October 17, 21:48Mozilla independently recovers the keysBugzilla 2083449
October 17, 22:59Firefox finishes blocking via OneCRL, Mozilla’s pushed revocation listSame
October 18, 09:24 to 09:38eMudhra revokes the eight certificates as CA compromiseSame
October 19Apple blocks the certificatesTest site
October 20Microsoft blocks them. NSS 3.131.1 removes emSign ECC Root CA - G3Same, Mozilla Security Blog

Certificate Transparency (CT) requires issued certificates to be recorded in public logs, and clients that enforce CT reject certificates that are not in those logs.
According to Chrome, no TLS certificates chaining to the six intermediates were logged to CT before the block, so none could have been trusted by default in CT-enforcing browsers. The test site likewise says Firefox, Chrome, Edge, and Safari enforce CT and could not have been attacked.
It adds that many clients other than browsers do not enforce CT, and it is unknown whether attackers targeted them (test site).

Because multiple people had recovered the keys publicly, Mozilla removed trust in emSign ECC Root CA - G3 on an emergency basis (Bug 2084101).
NSS is Mozilla’s TLS library used by Firefox, and it also carries the root certificate list. Chrome says version 43 of the Chrome Root Store will remove the eMudhra root.
Firefox 158.0.1 and the Extended Support Releases (ESR) 115.43.1 and 153.5.1 apply the same block to installations whose settings prevent OneCRL updates (MFSA 2026-111, CVE-2026-113470).

eMudhra stopped issuing under the G3 root and is moving subscribers to G1-chained intermediates. About 30,000 certificates need to migrate, and about 97% of them were obtained through ACME, the automated issuance protocol used by Let’s Encrypt and others.

Recovering all eight keys from public certificates

With only 255 candidates, I tried recovering the keys myself on an M4 Mac mini.

ItemDetails
MachineMac mini (M4)
OSmacOS 26.5.2
Python3.14.4
Librarycryptography 50.0.2
Inputimpacted-intermediates.json from the test site

The test site published by the reporter (emudhra.we-have-a-problem.com) hosts impacted-intermediates.json, a list of the affected intermediate certificates.
A certificate’s public key is the private key k multiplied by the curve’s base point G. There is no known efficient way to work backward from the public key to k, and normally there are far too many candidates to try them all.
Here there were only 255, so I generated the public key for every k from 1 to 255 and looked for one that matched each certificate. The certificate holds only the public key, and the candidate k whose public key matches is the private key.

from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization

fmt = (serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
table = {ec.derive_private_key(k, ec.SECP384R1()).public_key().public_bytes(*fmt): k
         for k in range(1, 256)}

cert = x509.load_pem_x509_certificate(pem)  # PEM from the certificate list
print(table.get(cert.public_key().public_bytes(*fmt)))

Generating all 255 public keys took 28.7 milliseconds.
All eight certificates matched one of the values, and the eMudhra-issued certificate and the Sectigo cross-sign for each intermediate gave the same value.

Intermediate CAUsePrivate key value
EM DV TLS CA - G3BTLS156
EM OV TLS CA - G3BTLS155
EM EV TLS CA - G3BTLS115
EM DV TLS CA - G3CTLS230
EM OV TLS CA - G3CTLS4
EM EV TLS CA - G3CTLS56
EM OV CS CA - G3BCode signing66
EM OV CS CA - G3CCode signing237

The private key for EM OV TLS CA - G3C was 4. With nothing but the public certificates and the cryptography library, I could compute a CA signing key without breaking into the CA.
These intermediates are already revoked and blocked, so updated clients reject any certificate signed with these values during verification.

Testing curl, Node.js, Python, uv, and Go on macOS against the compromised chains

Browsers blocked the intermediates through CRLSet and OneCRL, but software outside the browser verifies certificates in its own way.
The test site has hosts that serve certificates signed by the compromised intermediates. dv-g3b-emudhra serves a chain to emSign ECC Root CA - G3, and dv-g3b-aaa-emudhra serves one through Sectigo’s AAA Certificate Services.
A client that connects to these hosts without an error trusts the compromised intermediates.

I ran the tests on October 21, 2026, on the same M4 Mac mini (macOS 26.5.2).
In this test, “connected” and “passed TLS verification” are the bad outcomes. A verification failure or error is the correct behavior.

ClientVersionVia eMudhra rootVia Sectigo root
/usr/bin/curl (macOS built-in)8.7.1 (SecureTransport)Connected (200)Connected (200)
Node.js fetchv25.3.0Connected (200)Connected (200)
Python + certifi2026.7.22Connected (200)Verification failed
Python + certifi2026.10.18 / 2026.10.19Verification failedVerification failed
uv pip compile0.9.21 (Homebrew)Passed TLS verificationNot tested
uv pip compile0.13.3Passed TLS verificationNot tested
uv pip compile0.13.4invalid peer certificate: UnknownIssuerNot tested
Go net/http1.26.2 (macOS)certificate is revokedcertificate is revoked

The first client I tried, macOS’s built-in curl, connected to both hosts even after Apple’s October 19 block.
The test site explains that /usr/bin/curl does not check revocation and uses an outdated /etc/ssl/cert.pem. When I checked /etc/ssl/cert.pem on this machine, both emSign ECC Root CA - G3 and AAA Certificate Services were still in it.
Node.js v25.3.0’s fetch also connected to both hosts. Node.js embeds Mozilla’s root certificate list in its binary, and the test site lists both Node.js and Bun as pending as of October 21, 2026.

With certifi 2026.7.22, Python already failed on the Sectigo chain.
That certifi release no longer includes AAA Certificate Services but still includes emSign ECC Root CA - G3. From 2026.10.18 onward, the emSign root is gone as well.

For uv, I first pointed uv pip install --dry-run at the test host with --index-url, but it stopped before TLS with an error about a missing virtual environment.
Switching to pip compile, the Homebrew-installed 0.9.21 passed TLS verification and got as far as the HTTP response (Unsupported Content-Type "text/plain"). Running the same command with 0.13.4 fetched through uvx failed at the TLS stage with UnknownIssuer, while the previous release, 0.13.3, got through just like 0.9.21.
By default uv bundles root certificates taken from webpki-root-certs, and 0.13.4 removed emSign ECC Root CA - G3 from that bundle (GHSA-hq85-5cpj-mr62).
brew info uv showed that Homebrew already ships 0.13.4.

On macOS, Go hands certificate verification to the OS Security framework, so Apple’s block applied directly and the result was certificate is revoked.
Go 1.27.3 and 1.26.10 were released on October 19, and the release history says only that they include security fixes to the crypto/x509 package (Go Release History).

Package manager checks that still work when TLS verification is defeated

If a package manager verifies file hashes or signatures separately from TLS, a fake package is rejected at that step even when TLS verification has been defeated.
That only works if the correct hash or signature is already known in advance.

ToolVerification beyond TLSWhen it still protects you if TLS is defeated
aptRepository metadata is GPG-signed (SecureApt)Usually, because the public keys that verify the signatures are already on the system
Go modulesChecksum database (sum.golang.org) and go.sum (Go Modules Reference)When go.sum or the public checksum database can be checked
npm / pnpmLockfile integrity (package-lock.json)When an existing lockfile has the hash
pipHash checking with --require-hashes (Secure installs)When you use a requirements.txt with hashes
uvHashes recorded in uv.lockWhen an existing lockfile is present

When you resolve dependencies from scratch without a lockfile, there is no correct hash to compare against yet.
If TLS verification has been defeated at that point, files from an attacker impersonating the package registry are accepted as the real thing.
CI/CD container images that still carry old uv or certifi releases, and jobs that add dependencies without a lockfile, are protected only by TLS verification.

Cloudflare says its security review has not found active exploitation on Cloudflare services (Cloudflare Blog). That statement covers Cloudflare’s own services and says nothing about other environments.
The same post warns that disabling certificate verification to make the errors go away removes the authentication protection these updates are meant to restore. It also notes that updating an SDK alone does not change the result if the TLS library or the OS trust store (where trusted root certificates are kept) underneath is still outdated.

Fixed versions as of October 21, 2026

These are the main entries from the test site’s list.

SoftwareFixed version
uv0.13.4
Python certifi2026.10.18 or later (the list shows 2026.10.19)
pip26.2.2 (vendored certifi updated to 2026.10.19)
Go1.27.3, 1.26.10
Rust webpki-roots / webpki-root-certs1.0.10
Rust rustls-webpki0.103.16
Composer ca-bundle1.4.16, 1.6.0
Electron44.9.1, 43.8.1, 42.11.16
NSS3.131.1
Ubuntu ca-certificates20260816ubuntu0.22.04.1 / 0.24.04.1 / 0.26.04.1 / 0.26.10.1
Debian ca-certificates20250419~deb12u2, 20250419+deb13u1, 20260816+nmu1
Alpine ca-certificates20261020-r0
Node.js, Bun, FreeBSD ca_root_nssNot fixed yet (listed as pending)

Composer’s ca-bundle released 1.4.16 and 1.6.0 on October 20, and added CaBundle::findDistrustedCertificate to detect chains that include the compromised intermediates (composer/ca-bundle 1.6.0).

You can check whether your environment still trusts the compromised intermediates by connecting to the test site’s hosts. A certificate error means the client does not trust them.

curl https://dv-g3b-emudhra.we-have-a-problem.com/
curl https://dv-g3b-aaa-emudhra.we-have-a-problem.com/

The test site also documents how to remove the two roots from /etc/ssl/cert.pem or /etc/ca-certificates.conf. Removing a whole root breaks every legitimate certificate that chains to it, so the site treats this as a last resort for expert users.
When I tried again at 19:47 JST on October 21, 2026, the built-in macOS curl on my Mac still returned 200 from both hosts.