Phone to PC without the cloud: a local HTTPS server, a pinned certificate and mDNS
You take a photo on your phone and need it on your laptop, which is right there on the desk. On a Mac with an iPhone, AirDrop does it in two taps. On Windows, or with an Android phone, the photo goes up to someone's cloud, through a chat app or into an email to yourself, and back down again: two metres, by way of a data centre.
The phone and the computer are on the same Wi-Fi. They should be able to talk to each other directly, with no account and no server in between. That's the idea behind Dropple. It runs on Mac and Windows, and works with any phone. This post is about how it works, and about the parts that were harder than I expected.
The shape of it
The desktop app is built with Tauri 2, with a Rust backend. Inside it runs an axum server on the local network: plain HTTP on one port for first contact, and HTTPS (rustls) on another.
On the phone there are two ways in:
- Any phone's browser. You scan a QR code and the computer serves the whole phone side as a web page. Nothing gets installed, and it works on an iPhone too.
- A native Android app, which talks to the same API and stays connected in the background.
Files stream both ways. Text sent from the phone goes straight onto the computer's clipboard. Live updates go out to every connected phone as Server-Sent Events, which turned out much simpler than WebSockets for a list that only changes on one side at a time.
phone ──HTTPS──▶ axum (Rust, inside the Tauri app)
▲ │
└──── SSE /api/events ┘ "the list changed, fetch it"
Trust without a certificate authority
HTTPS on a home network is awkward. There's no domain name, so no public certificate.
Dropple makes its own certificate authority on first run (with rcgen) and uses it to issue a certificate for the computer's current IP address. When the address changes, it issues a new one. The authority is name-constrained to private addresses and .local, so even if it were trusted somewhere, it couldn't vouch for a real website.
A browser would need that authority installed before it trusts the connection, which is a real hurdle on a phone. The Android app gets around it by pinning instead:
1. The QR code carries the SHA-256 fingerprint of the computer's authority.
2. The app's trust manager accepts a connection only if the server's chain contains a certificate with exactly that fingerprint, and the server's own certificate was signed by it.
Nothing goes into the phone's trust store, there's nothing for the user to install, and a lookalike server on the same Wi-Fi gets nowhere. I've written up [the trust manager itself](#) separately.
Finding the computer again
Laptops change IP addresses. They join a different network, the router hands out a new lease, or the laptop wakes up from sleep.
The computer announces itself over mDNS as _dropple._tcp, with the start of its fingerprint in a TXT record. When the saved address stops answering, the Android app searches with Android's NSD and reconnects to the computer whose fingerprint matches. Connecting still checks the full fingerprint, so the TXT record only narrows the search; it never grants trust.
One Samsung phone turned this into a bug hunt. Its NSD results contained only an IPv6 link-local address (fe80::…%wlan0), which you can't put into an https:// URL. The app sat on "Connecting…" forever. Details in [the NSD post](#).
Staying connected on Android, politely
Receiving files in the background means a foreground service of type connectedDevice, with an ongoing notification. Two things made that bearable:
- Explaining before asking. Before Android shows its notification prompt, a short sheet says what the notifications are for. More people say yes, and nobody is surprised.
- An off switch for every annoying part. Users can hide the "Connected" notification's channel, or turn the background connection off entirely.
Android also won't let an app in the background read the clipboard, so "send what I just copied" needs a trick. A Quick Settings tile opens an invisible activity, which waits until it has window focus, reads the clipboard, sends it and closes. Write-up [here](#).
What I'd tell someone building something similar
- Pin, don't install. If you control both ends, pin the certificate rather than asking users to trust a new authority.
- Test discovery on more than one brand of phone. NSD results differ between them.
- Make reconnecting visible. A spinner with no words looks like a hang; "Looking for your computer…" doesn't. Users forgive waiting when they can see what's being waited for.
- Keep the last state. If the computer is out of reach, show the last list you had rather than an empty screen.
Sharing in Dropple is free, on Mac, Windows, any phone's browser, and Android through Google Play: scannertechs.com
Happy to answer questions about any of this in the comments.
1 reaction
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
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.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.
Mentions
@matthew_d_green rustls/upki may be part of it
If you pin webpki-roots, check the version: 1.0.9 still trusts the eMudhra root Apple told everyone to delete Context is the eMudhra / emSign CA compromise. Mozilla, Google, Microsoft and most distros dropped the roots over a week ago. Apple's macOS command line tools still trust them, which is what Hacker News is arguing about today. If you build on rustls you probably get your trust anchors from webpki-roots, compiled into the binary. I checked ours this afternoon (Mars, a browser engine we're writing from scratch, pinned webpki-roots = "=1.0.9" from July in two crates). Grepping src/lib.rs in each version: - 1.0.9 (Jul 18): emSign Root CA - G1, emSign Root CA - C1, emSign ECC Root CA - G3, emSign ECC Root CA - C3 - - 1.0.10 (Oct 19): removed emSign ECC Root CA - G3 - - 1.0.11 (Oct 23): removed emSign Root CA - C1 and emSign ECC Root CA - C3, tracking the CCADB removals So an exact pin, or a Cargo.lock nobody has touched since summer, means the binary still trusts the root Apple's support note tells people to delete by hand. rustls does no revocation checking unless you hand it CRLs, and there is no blocklist, so nothing at runtime tells you. Fix is cargo update -p webpki-roots and dropping any =1.0.9 pins. Ours is bumped and compiles. If you vendor roots another way, worth a grep for emSign. Release notes: github.com/rustls/webpki-roots/releases
Phone to PC without the cloud: a local HTTPS server, a pinned certificate and mDNS You take a photo on your phone and need it on your laptop, which is right there on the desk. On a Mac with an iPhone, AirDrop does it in two taps. On Windows, or with an Android phone, the photo goes up to someone's cloud, through a chat app or into an email to yourself, and back down again: two metres, by way of a data centre. The phone and the computer are on the same Wi-Fi. They should be able to talk to each other directly, with no account and no server in between. That's the idea behind Dropple. It runs on Mac and Windows, and works with any phone. This post is about how it works, and about the parts that were harder than I expected. The shape of it The desktop app is built with Tauri 2, with a Rust backend. Inside it runs an axum server on the local network: plain HTTP on one port for first contact, and HTTPS (rustls) on another. On the phone there are two ways in: - Any phone's browser. You scan a QR code and the computer serves the whole phone side as a web page. Nothing gets installed, and it works on an iPhone too. - A native Android app, which talks to the same API and stays connected in the background. Files stream both ways. Text sent from the phone goes straight onto the computer's clipboard. Live updates go out to every connected phone as Server-Sent Events, which turned out much simpler than WebSockets for a list that only changes on one side at a time. phone ──HTTPS──▶ axum (Rust, inside the Tauri app) ▲ │ └──── SSE /api/events ┘ "the list changed, fetch it" Trust without a certificate authority HTTPS on a home network is awkward. There's no domain name, so no public certificate. Dropple makes its own certificate authority on first run (with rcgen) and uses it to issue a certificate for the computer's current IP address. When the address changes, it issues a new one. The authority is name-constrained to private addresses and .local, so even if it were trusted somewhere, it couldn't vouch for a real website. A browser would need that authority installed before it trusts the connection, which is a real hurdle on a phone. The Android app gets around it by pinning instead: 1. The QR code carries the SHA-256 fingerprint of the computer's authority. 2. The app's trust manager accepts a connection only if the server's chain contains a certificate with exactly that fingerprint, and the server's own certificate was signed by it. Nothing goes into the phone's trust store, there's nothing for the user to install, and a lookalike server on the same Wi-Fi gets nowhere. I've written up [the trust manager itself](#) separately. Finding the computer again Laptops change IP addresses. They join a different network, the router hands out a new lease, or the laptop wakes up from sleep. The computer announces itself over mDNS as _dropple._tcp, with the start of its fingerprint in a TXT record. When the saved address stops answering, the Android app searches with Android's NSD and reconnects to the computer whose fingerprint matches. Connecting still checks the full fingerprint, so the TXT record only narrows the search; it never grants trust. One Samsung phone turned this into a bug hunt. Its NSD results contained only an IPv6 link-local address (fe80::…%wlan0), which you can't put into an https:// URL. The app sat on "Connecting…" forever. Details in [the NSD post](#). Staying connected on Android, politely Receiving files in the background means a foreground service of type connectedDevice, with an ongoing notification. Two things made that bearable: - Explaining before asking. Before Android shows its notification prompt, a short sheet says what the notifications are for. More people say yes, and nobody is surprised. - An off switch for every annoying part. Users can hide the "Connected" notification's channel, or turn the background connection off entirely. Android also won't let an app in the background read the clipboard, so "send what I just copied" needs a trick. A Quick Settings tile opens an invisible activity, which waits until it has window focus, reads the clipboard, sends it and closes. Write-up [here](#). What I'd tell someone building something similar - Pin, don't install. If you control both ends, pin the certificate rather than asking users to trust a new authority. - Test discovery on more than one brand of phone. NSD results differ between them. - Make reconnecting visible. A spinner with no words looks like a hang; "Looking for your computer…" doesn't. Users forgive waiting when they can see what's being waited for. - Keep the last state. If the computer is out of reach, show the last list you had rather than an empty screen. Sharing in Dropple is free, on Mac, Windows, any phone's browser, and Android through Google Play: scannertechs.com Happy to answer questions about any of this in the comments.
@grhmc idea: plugin for rustls/upki that creates a virtual file system for things that dont integrate with it
@greatplainswest @tqbf rustls/upki is a project to do exactly this.
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 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.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.
also new languages adding support for distrusting specific certs node#66871 webpki#546 because distrusting entire root would be.. troublesome. can see this getting standardized to something like /etc/ssl/certs/distrust.txt or something
this is some work in that direction rustls/upki
[ANNOUNCEMENT] Security Advisory for ca-certificates package The Termux ca-certificates and ca-certificates-java package versions 1:2026.10.20 have been released to revoke the compromised eMudhra intermediate certificate authority keys. If you use glibc repo, update the ca-certificates-glibc package as well. Check emudhra.we-have-a-problem.com for more info. Users are advised to immediately update to the fixed version 1:2026.10.20. An attacker with the private key of any of the compromised intermediate CAs could impersonate any website or service. You can run pkg install ca-certificates to update the package (or apt upgrade && apt install ca-certificates). If your packages repository mirror hasn't been synced yet and does not show the 1:2026.10.20 version, you can try other mirrors by using termux-change-repo. The default cloudflare or the primary Termux repository packages.termux.dev under single mirrors should have the required version. Android chrome browser seems to have revoked the certificates remotely. Android firefox browser is currently still affected, but releases are underway. Android apps that use Android system certificates should also be affected. The emSign ECC Root CA - G3 certificate and other emSign certificates can be disabled from Android Settings -> Trusted Credentials -> System. Some orgs are disabling emSign ECC Root CA - G1 as well, firefox and chrome currently are not (1, 2). Android system certificates include 4 certificates by emSign, only G3 would need to be disabled to be consistent with chrome/firefox, but you can disable all 4. On (older) Android versions, the Comodo CA Limited AAA Certificate Services certificate should also be disabled if it is listed in trusted credentials. Android should list 3 other credentials from Comodo which are safe. In the "Are there workarounds for Linux?" section of emudhra.we-have-a-problem.com, the "Comodo_AAA_Services_root" certificate is also disabled for Ubuntu and Debian. Check gist.github.com for why. The AAA Certificate Services was also distrusted long ago for other reasons and likely removed in newer Android versions. (1) See also: - bugzilla.mozilla.org - github.com/termux/termux-packages/commit/7aa4ba2e… - github.com/termux/glibc-packages/commit/75fa9e0c0… - python-certifi#437 - webpki-roots#133 ~ $ curl https://dv-g3b-emudhra.we-have-a-problem.com/ Your client successfully connected to the site. That's bad! ~ $ curl https://dv-g3b-aaa-emudhra.we-have-a-problem.com/ curl: (60) SSL certificate OpenSSL verify result: unable to get local issuer certificate (20) More details here: https://curl.se/docs/sslcerts.html curl failed to verify the legitimacy of the server and therefore could not establish a secure connection to it. To learn more about this situation and how to fix it, please visit the webpage mentioned above. ~ $ apt show ca-certificates Package: ca-certificates Version: 1:2026.08.13 Maintainer: @termux Installed-Size: 238 kB Homepage: https://curl.se/docs/caextract.html Download-Size: 105 kB APT-Sources: https://packages.termux.dev/apt/termux-main stable/main aarch64 Packages Description: Common CA certificates ~ $ apt update ~ $ apt install ca-certificates Reading package lists... Done Building dependency tree... Done Reading state information... Done The following additional packages will be installed: ca-certificates-java The following packages will be upgraded: ca-certificates ca-certificates-java Need to get 205 kB of archives. After this operation, 4096 B of additional disk space will be used. Do you want to continue? [Y/n] y Get:1 https://packages.termux.dev/apt/termux-main stable/main aarch64 ca-certificates-java all 1:2 026.10.20 [95.1 kB] Get:2 https://packages.termux.dev/apt/termux-main stable/main aarch64 ca-certificates all 1:2026.1 0.20 [110 kB] ... Setting up ca-certificates (1:2026.10.20) ... Setting up ca-certificates-java (1:2026.10.20) ... ~ $ curl https://dv-g3b-emudhra.we-have-a-problem.com/ curl: (60) SSL certificate OpenSSL verify result: unable to get local issuer certificate (20) More details here: https://curl.se/docs/sslcerts.html curl failed to verify the legitimacy of the server and therefore could not establish a secure connection to it. To learn more about this situation and how to fix it, please visit the webpage mentioned above. ~ $ curl https://dv-g3b-emudhra.we-have-a-problem.com/ curl: (60) SSL certificate OpenSSL verify result: unable to get local issuer certificate (20) More details here: https://curl.se/docs/sslcerts.html curl failed to verify the legitimacy of the server and therefore could not establish a secure connection to it. To learn more about this situation and how to fix it, please visit the webpage mentioned above.
More on upki: discourse.ubuntu.com My interest in building OneCRL support into upki has definitely increased this week. Issue: upki#229 I'm hoping some organizations will step forward to fund work on this. (Note that curl on Ubuntu 26.10 now has CRLite support baked via upki -- unfortunately that doesn't help with this because AIUI CRLite is only for end entities, not for intermediate certificates.)