Technical writing
How Voidly measures TLS-layer censorship: handshake forensics, certificate chain validation, and MITM detection
DNS manipulation is the noisiest form of censorship: the forged NXDOMAIN or bogon IP is visible to every resolver that queries the domain, detectable by any measurement system that runs a dual-resolver comparison, and well-documented in the academic literature going back to 2012. Governments that block only at DNS are detectable by a one-line shell script. TLS-layer interference is different. The censor operates inside an encrypted channel — or, more precisely, at the edges of one — with at least four distinct interference techniques that each require different detection methods and produce different observables. A naive measurement system that only checks whether a domain returns HTTP 200 will misattribute TLS interference as “connection refused” and lose the forensic signal that distinguishes a government MITM from a self-signed certificate error.
This article covers the TLS measurement layer inside Voidly in full: the data structures that capture handshake outcomes, the certificate chain validation logic that operates independently of the OS trust store, the MITM detection algorithm, the alert timing classifier that distinguishes injected resets from server-initiated failures, the dual-SNI test that detects SNI-based blocking without certificate injection, and what ECH adoption means for this entire class of measurement. The DNS layer that precedes these measurements is documented in How Voidly measures DNS-layer censorship; the HTTP layer that follows is in How Voidly measures HTTP and HTTPS censorship.
Why TLS is the second-hardest censorship layer
DNS manipulation is trivially detectable because the forged answer is in the clear. TCP RST injection is detectable because a RST from an IP that the server does not control is straightforwardly anomalous. TLS interference is subtler because the protocol has legitimate failure modes that look identical to censorship from the outside.
There are four distinct techniques censors use at the TLS layer, each requiring a different detection method:
- Pure connection drop after ClientHello. The censor reads the unencrypted Server Name Indication (SNI) field in the TLS ClientHello and injects a TCP RST or simply drops the connection before the server can send a ServerHello. The probe sees a TLS handshake that starts but never completes — no certificate is exchanged, no TLS alert is sent, the connection just dies. This is indistinguishable from a network glitch at the packet level, which is why timing analysis (Section 5) and dual-SNI comparison (Section 6) are needed to attribute it.
- Forged certificate signed by a government CA trusted in the OS store. The censor terminates the TLS session and presents its own certificate, signed by a root CA that it has distributed to OS trust stores inside its jurisdiction. The probe's OS completes the handshake successfully — the certificate validates — but the certificate it received is not the one the real server would have sent. This is the most sophisticated form of TLS censorship and the most dangerous for users, because it enables full content inspection. Kazakhstan's national CA (NCA) is the documented example: the government required ISPs to install it as a trusted root, enabling MITM of all HTTPS traffic.
- TLS alert code returned without application data. The censor (or the server, acting under legal pressure) returns a TLS fatal alert — typically
handshake_failure(40) orunrecognized_name(112) — to terminate the handshake before the certificate is exchanged. This is unambiguous: a TLS fatal alert received before the ServerHello is completed with no prior application data is not a legitimate server behavior for a properly configured server that the control probe reaches successfully. - Successful TLS handshake, blocked HTTP request inside. The TLS layer completes normally, the certificate is the correct one, but the HTTP request inside the TLS tunnel returns a block page or a reset. This is HTTP-layer censorship that happens to occur over HTTPS — it is documented in the HTTP measurement article — but correctly attributing it requires first confirming the TLS handshake succeeded.
The measurement system has to handle all four modes. A single boolean “TLS failed” field in the result struct loses the forensic distinction between a RST-after-ClientHello (SNI filtering, no cert injection) and a MITM cert (full traffic interception). The TlsResult struct captures all the observables needed to distinguish them.
The TLS measurement sequence and the TlsResult struct
After TCP connect succeeds, the probe initiates a TLS 1.3 ClientHello to the target IP with the correct SNI for the domain under test. The probe captures the full handshake interaction: round-trip time from ClientHello sent to ServerHello received, the complete certificate chain, whether the handshake completed successfully, and if not, the exact TLS alert code returned.
use rustls::{ClientConfig, ClientConnection, RootCertStore};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Complete TLS measurement result for one probe attempt.
#[derive(Debug, Serialize, Deserialize)]
pub struct TlsResult {
// Handshake outcome
pub handshake_complete: bool,
pub negotiated_version: Option<TlsVersion>, // Tls12 | Tls13
pub cipher_suite: Option<u16>,
pub sni_sent: String, // SNI value placed in ClientHello
// Timing
pub client_hello_sent_ms: u64, // epoch-ms when ClientHello was written
pub server_hello_ms: Option<u64>,// epoch-ms when ServerHello was received
pub handshake_rtt_ms: Option<u32>,// server_hello_ms - client_hello_sent_ms
pub handshake_timeout: bool, // true if no response within 10 seconds
// Certificate chain — populated even on failed handshakes if any certs arrived
pub cert_chain: Vec<CertInfo>,
pub cert_chain_valid: bool, // chain validates mathematically (signatures only)
pub cert_mozilla_trusted: bool, // issuer chain leads to Mozilla trusted root
pub cert_matches_sni: bool, // leaf cert CN or SAN matches sni_sent
pub government_ca_detected: bool,// issuer appears in GOVERNMENT_CA_LIST
pub government_ca_name: Option<String>,
// Failure details
pub handshake_error: Option<TlsHandshakeError>,
pub alert_code: Option<u8>, // TLS alert code if fatal alert received
pub alert_received_ms: Option<u64>, // epoch-ms when alert arrived
pub rst_during_handshake: bool, // TCP RST received during TLS handshake
pub rst_received_ms: Option<u64>,// epoch-ms when RST arrived
// ECH / ESNI
pub ech_supported: bool, // HTTPS DNS record advertised ECH config
pub ech_used: bool, // this attempt used ECH
}
/// Per-certificate info extracted from the DER-encoded certificate.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CertInfo {
pub subject_cn: String,
pub issuer_cn: String,
pub issuer_org: Option<String>,
pub sha256_fingerprint: [u8; 32],
pub not_before: i64, // Unix timestamp
pub not_after: i64, // Unix timestamp
pub san_domains: Vec<String>,
pub is_self_signed: bool,
pub position: u8, // 0 = leaf, 1 = intermediate, 2+ = root
}
/// Structured TLS handshake failure, distinct from the raw alert code.
#[derive(Debug, Serialize, Deserialize)]
pub enum TlsHandshakeError {
Timeout,
AlertHandshakeFailure, // alert code 40
AlertBadCertificate, // alert code 42
AlertIllegalParameter, // alert code 47
AlertDecodeError, // alert code 50
AlertProtocolVersion, // alert code 70
AlertBadRecordMac, // alert code 20
AlertUnrecognizedName, // alert code 112 (SNI not found)
CertVerificationFailed, // OS or rustls rejected the cert chain
ConnectionResetDuring, // TCP RST during handshake
Other(String),
}The millisecond timestamps on client_hello_sent_ms, alert_received_ms, and rst_received_ms are what enable the timing analysis in Section 5. Recording all three as absolute epoch timestamps (rather than a derived RTT) means the collector can reconstruct the relative sequence of events regardless of when the probe uploaded the result.
Certificate chain validation
Voidly validates the certificate chain on three independent axes, deliberately separated from the OS trust store so that a government CA that has been distributed to OS stores inside a jurisdiction does not cause the probe to silently accept a forged certificate.
The three checks are:
- Mathematical validity (
cert_chain_valid): whether each certificate's signature is valid under its issuer's public key. This check has nothing to do with trust — a chain of three self-signed certificates with valid internal signatures passes this check. It catches garbled or truncated certificate data. - Mozilla trusted root membership (
cert_mozilla_trusted): whether the issuer chain leads to a root in Mozilla's trusted root list (the Mozilla CA Certificate Program). This is the check the OS would perform, but run independently using the webpki crate's root store, which is pinned to the Mozilla bundle shipped with the Voidly probe binary. A government CA that was distributed via OS policy but is not in Mozilla's list will fail this check. - Government CA detection (
government_ca_detected): whether any certificate in the chain was issued by or is itself a known government or surveillance CA. This check fires even when the chain is otherwise valid.
The government CA list is maintained in the probe source and updated whenever a new state-operated CA is documented. Current entries:
/// Known government and surveillance certificate authorities.
/// Checked against the issuer_cn and issuer_org fields of every cert in the chain.
/// Updated as new state-operated CAs are documented.
pub const GOVERNMENT_CA_LIST: &[GovernmentCa] = &[
GovernmentCa {
name: "China MoI CA",
country: "CN",
issuer_cn_substrings: &[
"CNNIC",
"China Internet Network Information Center",
"Ministry of Industry and Information Technology",
"MIIT",
"WoSign", // de-trusted by browsers 2016; still seen in MITM probes
],
notes: "CNNIC root de-trusted by major browsers in 2015 after unauthorized cert issuance incident. WoSign de-trusted 2016. Still distributed via Windows Update in China.",
},
GovernmentCa {
name: "Iran MICT CA",
country: "IR",
issuer_cn_substrings: &[
"Iran",
"MICT",
"Ministry of ICT",
"Telecommunication Infrastructure Company",
"TIC",
"Pars Online",
],
notes: "Iran's Telecommunication Infrastructure Company operates the national filtering gateway. Multiple subordinate CAs documented in MITM intercepts from 2011 onward.",
},
GovernmentCa {
name: "Kazakhstan National CA (NCA)",
country: "KZ",
issuer_cn_substrings: &[
"Qaznet",
"NCA",
"National Certification Authority",
"Information Security Committee",
"Kazakhstan",
],
notes: "Government of Kazakhstan attempted mandatory installation of the NCA root certificate in 2019 and again in 2020. ISPs were directed to redirect HTTP/HTTPS traffic to a 'security certificate' installation page. Major browsers (Chrome, Firefox, Safari) added the NCA to their blocklists rather than trust stores. Voidly probes in KZ still see NCA certs on some ISPs for targeted domains.",
},
];
pub struct GovernmentCa {
pub name: &'static str,
pub country: &'static str,
pub issuer_cn_substrings: &'static [&'static str],
pub notes: &'static str,
}
/// Check all certs in a chain against the government CA list.
pub fn check_government_ca(chain: &[CertInfo]) -> Option<&'static str> {
for cert in chain {
for ca in GOVERNMENT_CA_LIST {
for substring in ca.issuer_cn_substrings {
if cert.issuer_cn.to_lowercase().contains(&substring.to_lowercase()) {
return Some(ca.name);
}
if let Some(ref org) = cert.issuer_org {
if org.to_lowercase().contains(&substring.to_lowercase()) {
return Some(ca.name);
}
}
}
}
}
None
}Extracting the certificate chain with rustls requires accessing the raw DER-encoded certificates from the handshake before they are consumed by the verifier. The probe uses a custom ServerCertVerifier implementation that captures the chain before delegating to the standard verifier:
use rustls::client::ServerCertVerifier;
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{DigitallySignedStruct, SignatureScheme};
use std::sync::{Arc, Mutex};
use x509_parser::prelude::*;
pub struct CapturingVerifier {
inner: Arc<dyn ServerCertVerifier>,
captured: Arc<Mutex<Vec<CertInfo>>>,
}
impl ServerCertVerifier for CapturingVerifier {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>],
server_name: &ServerName<'_>,
ocsp_response: &[u8],
now: UnixTime,
) -> Result<rustls::client::ServerCertVerified, rustls::Error> {
// Extract chain info before delegating to the real verifier
let mut chain = Vec::new();
let all_certs: Vec<&CertificateDer<'_>> =
std::iter::once(end_entity).chain(intermediates).collect();
for (position, der) in all_certs.iter().enumerate() {
if let Ok((_, cert)) = X509Certificate::from_der(der.as_ref()) {
let sha256 = {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(der.as_ref());
h.finalize().into()
};
let san_domains: Vec<String> = cert
.subject_alternative_name()
.ok()
.flatten()
.map(|ext| {
ext.value.general_names.iter()
.filter_map(|n| match n {
GeneralName::DNSName(d) => Some(d.to_string()),
_ => None,
})
.collect()
})
.unwrap_or_default();
chain.push(CertInfo {
subject_cn: cert.subject().to_string(),
issuer_cn: cert.issuer().to_string(),
issuer_org: None, // extracted separately from issuer RDN
sha256_fingerprint: sha256,
not_before: cert.validity().not_before.timestamp(),
not_after: cert.validity().not_after.timestamp(),
san_domains,
is_self_signed: cert.subject() == cert.issuer(),
position: position as u8,
});
}
}
*self.captured.lock().unwrap() = chain;
// Delegate to the real verifier (Mozilla roots via webpki)
self.inner.verify_server_cert(
end_entity, intermediates, server_name, ocsp_response, now,
)
}
fn verify_tls12_signature(&self, m: &[u8], c: &rustls::pki_types::CertificateDer,
d: &DigitallySignedStruct) -> Result<rustls::client::HandshakeSignatureValid, rustls::Error> {
self.inner.verify_tls12_signature(m, c, d)
}
fn verify_tls13_signature(&self, m: &[u8], c: &rustls::pki_types::CertificateDer,
d: &DigitallySignedStruct) -> Result<rustls::client::HandshakeSignatureValid, rustls::Error> {
self.inner.verify_tls13_signature(m, c, d)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.inner.supported_verify_schemes()
}
}MITM detection via certificate fingerprinting
Many censorship systems install a custom root CA and sign block-page certificates with it. When a probe connects to a MITM-intercepted HTTPS endpoint, the certificate it receives will differ from what the control server saw along at least one of three axes: a different SHA-256 fingerprint, a subject CN that does not match the requested domain, or issuance by a known government CA. The detect_tls_mitm() function combines these signals into a structured verdict.
from dataclasses import dataclass
from typing import Optional
import hashlib
@dataclass
class CertInfo:
subject_cn: str
issuer_cn: str
sha256_fingerprint: bytes # 32 bytes
not_before: int # Unix timestamp
not_after: int
san_domains: list[str]
is_self_signed: bool
@dataclass
class MitmVerdict:
is_mitm: bool
confidence: float # 0.0 – 1.0
reasons: list[str] # human-readable signals that fired
fingerprint_mismatch: bool # probe cert != control cert fingerprint
cn_mismatch: bool # leaf cert CN/SAN doesn't match requested domain
government_ca: bool # issuer matches GOVERNMENT_CA_LIST
self_signed: bool # leaf cert is self-signed (no issuer chain)
not_mozilla_trusted: bool # issuer chain not in Mozilla root store
# Government CA substrings — kept in sync with the Rust GOVERNMENT_CA_LIST
_GOVERNMENT_CA_SUBSTRINGS: list[str] = [
# China
'cnnic', 'china internet network information', 'miit',
'ministry of industry and information technology', 'wosign',
# Iran
'iran', 'mict', 'ministry of ict',
'telecommunication infrastructure company', 'tic', 'pars online',
# Kazakhstan
'qaznet', ' nca', 'national certification authority',
'information security committee', 'kazakhstan',
]
def _issuer_is_government(issuer_cn: str) -> bool:
lower = issuer_cn.lower()
return any(sub in lower for sub in _GOVERNMENT_CA_SUBSTRINGS)
def _cert_matches_domain(cert: CertInfo, domain: str) -> bool:
"""Return True if the cert's CN or SAN covers the given domain."""
domain = domain.lower().lstrip('www.')
cn = cert.subject_cn.lower()
if cn.lstrip('*.') == domain or cn == domain:
return True
for san in cert.san_domains:
san = san.lower()
if san == domain:
return True
# Wildcard SAN: *.example.com covers sub.example.com
if san.startswith('*.') and domain.endswith(san[1:]):
return True
return False
def detect_tls_mitm(
probe_chain: list[CertInfo],
control_fingerprint: Optional[bytes], # SHA-256 of the control's leaf cert
requested_domain: str,
mozilla_trusted: bool, # from probe's independent chain check
) -> MitmVerdict:
"""
Detect TLS MITM from the certificate chain the probe received.
probe_chain: cert chain captured by the CapturingVerifier
control_fingerprint: SHA-256 of the leaf cert the control server observed,
or None if the control server also failed the handshake
requested_domain: SNI value sent in ClientHello (the domain under test)
mozilla_trusted: whether the webpki verifier accepted the chain against
the Mozilla root bundle
"""
if not probe_chain:
return MitmVerdict(
is_mitm=False, confidence=0.0, reasons=['no certificate received'],
fingerprint_mismatch=False, cn_mismatch=False, government_ca=False,
self_signed=False, not_mozilla_trusted=False,
)
leaf = probe_chain[0]
reasons: list[str] = []
signal_count = 0
# Signal 1: fingerprint mismatch against control server's cert
fingerprint_mismatch = False
if control_fingerprint is not None:
if leaf.sha256_fingerprint != control_fingerprint:
fingerprint_mismatch = True
signal_count += 2 # strong signal — same IP, different cert
reasons.append(
f'leaf cert fingerprint differs from control '
f'({leaf.sha256_fingerprint.hex()[:16]}… vs {control_fingerprint.hex()[:16]}…)'
)
# Signal 2: subject CN / SAN does not match the requested domain
cn_mismatch = not _cert_matches_domain(leaf, requested_domain)
if cn_mismatch:
signal_count += 2
reasons.append(
f'cert CN "{leaf.subject_cn}" does not cover domain "{requested_domain}"'
)
# Signal 3: any cert in chain issued by a known government CA
government_ca = any(_issuer_is_government(c.issuer_cn) for c in probe_chain)
if government_ca:
signal_count += 3 # very strong signal
gov_issuers = [c.issuer_cn for c in probe_chain if _issuer_is_government(c.issuer_cn)]
reasons.append(f'government CA in chain: {gov_issuers[0]}')
# Signal 4: self-signed leaf cert (no issuer chain)
self_signed = leaf.is_self_signed
if self_signed:
signal_count += 1
reasons.append('leaf certificate is self-signed')
# Signal 5: not in Mozilla trusted root bundle
not_mozilla_trusted = not mozilla_trusted
if not_mozilla_trusted and not self_signed:
signal_count += 1
reasons.append('issuer chain does not lead to a Mozilla trusted root')
# MITM verdict: fire if any strong signal (government CA, CN mismatch +
# fingerprint mismatch together) or multiple weaker signals
is_mitm = (
government_ca
or (fingerprint_mismatch and cn_mismatch)
or signal_count >= 3
)
# Confidence: clip to [0.0, 1.0], scale by number and weight of signals
confidence = min(1.0, signal_count * 0.25) if is_mitm else 0.0
return MitmVerdict(
is_mitm=is_mitm,
confidence=confidence,
reasons=reasons,
fingerprint_mismatch=fingerprint_mismatch,
cn_mismatch=cn_mismatch,
government_ca=government_ca,
self_signed=self_signed,
not_mozilla_trusted=not_mozilla_trusted,
)TLS alert timing analysis
When a TLS handshake fails, the failure timing tells you who terminated it. A legitimate server responds to a ClientHello after a full network round-trip: it receives the ClientHello, processes it, and sends the ServerHello or a TLS alert. A censor that is injecting a response based on the SNI field alone can respond much faster, because it does not need to forward the packet to the actual server and wait for a reply — it synthesizes the response locally.
Voidly uses two timing thresholds derived from empirical measurement across the probe network:
| Timing observation | Threshold | Classification |
|---|---|---|
| TCP RST arrives after ClientHello sent | < 15 ms | Injected RST — faster than any real server round-trip; middlebox synthesized |
| TLS alert arrives after ClientHello sent, no prior application data | < 30 ms | SNI-triggered alert — censor inspected ClientHello and responded before server could |
| RST or alert arrives after ClientHello sent | 30 ms – 400 ms | Ambiguous — within real server RTT range; requires corroborating signals |
| TLS alert arrives after ServerHello and cert exchange | Any | Server-initiated alert — server inspected the ClientHello and rejected at a later stage |
from enum import Enum
from dataclasses import dataclass
from typing import Optional
class TlsFailureClass(Enum):
RST_INJECTED = 'rst_injected' # sub-15ms RST, censor origin
ALERT_INJECTED = 'alert_injected' # sub-30ms alert, no server data, SNI-triggered
AMBIGUOUS = 'ambiguous' # timing in legitimate server range
SERVER_INITIATED = 'server_initiated' # alert after server data exchange
TIMEOUT = 'timeout' # no response within 10 seconds
HANDSHAKE_OK = 'handshake_ok' # TLS completed successfully
RST_INJECTION_THRESHOLD_MS = 15
ALERT_INJECTION_THRESHOLD_MS = 30
@dataclass
class TlsTimingInput:
client_hello_sent_ms: int # epoch-ms
server_hello_ms: Optional[int] # epoch-ms, None if no ServerHello received
alert_received_ms: Optional[int]# epoch-ms, None if no alert
rst_received_ms: Optional[int] # epoch-ms, None if no RST
application_data_exchanged: bool# True if any app data (HTTP) flowed after handshake
def classify_tls_failure(timing: TlsTimingInput) -> TlsFailureClass:
"""
Classify a TLS handshake outcome using timing to distinguish
injected failures from server-initiated ones.
"""
t0 = timing.client_hello_sent_ms
# RST during handshake
if timing.rst_received_ms is not None:
rtt_to_rst = timing.rst_received_ms - t0
if rtt_to_rst < RST_INJECTION_THRESHOLD_MS:
return TlsFailureClass.RST_INJECTED
return TlsFailureClass.AMBIGUOUS
# TLS alert received
if timing.alert_received_ms is not None:
rtt_to_alert = timing.alert_received_ms - t0
server_hello_arrived = timing.server_hello_ms is not None
# Alert arrived before any server data — sub-threshold implies injection
if not server_hello_arrived and not timing.application_data_exchanged:
if rtt_to_alert < ALERT_INJECTION_THRESHOLD_MS:
return TlsFailureClass.ALERT_INJECTED
return TlsFailureClass.AMBIGUOUS
# Alert arrived after ServerHello or application data — server-initiated
return TlsFailureClass.SERVER_INITIATED
# No response at all
if timing.server_hello_ms is None and timing.alert_received_ms is None:
return TlsFailureClass.TIMEOUT
# Handshake completed
return TlsFailureClass.HANDSHAKE_OKThe 15 ms and 30 ms thresholds are conservative. In practice, the fastest legitimate server response from any probe location in the Voidly network is around 40 ms (probes in Germany reaching servers in nearby EU data centers). Sub-15 ms RSTs are never legitimate server responses in the measurement data — they have zero overlap with the real-server RTT distribution. The 30 ms alert threshold has a small overlap region (28–35 ms) where some nearby servers could theoretically respond, which is why the AMBIGUOUS class exists and the classifier requires corroborating signals before promoting anAMBIGUOUS outcome to tls_interference.
SNI-based blocking without certificate injection
Russia's TSPU (the “sovereign internet” deep packet inspection infrastructure deployed at IXPs under Federal Law 149-FZ) blocks by SNI but does not always inject a substitute certificate. Instead, the TSPU device reads the ClientHello SNI and terminates the connection — RST injection or a synthetic TLS alert — without forwarding to the server. The probe sees a failed TLS handshake for the target domain, but the IP is otherwise reachable.
The key diagnostic is that the block is SNI-specific, not IP-specific. If the same IP is reachable when the probe uses a different SNI, the block is at the TLS SNI layer, not at the IP or TCP layer. Voidly detects this pattern with a dual-SNI test:
use std::net::SocketAddr;
use std::time::Duration;
/// Result of a dual-SNI probe: one TLS attempt with the target domain's SNI,
/// one with a control SNI to the same IP.
#[derive(Debug, Serialize, Deserialize)]
pub struct DualSniResult {
pub target_ip: SocketAddr,
/// Attempt with the target domain's SNI (e.g. "twitter.com")
pub target_sni: String,
pub target_result: TlsResult,
/// Attempt with a control SNI to the same IP (e.g. "example.com")
/// The control domain is chosen to be a domain that:
/// 1. Is not blocked in the target country
/// 2. Is not hosted on the same server as the target (to avoid shared-cert hits)
/// 3. Will produce a cert CN mismatch (expected, ignored for this probe)
pub control_sni: String,
pub control_result: TlsResult,
/// Derived: target_sni failed, control_sni to same IP succeeded
pub sni_specific_block: bool,
/// TSPU-pattern match: sub-30ms alert or RST on target + success on control
pub tspu_signature: bool,
}
pub async fn run_dual_sni_probe(
target_ip: SocketAddr,
target_domain: &str,
control_domain: &str, // e.g. "example.com" — known unblocked, same or nearby IP
timeout: Duration,
) -> DualSniResult {
// Run both attempts concurrently to the same IP
let (target_result, control_result) = tokio::join!(
tls_handshake(target_ip, target_domain, timeout),
tls_handshake(target_ip, control_domain, timeout),
);
// SNI-specific block: target failed, control to same IP succeeded
let sni_specific_block = !target_result.handshake_complete
&& control_result.handshake_complete;
// TSPU signature: sni_specific_block + sub-threshold timing on target failure
let tspu_signature = sni_specific_block && {
let failure_class = classify_tls_failure(TlsTimingInput {
client_hello_sent_ms: target_result.client_hello_sent_ms,
server_hello_ms: target_result.server_hello_ms,
alert_received_ms: target_result.alert_received_ms,
rst_received_ms: target_result.rst_received_ms,
application_data_exchanged: false,
});
matches!(failure_class,
TlsFailureClass::RST_INJECTED | TlsFailureClass::ALERT_INJECTED
)
};
DualSniResult {
target_ip,
target_sni: target_domain.to_string(),
target_result,
control_sni: control_domain.to_string(),
control_result,
sni_specific_block,
tspu_signature,
}
}
/// The control SNI selection algorithm: pick a domain that shares the IP's ASN
/// (typically a large CDN or cloud provider) but is not blocked in the target country.
/// Returns a domain string from a hardcoded stable list.
pub fn select_control_sni(target_asn: u32, probe_cc: &str) -> &'static str {
// Cloudflare-hosted domains stable for this purpose across ASN 13335
if target_asn == 13335 {
return "cloudflare.com";
}
// Akamai-hosted stable control
if target_asn == 20940 || target_asn == 16625 {
return "akamai.com";
}
// Generic fallback — always resolvable, not blocked in any documented case
"example.com"
}The dual-SNI test adds a second TLS handshake per probe attempt, increasing measurement time by a median of 42 ms. It is only run when the initial TLS attempt fails — not for every domain on every cycle — keeping overhead manageable. In the current dataset, tspu_signature = true appears in 71% of Russian TLS failures for TSPU-enforced domains, compared to 8% for Russian TLS failures on non-enforced domains (where the failure is more likely a server or network issue).
ESNI/ECH and how it affects measurement
Encrypted Client Hello (ECH, RFC draft — formerly ESNI) hides the SNI from network observers by encrypting the ClientHello payload using a public key published in the domain's DNS HTTPS record. When ECH is in use, a censor reading packets at the network layer sees only the “outer” SNI — a generic cover domain like cloudflare-ech.com — and cannot determine which domain is actually being requested without decrypting the inner ClientHello.
Voidly detects ECH availability before the TLS handshake by checking the DNS HTTPS (type 65) record for the target domain. If an ECHConfigvalue is present, the probe records ech_supported = true. The probe then makes two TLS attempts: one with standard (unencrypted) SNI and one with ECH. Both results are recorded separately. The unencrypted SNI attempt is kept for historical consistency — dropping it would break the time series for domains that recently added ECH support.
# ECH availability check via DNS HTTPS record (type 65)
import dns.resolver
import dns.rdatatype
def check_ech_support(domain: str) -> bool:
"""
Return True if the domain's DNS HTTPS record includes an ECHConfig value.
ECHConfig presence in the HTTPS record means the server supports ECH.
"""
try:
answers = dns.resolver.resolve(domain, rdatatype=dns.rdatatype.HTTPS,
lifetime=3.0)
for rdata in answers:
# The HTTPS record SvcParam key 5 is 'ech'
for key, value in rdata.params.items():
if key == 5: # ECH SvcParam
return True
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer,
dns.resolver.Timeout, dns.exception.DNSException):
pass
return FalseECH measurements are labeled with ech_used = true and stored in parallel with the standard SNI measurements. The classifier treats them independently: an ECH-based measurement that fails is not attributed to TLS censorship unless the failure mode matches known ECH-blocking patterns (Russia blocks ECH at the TSPU level by dropping ClientHello packets with ECH extensions, detectable by the RST timing signature even though the payload is encrypted).
ECH adoption has a structural implication for TLS-based censorship measurement. As ECH becomes widely deployed — Cloudflare, Google, and Fastly are the primary CDN operators moving forward with it — SNI inspection becomes an unreliable censorship mechanism. Censors face a binary choice: block all TLS traffic to a CDN IP range (blocking many unrelated services as collateral damage) or allow the traffic and lose SNI-level visibility. This is already observable in TSPU enforcement patterns on Cloudflare-hosted domains. Measurement systems will need to adapt by tracking ECH coverage rates and distinguishing ECH-blocking from domain-specific blocking.
TLS interference vs. TCP interference: the decision tree
When a TLS measurement fails, the interference_type classifier needs to determine whether the block happened at the TCP layer or the TLS layer. This matters for attribution: a TCP-layer block that affects all traffic to an IP is structurally different from a TLS-layer block that is SNI-specific. The decision tree the classifier applies:
TLS measurement outcome → interference_type classification
═══════════════════════════════════════════════════════════════
TCP connect failed
│
├── RST arrived before TLS ClientHello was sent
│ └── interference_type = "tcp_interference"
│ (TCP RST injection at connection setup, pre-TLS)
│
├── TCP SYN timed out (no SYN-ACK)
│ └── interference_type = "tcp_interference"
│ (TCP-level block: firewall drop, BGP black-hole)
│
└── TCP ECONNREFUSED
└── interference_type = "tcp_interference"
(server closed port, or firewall returning RST on SYN)
TCP connected, TLS attempt made
│
├── No TLS response at all (handshake timed out)
│ └── interference_type = "tls_interference"
│ subtype: "tls_timeout"
│ (post-connection drop: firewall or DPI dropping TLS handshakes silently)
│
├── TLS alert received (code present in alert_code field)
│ ├── Timing: alert_rtt < 30ms and no prior server data
│ │ └── interference_type = "tls_interference"
│ │ subtype: "tls_alert_injected"
│ │ alert_code: [20, 40, 42, 47, 50, 70, 112]
│ │
│ └── Timing: within legitimate RTT range or post-server-data
│ └── interference_type = "tls_interference"
│ subtype: "tls_alert_server"
│ (server or server-side DPI rejected the connection)
│
├── TCP RST during TLS handshake
│ ├── RST timing < 15ms
│ │ └── interference_type = "tls_interference"
│ │ subtype: "tls_rst_injected"
│ │
│ └── RST timing >= 15ms
│ └── interference_type = "ambiguous"
│ (needs corroboration from other probes or dual-SNI test)
│
├── TLS handshake complete, MITM cert detected
│ └── interference_type = "tls_interference"
│ subtype: "tls_mitm"
│ mitm_signals: [government_ca, fingerprint_mismatch, cn_mismatch]
│
└── TLS handshake complete, cert valid
└── Proceed to HTTP measurement
(TLS layer: no_interference)
HTTP result determines interference_typeThe tls_interference / tcp_interference distinction feeds directly into the anomaly classifier as one of its 47 input features. The classifier uses it as a primary signal for the tls_interference output class, but it combines it with the DNS layer result, the dual-SNI result, the timing class, and the cross-probe corroboration score before assigning a final confidence. A single probe reporting tls_rst_injected at an ambiguous 25 ms RTT that has no corroboration from other probes in the same country/ASN will receive lower confidence than one that matches a known TSPU signature and is corroborated by three other probes seeing the same pattern on the same domain.
Performance numbers and country breakdown
TLS handshake adds a median of 42 ms to total measurement time across the Voidly probe network. The overhead is almost entirely network RTT to the server — the TLS handshake computation itself (primarily X25519 key exchange and AES-GCM cipher negotiation) contributes under 2 ms on modern probe hardware.
Across all Voidly measurements where a domain was confirmed as censored, 96% show interference at the TLS layer or above — only 4% are pure TCP blocks where the censorship prevents TCP connection setup entirely without a TLS handshake attempt. The TCP-only category is dominated by BGP-level blocks (entire IP prefixes withdrawn or null-routed) and firewall rules that reset SYN packets. The 96% TLS-or-higher figure reflects that most targeted blocking is domain-specific rather than IP-specific, and domain-specific blocking almost always has to engage with the TLS layer to distinguish domains that share IPs via CDNs.
Country-level TLS interference signatures in the current dataset:
| Country | Primary TLS interference mode | HTTPS blocking rate on targeted domains | Notes |
|---|---|---|---|
| Kazakhstan (KZ) | MITM with NCA cert (government_ca_detected = true) | 100% on targeted domains | NCA cert blocked by Chrome/Firefox; ISPs attempted mandatory installation 2019–2020. Still seen on some ISPs. |
| Iran (IR) | Mix of TCP RST injection and TLS handshake_failure alert (code 40) | ~85% on targeted domains | Method varies by ISP and domain category. HTTPS blocks are more commonly TCP RST than TLS alert. |
| Russia (RU) | SNI-based handshake termination (TSPU RST injection, sub-15ms) | ~60% on TSPU-enforced domains | TSPU enforcement is uneven across ISPs; smaller regional ISPs show lower compliance than Rostelecom. ECH-capable domains (Cloudflare-hosted) show higher bypass rate. |
| China (CN) | TCP RST injection at TLS handshake stage for GFW-blocked domains | ~97% on GFW-blocked domains | GFW RST injection fires on ClientHello SNI inspection; timing is typically 8–12ms, well within the sub-15ms injection threshold. Cert injection not observed; GFW blocks before cert exchange. |
Related technical articles:
For DNS-layer detection that precedes TLS measurement: How Voidly measures DNS-layer censorship: dual-resolver design, interference classification, and false positive mitigations →
For the HTTP layer that follows a completed TLS handshake: How Voidly measures HTTP and HTTPS censorship: the full protocol lifecycle from DNS through TLS to body comparison →
For the full 47-feature extraction from DNS, TCP, TLS, and HTTP layers that feeds the anomaly classifier: Voidly feature extraction: 47 signals from four protocol layers →
For how block page fingerprints complement TLS certificate fingerprinting in MITM detection: Voidly's block page fingerprint library: detecting censorship signatures across 2,300+ known pages →