On 17 October, someone told the browser root programs they could recover the private keys of eight intermediate CA certificates issued by eMudhra. Mozilla's incident report, Bugzilla 2083449, explains how that was possible in one line:
The 8 impacted keys all used integers less than 256.
A P-384 private key should be a random number about 384 bits long. These were one byte. Every signature they made was valid and every chain built on them validated. Anyone could find the keys by trying 255 numbers.
I wanted to see that myself without installing anything, so I wrote a check that uses only the Python standard library. It reads the public key out of every certificate in CCADB's public reports, compares it with the public keys for private keys 1 to 65,535, and matches SPKI hashes against the list Go shipped on 19 October. Then I pointed a second script at the trust store on the machine I ran it on, and found the distrusted root still in it. Everything below was run on 22 October 2026.
What the incident report says
From the report and its comments, attributed to whoever said it:
- The report reached Mozilla at 20:06 UTC on 17 October with proof of key control for all eight. Mozilla says it recovered the keys independently by 21:48 UTC.
- Six are TLS intermediates trusted by Chrome, Mozilla, Microsoft and Apple. Two are code-signing intermediates trusted by Microsoft.
- eMudhra revoked all eight on 18 October with the reason
cACompromise. Sectigo revoked its cross-signatures of the same keys on 19 October. - eMudhra says the keys came from one key ceremony on 30 April 2025, only one of the eight was in production, and no end-entity certificates were issued from any of them. Its preliminary assessment points at a flaw in the key-generation script during a wrap/unwrap test that departed from its usual ceremony. It says it is still assessing.
- On 20 October, Mozilla wrote that "multiple individuals have independently recovered the private keys" and removed trust in the whole emSign ECC Root CA - G3. Chrome said it would take a similar path.
Go's fix landed a day earlier. Go 1.26.10 and 1.27.3 add a distrustedSPKIHashes map of eight SHA-256 hashes to crypto/x509 and drop any chain containing one of them. The commit names no CA. My reading of why Go needed its own list comes from verify.go itself. The doc comment on Verify says "this function doesn't do any revocation checking", and on Linux the roots come from the OS bundle (root_linux.go tries /etc/ssl/certs/ca-certificates.crt first). With no CRL check and no control over the OS bundle, a list of bad keys is the one thing Go can enforce everywhere, and the same commit adds the check to the macOS and Windows verifier paths too.
Finding a small private key without any discrete-log maths
An EC public key is the point d·G, where d is the private key and G is the curve's generator. Recovering d from a properly random key is the hard problem the whole scheme rests on. Recovering it when d is below 256 is a loop: compute G, 2G, 3G and so on, and compare each one with the public key.
Here is the curve arithmetic for P-256 and P-384, in affine coordinates with Python's built-in modular inverse. The self-test matters more than it looks. A typo in the generator or the prime would make the table silently useless, and checking that G is on the curve and that n·G is the point at infinity catches a typo in any of the four constants.
import csv, hashlib, re, sys
from base64 import b64decode
# NIST P-256 and P-384: field prime, curve b (a = -3 on both), generator, order
CURVES = {
"P-256": dict(
oid=bytes.fromhex("2a8648ce3d030107"),
p=2**256 - 2**224 + 2**192 + 2**96 - 1,
b=0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B,
g=(0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296,
0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5),
n=0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551),
"P-384": dict(
oid=bytes.fromhex("2b81040022"),
p=2**384 - 2**128 - 2**96 + 2**32 - 1,
b=0xB3312FA7E23EE7E4988E056BE3F82D19181D9C6EFE8141120314088F5013875AC656398D8A2ED19D2A85C8EDD3EC2AEF,
g=(0xAA87CA22BE8B05378EB1C71EF320AD746E1D3B628BA79B9859F741E082542A385502F25DBF55296C3A545E3872760AB7,
0x3617DE4A96262C6F5D9E98BF9292DC29F8F41DBD289A147CE9DA3113B5F0B8C00A60B1CE1D7E819D7A431D7C90EA0E5F),
n=0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFC7634D81F4372DDF581A0DB248B0A77AECEC196ACCC52973),
}
def ec_add(c, P, Q):
"""Affine point addition on y^2 = x^3 - 3x + b. None is the point at infinity."""
p = c["p"]
if P is None:
return Q
if Q is None:
return P
if P[0] == Q[0] and (P[1] + Q[1]) % p == 0:
return None
if P == Q:
lam = (3 * P[0] * P[0] - 3) * pow(2 * P[1], -1, p) % p
else:
lam = (Q[1] - P[1]) * pow(Q[0] - P[0], -1, p) % p
x = (lam * lam - P[0] - Q[0]) % p
return x, (lam * (P[0] - x) - P[1]) % p
def ec_mul(c, k, P):
R = None
while k:
if k & 1:
R = ec_add(c, R, P)
P = ec_add(c, P, P)
k >>= 1
return R
def self_test(c):
x, y = c["g"]
assert (y * y - (x**3 - 3 * x + c["b"])) % c["p"] == 0, "G is not on the curve"
assert ec_mul(c, c["n"], c["g"]) is None, "n*G is not the point at infinity"
def small_key_table(c, limit):
"""Public points for private keys 1..limit-1, keyed by point."""
table, P = {}, None
for d in range(1, limit):
P = ec_add(c, P, c["g"])
table[P] = d
return table
Pulling the public key out of a certificate
Go hashes RawSubjectPublicKeyInfo, the complete DER-encoded SubjectPublicKeyInfo, so the script has to cut out exactly those bytes. A certificate is a SEQUENCE containing the to-be-signed SEQUENCE, and the SPKI is the sixth field of that once the optional [0] version tag is skipped. A minimal DER reader is enough:
def tlv(buf, i):
"""Read one DER tag-length-value. Returns (tag, value_start, value_end)."""
tag, length = buf[i], buf[i + 1]
i += 2
if length & 0x80:
n = length & 0x7F
length = int.from_bytes(buf[i:i + n], "big")
i += n
return tag, i, i + length
def children(buf, start, end):
out = []
while start < end:
tag, vs, ve = tlv(buf, start)
out.append((tag, start, vs, ve))
start = ve
return out
def spki_der(cert):
"""The raw SubjectPublicKeyInfo bytes, the thing Go hashes."""
_, vs, ve = tlv(cert, 0)
_, tvs, tve = tlv(cert, vs) # tbsCertificate
fields = children(cert, tvs, tve)
if fields[0][0] == 0xA0: # explicit [0] version
fields = fields[1:]
_, start, _, end = fields[5] # serial, sigAlg, issuer, validity, subject, spki
return cert[start:end]
def ec_public_point(spki):
"""(curve name, (x, y)) for an EC key, or (None, None) for anything else."""
_, vs, ve = tlv(spki, 0)
(_, _, avs, ave), (_, _, bvs, bve) = children(spki, vs, ve)
params = children(spki, avs, ave)
if len(params) < 2:
return None, None
curve_oid = spki[params[1][2]:params[1][3]]
point = spki[bvs + 1:bve] # skip the unused-bits byte
for name, c in CURVES.items():
if c["oid"] == curve_oid and point[0] == 4:
half = (len(point) - 1) // 2
return name, (int.from_bytes(point[1:1 + half], "big"),
int.from_bytes(point[1 + half:], "big"))
return None, None
def pem_to_der(text):
m = re.search(r"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", text, re.S)
return b64decode("".join(m.group(1).split())) if m else None
Running it over CCADB
The last part holds Go's eight hashes, copied from verify.go at tag go1.27.3, and walks a CCADB CSV. The three CCADB reports below are Mozilla's revoked intermediates, all intermediates, and included roots, each with a PEM column.
GO_DISTRUSTED = { # crypto/x509/verify.go at go1.27.3, lines 1417-1426
"ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105",
"49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f",
"e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41",
"5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0",
"43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b",
"63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8",
"ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6",
"92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30",
}
LIMIT = 2**16 # private keys 1..65535: covers "below 256" with room to spare
def scan(path, tables):
csv.field_size_limit(sys.maxsize)
rows = list(csv.DictReader(open(path, encoding="utf-8")))
pem_col = next(k for k in rows[0] if "PEM" in k)
name_col = next(k for k in ("Certificate Subject Common Name",
"Common Name or Certificate Name") if k in rows[0])
counts = {"rows": len(rows), "P-256": 0, "P-384": 0, "go_match": 0}
hits = []
for r in rows:
der = pem_to_der(r[pem_col])
if der is None:
continue
spki = spki_der(der)
spki_hash = hashlib.sha256(spki).hexdigest()
in_go = spki_hash in GO_DISTRUSTED
counts["go_match"] += in_go
curve, point = ec_public_point(spki)
if curve is None:
continue
counts[curve] += 1
d = tables[curve].get(point)
if d is not None or in_go:
hits.append((r[name_col], r.get("Certificate Issuer Common Name", ""),
curve, d, spki_hash[:16], in_go))
return counts, hits
if __name__ == "__main__":
tables = {}
for name, c in CURVES.items():
self_test(c)
tables[name] = small_key_table(c, LIMIT)
print(f"self-test passed; tables hold private keys 1..{LIMIT - 1} on P-256 and P-384")
for path in sys.argv[1:]:
counts, hits = scan(path, tables)
print(f"\n{path.split('/')[-1]}: {counts['rows']} rows, "
f"{counts['P-256']} P-256 keys, {counts['P-384']} P-384 keys, "
f"{counts['go_match']} certificates whose SPKI hash is in Go's list")
for subj, issuer, curve, d, h, in_go in sorted(hits):
size = f"{d.bit_length()}-bit key" if d else "not small"
print(f" {subj:<20} issuer={issuer:<26} {curve} {size:<10} spki={h}... in_go={in_go}")
curl -s -o ccadb.csv 'https://ccadb.my.salesforce-sites.com/mozilla/PublicIntermediateCertsRevokedWithPEMCSV'
curl -s -O 'https://ccadb.my.salesforce-sites.com/mozilla/PublicAllIntermediateCertsWithPEMCSV'
curl -s -O 'https://ccadb.my.salesforce-sites.com/mozilla/IncludedCACertificateReportPEMCSV'
mv PublicAllIntermediateCertsWithPEMCSV PublicAllIntermediateCertsWithPEMCSV.csv
mv IncludedCACertificateReportPEMCSV IncludedCACertificateReportPEMCSV.csv
python3 tinykey_check.py ccadb.csv PublicAllIntermediateCertsWithPEMCSV.csv IncludedCACertificateReportPEMCSV.csv
self-test passed; tables hold private keys 1..65535 on P-256 and P-384
ccadb.csv: 2977 rows, 114 P-256 keys, 243 P-384 keys, 16 certificates whose SPKI hash is in Go's list
EM DV TLS CA - G3B issuer=AAA Certificate Services P-384 8-bit key spki=ebed6475c2c2d80f... in_go=True
EM DV TLS CA - G3B issuer=emSign Root TLS CA - G3 P-384 8-bit key spki=ebed6475c2c2d80f... in_go=True
EM DV TLS CA - G3C issuer=AAA Certificate Services P-384 8-bit key spki=49dd353dc2b919a3... in_go=True
EM DV TLS CA - G3C issuer=emSign Root TLS CA - G3 P-384 8-bit key spki=49dd353dc2b919a3... in_go=True
EM EV TLS CA - G3B issuer=AAA Certificate Services P-384 7-bit key spki=e0254c7fcdc265e2... in_go=True
EM EV TLS CA - G3B issuer=emSign Root TLS CA - G3 P-384 7-bit key spki=e0254c7fcdc265e2... in_go=True
EM EV TLS CA - G3C issuer=AAA Certificate Services P-384 6-bit key spki=5c0660a2b164910d... in_go=True
EM EV TLS CA - G3C issuer=emSign Root TLS CA - G3 P-384 6-bit key spki=5c0660a2b164910d... in_go=True
EM OV CS CA - G3B issuer=AAA Certificate Services P-384 7-bit key spki=ec4ffa1ad0ac8808... in_go=True
EM OV CS CA - G3B issuer=emSign Root CS CA - G3 P-384 7-bit key spki=ec4ffa1ad0ac8808... in_go=True
EM OV CS CA - G3C issuer=AAA Certificate Services P-384 8-bit key spki=92c063d67e8f14db... in_go=True
EM OV CS CA - G3C issuer=emSign Root CS CA - G3 P-384 8-bit key spki=92c063d67e8f14db... in_go=True
EM OV TLS CA - G3B issuer=AAA Certificate Services P-384 8-bit key spki=43bffa19d6ef6514... in_go=True
EM OV TLS CA - G3B issuer=emSign Root TLS CA - G3 P-384 8-bit key spki=43bffa19d6ef6514... in_go=True
EM OV TLS CA - G3C issuer=AAA Certificate Services P-384 3-bit key spki=63ecefee431d4952... in_go=True
EM OV TLS CA - G3C issuer=emSign Root TLS CA - G3 P-384 3-bit key spki=63ecefee431d4952... in_go=True
PublicAllIntermediateCertsWithPEMCSV.csv: 2517 rows, 420 P-256 keys, 374 P-384 keys, 0 certificates whose SPKI hash is in Go's list
IncludedCACertificateReportPEMCSV.csv: 169 rows, 3 P-256 keys, 48 P-384 keys, 0 certificates whose SPKI hash is in Go's list
The whole run took about four seconds. Sixteen revoked certificates carry the eight keys, because each key was certified twice: once by eMudhra's own emSign root and once by Sectigo's AAA Certificate Services cross-sign. All eight of Go's hashes are there, and every one of the eight keys is between 3 and 8 bits long.
I print the bit length rather than the number. The smallest is EM OV TLS CA - G3C, whose private key is 4. That value is already public: mitmproxy's main branch has a new --emudhra option, listed under "Unreleased" in its changelog, that lets the proxy sign the certificates it generates with this key. I parsed the PEM from that commit with openssl pkey, and it is 47 zero bytes followed by 04, with an SPKI hash of 63ecefee..., the sixth entry in Go's map. The other seven values are just as easy to print. I left them out because there is no reason to copy them around.
The other two reports are the control group. Among the 794 EC keys in CCADB's all-intermediates report and the 51 EC roots in Mozilla's included-roots report, nothing has a private key under 65,536. The revoked list also holds a second batch of eMudhra cross-signs that Sectigo revoked on 21 October with the reason cessationOfOperation, and none of those keys is small either. This test catches one specific failure: a key that is a small integer. A key drawn from a broken random number generator would pass it.
The part that applies to your machine
Revoking an intermediate only protects clients that check revocation. OpenSSL's verify does not by default, and neither does Python's ssl module. Here is the G3C intermediate from the CCADB CSV, checked against the system bundle on the Ubuntu 24.04 container I ran all of this in. The certificate linking emSign Root TLS CA - G3 to emSign ECC Root CA - G3 is not in either CCADB intermediate report I downloaded, so I took it from the same mitmproxy commit.
BUNDLE=/etc/ssl/certs/ca-certificates.crt
# 1. Path validation only (openssl verify does not check CRLs by default)
openssl verify -CAfile "$BUNDLE" -untrusted emsign-root-tls-g3-cross.pem em-ov-tls-g3c.pem
# 2. Same chain, with the issuer's CRL checked
curl -s --retry 3 -A Mozilla/5.0 -o roottlscag3.crl 'http://crl.emsign.com/?roottlscag3.crl'
openssl crl -inform DER -in roottlscag3.crl -out roottlscag3.pem
openssl verify -CAfile "$BUNDLE" -untrusted emsign-root-tls-g3-cross.pem \
-crl_check -CRLfile roottlscag3.pem em-ov-tls-g3c.pem
em-ov-tls-g3c.pem: OK
C = US, O = eMudhra Inc, CN = EM OV TLS CA - G3C
error 23 at 0 depth lookup: certificate revoked
error em-ov-tls-g3c.pem: verification failed
Path validation alone says OK. The CRL says revoked. When I ran this, the CRL host answered curl's default user agent with a 403, so the script sends a different one.
Python's default context on the same container:
import ssl
ctx = ssl.create_default_context()
crl_flags = ssl.VERIFY_CRL_CHECK_LEAF | ssl.VERIFY_CRL_CHECK_CHAIN
print("CRL checking on by default:", bool(ctx.verify_flags & crl_flags))
for cert in ctx.get_ca_certs():
name = dict(rdn[0] for rdn in cert["subject"]).get("commonName", "")
if name.startswith("emSign"):
print("trusted root:", name)
CRL checking on by default: False
trusted root: emSign Root CA - C1
trusted root: emSign ECC Root CA - C3
trusted root: emSign Root CA - G1
trusted root: emSign ECC Root CA - G3
So on this container, a Python client with default settings trusted the root above the revoked intermediates and did not look at the CRL. The fix for that is the trust bundle, and different bundles took different routes. This script checks a bundle by the root's SPKI hash rather than its name:
import hashlib, re, sys
from tinykey_check import pem_to_der, spki_der
# SPKI SHA-256 of emSign ECC Root CA - G3, as listed in rustls/webpki-roots PR 133
G3_ROOT = "8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782"
for path in sys.argv[1:]:
pems = re.findall(r"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----",
open(path).read(), re.S)
hashes = {hashlib.sha256(spki_der(pem_to_der(p))).hexdigest() for p in pems}
print(f"{path}: {len(pems)} certificates, emSign ECC Root CA - G3 present: {G3_ROOT in hashes}")
for v in 2026.07.22 2026.10.18 2026.10.19; do
curl -s -o certifi-$v.pem https://raw.githubusercontent.com/certifi/python-certifi/$v/certifi/cacert.pem
done
python3 bundle_check.py /etc/ssl/certs/ca-certificates.crt certifi-2026.07.22.pem certifi-2026.10.18.pem certifi-2026.10.19.pem
/etc/ssl/certs/ca-certificates.crt: 121 certificates, emSign ECC Root CA - G3 present: True
certifi-2026.07.22.pem: 121 certificates, emSign ECC Root CA - G3 present: True
certifi-2026.10.18.pem: 117 certificates, emSign ECC Root CA - G3 present: False
certifi-2026.10.19.pem: 120 certificates, emSign ECC Root CA - G3 present: False
Three different answers:
- Ubuntu published ca-certificates
20260816ubuntu0.24.04.1to noble-security on 20 October. Its changelog blacklists emSign ECC Root CA - G3 "due to intermediate cert compromise". My container still had20260601~24.04.1because nobody had upgraded it since. The fix existed and it was not on the machine. - certifi 2026.10.18 removed all four emSign roots. Universities whose services chain to other emSign roots reported breakage in certifi issue 439, and 2026.10.19 put three back and kept G3 out. A maintainer explained in that thread why the first release went wider than the intermediates: "there's no facility for revoking an intermediate only."
- rustls webpki-roots 1.0.10 (PR 133) removed only G3 and printed its SPKI hash,
8d417db2...c782. That is the same hashbundle_check.pycomputes from Ubuntu's copy of the root, which is how I know the script is matching the right certificate.
What I am keeping from this
A root bundle is the only lever most clients have, and a machine can hold several copies: the OS package, a pinned certifi in a lockfile, a container image built before 20 October, and Go binaries built with a toolchain older than 1.26.10 or 1.27.3. Each needs its own update or rebuild. The SPKI-hash check above takes a few seconds per bundle.
The small-key test is cheap enough to run on any EC public key you generate yourself, as a smoke test. It would have flagged these eight on the day of the ceremony. It says nothing about keys that are bad in subtler ways.
And a valid signature tells you nothing about how the key behind it was made. Every chain here validated, and that was the problem.
Written with AI assistance from my own notes and test results. I checked every fact and command before publishing.
Top comments (0)