ech_http 0.2.1
ech_http: ^0.2.1 copied to clipboard
An HTTP client with explicit TLS Encrypted Client Hello, proxy support, and a C++ backend bundled through Dart build hooks.
ech_http #
An enterprise-grade, package:http-compatible client for Dart and Flutter with native support for TLS Encrypted Client Hello (ECH), HTTP CONNECT proxies, custom PKI trust roots, and streaming transfers.
Powered by an in-process C++17 engine built on libcurl and BoringSSL, bundled seamlessly through Dart's modern build hooks (code_assets). No background daemons, local listening proxies, Flutter platform channel plugins, or manual DLL loading required at runtime.
Architecture Overview #
flowchart TD
subgraph DartApp["Dart / Flutter Application"]
Consumer["Application Code"] -->|"implements package:http.Client"| EchClient["EchClient"]
EchClient -->|"DNS JSON (Type 65)"| Resolver["EchResolver\n(DohEchResolver / StaticEchResolver)"]
end
subgraph NativeBridge["In-Process Native Bridge (FFI)"]
EchClient -->|"dart:ffi"| Bridge["ech_http.cpp (C++17 Bridge)"]
Bridge -->|"Dart_PostCObject: copied events"| Events["ReceivePort"]
Events -->|"headers / body / completion"| EchClient
Bridge -->|"TLS 1.3 + ECH Handshake"| BoringSSL["BoringSSL\n(Statically Linked)"]
Bridge -->|"HTTP/1.1 Engine + Proxy"| Libcurl["libcurl\n(Statically Linked)"]
Bridge -->|"CA Validation"| MozRoots["Mozilla Root CA Bundle\n(Built-in / Overridable)"]
end
subgraph BuildSystem["Dart Build Hook (Build Time)"]
Hook["hook/build.dart"] -->|"Verify SHA-256"| SDKCache["Precompiled SDK Cache\n(.dart_tool or user_defines)"]
SDKCache -->|"Fetch Target Only"| PrebuiltRepo["GitHub Releases\n(Windows / Linux / Apple / Android)"]
Hook -->|"Compile C++ Bridge Only"| CMakeNinja["CMake + Ninja"]
CMakeNinja -->|"Bundle Native Library"| OutputLib["ech_http.dll / .so / .dylib"]
end
Each native request worker posts copied response events to a Dart ReceivePort
through
NativeApi.postCObject
without polling or NativeCallable. Stream pauses withhold acknowledgements,
limiting unconsumed native body data to 256 KiB per request.
Cancellation stops posting and releases the request handle without waiting for network I/O. Workers retain their state until exit; native finalizers handle unreachable objects and isolate-group teardown. Close clients explicitly for prompt cleanup.
Key Features & Security Principles #
- Fail-Closed by Design: If an
EchRouteis configured for a destination, ECH negotiation must succeed. If the server does not support ECH, rejects the handshake, or presents an unusable configuration, the request fails immediately. There is never a silent fallback to cleartext SNI. - Authenticated ECH Retries: Supports up to 2 automated ECH retries when the server responds with a valid TLS
retry_configsrejection. Public names are strictly validated before retrying. - Separation of Destination Routing and SNI: IP address overrides (
addresses) redirect only the underlying TCP socket or proxy CONNECT tunnel. The TLS inner SNI, certificate identity verification, and HTTPHostheader always correspond to the original requested hostname. - Strict TLS Verification: Every connection strictly validates the server's certificate chain and hostname against the bundled Mozilla CA root certificates. There is no insecure mode or certificate bypass switch. Custom PKI roots can be explicitly loaded via
trustedRootsPem. - High-Performance Build Hooks: Build hooks (
hook/build.dart) automatically download verified precompiled dependency SDKs for the exact target platform, compiling only the lightweight C++ bridge locally (~15 s cold build). No need to compile BoringSSL or libcurl from scratch. - Zero Runtime Daemons: Communicates directly through Dart FFI within the host process memory space, avoiding child processes, socket proxy bottlenecks, and OS port conflicts.
Supported Platforms and Toolchain #
| Target Platform | Configured Architectures | Build Host Requirements | Minimum OS Baseline |
|---|---|---|---|
| Windows | x64, arm64, ia32 |
Windows host, Visual Studio 2022+ C++ build tools, CMake, Ninja | Windows 10+ / Server 2016+ |
| Linux | x64, arm64 |
Linux host, build-essential, cmake, ninja-build |
glibc >= 2.35, GCC 11+ libstdc++ |
| macOS | x64, arm64 |
macOS host, Xcode Command Line Tools, CMake, Ninja | macOS 10.15+ (x64) / 11.0+ (arm64) |
| iOS | arm64 (device), arm64/x64 (simulator) |
macOS host, Xcode, CMake, Ninja | iOS 13.0+ |
| Android | arm64-v8a, armeabi-v7a, x86_64, x86 |
Android NDK r28.2+ (or Flutter bundled NDK), CMake, Ninja | Android API level 21+ (5.0 Lollipop) |
Note
Web (Browsers) and native HarmonyOS are not currently supported. Android binaries link against static libc++ and use 16 KiB ELF segment alignment for Android 15 compatibility.
Getting Started #
1. Installation #
Add ech_http to your application's pubspec.yaml:
dependencies:
ech_http: ^0.2.1
http: ^1.6.0
Run dart pub get (or flutter pub get).
2. Basic Request with Dynamic DoH Discovery #
For hosts that publish ECH configurations in their DNS HTTPS records (RFC 9460), use DohEchResolver:
import 'package:ech_http/ech_http.dart';
import 'package:http/http.dart' as http;
Future<void> main() async {
final target = Uri.parse('https://crypto.cloudflare.com/cdn-cgi/trace');
final dohEndpoint = Uri.parse('https://cloudflare-dns.com/dns-query');
// 1. Create a bootstrap client for DNS queries
final bootstrap = EchClient();
// 2. Configure the main client with DoH resolver
final client = EchClient(
resolver: DohEchResolver(
client: bootstrap,
endpoint: dohEndpoint,
hosts: {target.host},
),
);
try {
final response = await client.send(http.Request('GET', target));
print('HTTP ${response.statusCode}');
print('ECH Accepted: ${response.echAccepted}');
print('Authenticated Retries: ${response.echRetries}');
final body = await response.stream.bytesToString();
print('Response:\n$body');
} finally {
// Always close clients when finished to release native resources
client.close();
bootstrap.close();
}
}
3. Static Route & IP Overrides (Custom CDN Routing) #
When using pre-distributed ECH configurations or connecting to specific origin/edge IPs:
import 'package:ech_http/ech_http.dart';
import 'package:http/http.dart' as http;
Future<void> main() async {
// Base64-encoded ECHConfigList
const echConfig = 'AED+DQA85wAgACD...AAA=';
final client = EchClient(
resolver: StaticEchResolver({
'my-service.example': EchRoute(
configList: echConfig,
// Traffic routes directly to these IPs; SNI and Host header remain 'my-service.example'
addresses: ['198.51.100.10', '198.51.100.11'],
),
}),
);
try {
final response = await client.get(Uri.parse('https://my-service.example/api/v1'));
print('Status: ${response.statusCode}');
} finally {
client.close();
}
}
4. HTTP CONNECT Proxy #
Pass the proxy URI to both the bootstrap client and application client:
final proxyUri = Uri.parse('http://127.0.0.1:7890');
final bootstrap = EchClient(proxy: proxyUri);
final client = EchClient(
proxy: proxyUri,
resolver: DohEchResolver(
client: bootstrap,
endpoint: dohEndpoint,
hosts: {'my-service.example'},
),
);
Important
EchClient does not automatically inspect OS proxy settings or environment variables (HTTP_PROXY, ALL_PROXY). You must configure the proxy parameter explicitly.
Offline Builds & Binary Caching #
By default, Dart build hooks download precompiled dependency SDKs on the first build and cache them under .dart_tool. For CI/CD environments or reproducible offline builds, configure a shared binary cache directory in your application's pubspec.yaml:
hooks:
user_defines:
ech_http:
binary_cache: .dart_tool/ech_http_dependencies
- Paths are relative to the consuming project's
pubspec.yaml. - The build hook verifies the SHA-256 checksum of downloaded archives against
lib/src/build_support/dependencies.json. - Cached archives are validated on each build; any damaged or modified extracted files are automatically repaired from the cached archive.
Operational Limits & Design Constraints #
| Parameter / Feature | Default | Description |
|---|---|---|
| Protocol | HTTP/1.1 |
HTTP/2 and HTTP/3 are not supported in this release. |
Global Max In-Flight Requests (maxConcurrentRequests) |
64 |
Maximum native requests across all hosts within each client instance. |
Per-Host Max In-Flight Requests (maxConcurrentRequestsPerHost) |
6 |
Counted by URL hostname; different schemes and ports on that hostname share capacity. |
| Max Response Size | 32 MiB |
Maximum delivered body size after gzip decoding; aborts if exceeded. |
| Max Upload Size | 8 MiB |
Upload streams are fully buffered into native memory before dispatching. |
| Request Timeout | 30 s |
Per-destination attempt timeout (covers DNS connection, TLS handshake, ECH retries, and data streaming). |
| Connect Timeout | 10 s |
TCP connection and initial TLS handshake timeout. |
| Compression | Automatic gzip | Dart HttpClient negotiation and header/length semantics; autoUncompress: false disables decoding. Other encodings pass through. |
| Address Failover | Supported | Automatically fails over across addresses for GET and HEAD requests before receiving headers. |
| Redirect Security | Enforced | Rejects HTTPS-to-HTTP downgrades. Strips Authorization, Cookie, and Host on cross-origin redirects. |
Concurrency scheduling #
final client = EchClient(
maxConcurrentRequests: 64,
maxConcurrentRequestsPerHost: 6,
);
Requests start when both global and per-host capacity are available, releasing both slots on completion or cancellation. The scheduler selects eligible requests in queue order, skipping requests to saturated hosts so other hosts can proceed. Queued requests hold no in-flight capacity, and ordering within each host is FIFO. Both settings must be positive integers and apply independently to each client.
Hostnames are case-insensitive; proxy addresses, resolved IPs and ECH public names
do not affect accounting. HTTP/HTTPS and different ports on the same hostname
share capacity. Different hostnames remain independent even when they share an
IP or proxy. Cross-host redirects release source capacity and acquire capacity
for the destination URL's hostname. Queueing and resolver work are excluded from
timeout, which remains a per-destination transfer timeout.
Use AbortableRequest to cancel queued requests; close() cancels queued and
in-flight requests.
Unread or paused bodies retain the 256 KiB unacknowledged payload budget, while completion and error events continue to be processed. Native completion or timeout therefore releases the slot even before the body is consumed. Buffered body data and errors remain available to the response stream's eventual listener.
Gzip behavior #
EchClient sends Accept-Encoding: gzip unless the request supplies its own
value. Like Dart HttpClient, autoUncompress defaults to true and decodes
only Content-Encoding: gzip. Setting it to false returns the original bytes
while retaining gzip negotiation; use Accept-Encoding: identity to request
an uncompressed response. Request bodies are never compressed automatically.
Response headers remain unchanged. For send(), EchResponse.contentLength
and the Content-Length header describe the compressed wire body, while the
stream may contain more bytes. compressionState reports whether gzip decoding
was selected. For buffered get()/post() responses, contentLength equals
bodyBytes.length, as in package:http; headers still retain wire values.
maxResponseBytes and the 256 KiB native delivery budget apply after decoding.
Concatenated gzip members are supported. Malformed or truncated gzip streams
fail with EchException (native code 61); partial bytes may already have been
emitted. Other content encodings and .gz files without Content-Encoding
pass through unchanged. Range requests follow Dart negotiation behavior;
set identity and/or disable decoding when encoded byte offsets must be kept.
In-Depth Documentation #
- Routing & ECH Discovery - Deep dive into DoH resolution, provider shared configurations, IP overrides, and redirect policies.
- Testing & Release Preparation - Guide for local validation, live end-to-end tests, environment variables, and pub.dev publication.
- Platform Verification Matrix - Breakdown of tested platforms, CI matrix execution, binary footprint, and known constraints.
- Third-Party Notices - Open-source licenses for libcurl, BoringSSL, zlib, Mozilla CA bundle, and Android libc++.
Local Development & Testing #
# Fetch dependencies
dart pub get
# Code formatting and static analysis
dart format --output=none --set-exit-if-changed lib hook test example tool
dart analyze --fatal-infos
# Run unit and offline mock tests (73 tests)
dart test -r expanded
# Dry-run package publication
dart pub publish --dry-run
For live testing with real ECH endpoints, see doc/releasing.md.
License #
This project is licensed under the MIT License. Third-party dependencies (libcurl, BoringSSL, zlib, Mozilla CA bundle) are licensed under their respective licenses documented in THIRD_PARTY_NOTICES.md.