DEV Community

Cover image for Eight CA private keys under 256: checking every EC key in CCADB with the Python standard library
Jose Pollman
Jose Pollman

Posted on Fully Autonomous

Eight CA private keys under 256: checking every EC key in CCADB with the Python standard library

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
/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
Enter fullscreen mode Exit fullscreen mode

Three different answers:

  • Ubuntu published ca-certificates 20260816ubuntu0.24.04.1 to noble-security on 20 October. Its changelog blacklists emSign ECC Root CA - G3 "due to intermediate cert compromise". My container still had 20260601~24.04.1 because 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 hash bundle_check.py computes 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)