Pure Dart implementation of Oblivious HTTP (RFC 9458) — no native dependencies.
- OHTTP encapsulation / decapsulation (RFC 9458)
- HPKE Base Mode Sender (RFC 9180) — hand-written from crypto primitives
- Binary HTTP (RFC 9292) — serialize/parse HTTP messages
- Transport-agnostic core — plug in any HTTP client via the
OhttpTransportinterface package:httpadapter — drop-inhttp.BaseClientreplacement- TTL-based key config cache with single-flight deduplication
- Configurable size limits for encrypted and decrypted responses
Cipher suite: DHKEM(X25519, HKDF-SHA256) + HKDF-SHA256 + AES-128-GCM
package:ohttp_dart/ohttp_dart.dart ← core (transport-agnostic)
package:ohttp_dart/http.dart ← package:http adapter
The core library defines abstractions (OhttpTransport, OhttpSession,
KeyConfigCache) that any HTTP client can plug into. The http.dart
adapter provides HttpClientTransport and OhttpHttpClient for
consumers using package:http.
OhttpSession orchestrates the full pipeline: cache lookup → BHTTP
serialization → OHTTP encapsulation → transport call → decapsulation →
BHTTP parsing. Cache invalidation happens automatically on gateway errors.
When retryOnGatewayError is enabled (the default), a single automatic
retry is performed with a freshly fetched key config.
Sessions accept an optional OhttpObserver to receive lifecycle event notifications:
class MyObserver extends OhttpObserver {
@override
void onKeyConfigFetched() => print('Key config fetched');
@override
void onKeyConfigCacheHit() => print('Using cached key config');
@override
void onPostToGateway() => print('Posting to gateway');
@override
void onDecapsulationError(Type errorType) => print('Decapsulation failed: $errorType');
@override
void onGatewayError(int statusCode) => print('Gateway error: $statusCode');
@override
void onCacheInvalidated() => print('Cache invalidated');
@override
void onEncapsulationError(Type errorType) => print('Encapsulation failed: $errorType');
@override
void onGatewayRetry() => print('Retrying after gateway error');
@override
void onRoundTripCompleted(Duration elapsed) => print('Round trip completed in $elapsed');
@override
void onRequestAborted(OhttpRequestStage stage) => print('Request aborted during $stage');
}
final session = OhttpSession.withTransport(
transport: transport,
observer: MyObserver(),
);Observer methods have no-op defaults, so you only override the events you care about.
Observer errors are suppressed via notifySafe() — they never affect the OHTTP pipeline.
Callbacks only ever receive safe metadata (the error Type, an HTTP status code) — never
keys, nonces, shared secrets, or plaintext request/response bodies.
lib/
├── ohttp_dart.dart # Core library entry point (transport-agnostic)
├── http.dart # Optional package:http adapter entry point
└── src/
├── bhttp.dart # Binary HTTP (RFC 9292)
├── bhttp_response_limits.dart # Response size limits configuration
├── cipher_suite.dart # Cipher suite constants
├── exceptions.dart # Sealed exception hierarchy
├── hpke.dart # HPKE Base Mode Sender (RFC 9180)
├── key_config_cache.dart # TTL cache with single-flight for key configs
├── ohttp.dart # OHTTP encap/decap + KeyConfig (RFC 9458)
├── ohttp_data.dart # Request / response data types
├── ohttp_observer.dart # Lifecycle event observer interface
├── ohttp_session.dart # OHTTP session orchestrator
├── ohttp_transport.dart # Transport abstraction interface
├── erasable_byte_array.dart # Byte buffer that zeroes on erase(), guards post-erase reads
├── ohttp_constants.dart # Centralized default values
├── data/
│ └── key_config_fetch_result.dart # Result type for key config fetch (bytes + TTL)
└── adapters/
└── http/
├── http_client_transport.dart # HttpClientTransport implementation
└── ohttp_http_client.dart # OhttpHttpClient drop-in replacement
All exceptions thrown by the library extend OhttpException (sealed class),
so consumers can catch every library error with a single handler:
try {
final response = await session.send(request);
} on OhttpException catch (e) {
// All library errors land here
print('OHTTP error: $e');
}Specific exception types:
| Type | When |
|---|---|
OhttpConfigException |
Invalid request/URL config (non-HTTPS scheme, bad authority, negative limits) |
OhttpUnsupportedSuiteException |
KeyConfig advertises only unsupported KEM/KDF/AEAD |
OhttpKeyConfigException |
Structurally malformed KeyConfig binary data (too short, wrong lengths, trailing data) |
OhttpFormatException |
Malformed BHTTP data (wrong framing indicator, truncated fields, invalid varint) |
OhttpGatewayException |
Gateway returned non-2xx response (includes statusCode; triggers cache invalidation) |
OhttpDecapsulationException |
OHTTP response decapsulation failure (response too short, ciphertext too short for GCM tag) |
OhttpCryptoException |
AES-GCM / HPKE crypto failure (includes optional cause) |
OhttpSizeLimitException |
Response exceeds configured size limits (includes limit and actualSize) |
OhttpNetworkException |
Network-level error — DNS, connection refused, etc. (includes optional cause) |
OhttpTimeoutException |
HTTP request exceeded configured timeout (includes timeout duration and optional url) |
OhttpRequestAbortedException |
Intentional client-side request cancellation (e.g. http.RequestAbortedException); a sibling of OhttpNetworkException, not a subtype, so an aborted request stays distinguishable from a real network error (includes optional cause) |
import 'package:http/http.dart' as http;
import 'package:ohttp_dart/http.dart';
import 'package:ohttp_dart/ohttp_dart.dart';
final raw = http.Client();
final transport = HttpClientTransport(
client: raw,
keysUrl: Uri.parse('https://gateway.example.com/ohttp/config'),
gatewayUrl: Uri.parse('https://gateway.example.com/ohttp/gateway'),
);
final session = OhttpSession.withTransport(transport: transport);
final client = OhttpHttpClient(session: session, closeWith: raw);
final response = await client.get(Uri.https('target.example.com', '/api/data'));
// Use response as a standard http.StreamedResponse
client.close();final session = OhttpSession.withTransport(transport: transport);
final request = OhttpRequestData(
method: 'GET',
scheme: 'https',
authority: 'target.example.com',
path: '/api/data',
headers: [('accept', 'application/json')],
);
final response = await session.send(request);OhttpSession supports configurable limits for response sizes
and a TTL override for the key config cache:
final session = OhttpSession.withTransport(
transport: transport,
maxEncryptedResponseBytes: 32 * 1024 * 1024, // 32 MiB (default: 16 MiB)
keyConfigCacheTtl: Duration(minutes: 30), // optional TTL override
decryptedResponseLimits: BhttpResponseLimits(
maxHeaderBytes: 32 * 1024, // 32 KiB (default: 16 KiB)
maxBodyBytes: 20 * 1024 * 1024, // 20 MiB (default: 10 MiB)
),
);Key Config Cache TTL resolution (highest to lowest priority):
- Explicit
keyConfigCacheTtlpassed towithTransport(orttlonKeyConfigCache) - Server
max-agefrom theCache-Controlheader OhttpConstants.fallbackKeyConfigCacheTtl(1 hour)
no-cache / no-store in the server response disables caching (TTL = 0).
For full control over the cache, use the primary constructor:
final cache = KeyConfigCache(
transport: transport,
ttl: Duration(hours: 2),
);
final session = OhttpSession(
transport: transport,
cache: cache,
maxEncryptedResponseBytes: 32 * 1024 * 1024,
);HttpClientTransport enforces HTTPS per RFC 9458 §1 and supports
configurable timeouts:
final transport = HttpClientTransport(
client: httpClient,
keysUrl: Uri.parse('https://gateway.example.com/ohttp/config'),
gatewayUrl: Uri.parse('https://gateway.example.com/ohttp/gateway'),
fetchKeyConfigTimeout: Duration(seconds: 10), // default: 30s
postToGatewayTimeout: Duration(seconds: 15), // default: 30s
);For testing with non-HTTPS endpoints (e.g., MockClient with http://localhost):
final transport = HttpClientTransport.insecureForTesting(
client: mockClient,
keysUrl: Uri.parse('http://localhost:8080/keys'),
gatewayUrl: Uri.parse('http://localhost:8080/gateway'),
);Implement OhttpTransport to integrate with any HTTP client (Dio, etc.):
class DioTransport implements OhttpTransport {
@override
Future<KeyConfigFetchResult> fetchKeyConfig() async {
// GET the key config URL, throw OhttpGatewayException on non-2xx
// Return KeyConfigFetchResult(bytes: body, maxAge: parsedMaxAge)
}
@override
Future<Uint8List> postToGateway(Uint8List body) async {
// POST to gateway with Content-Type: message/ohttp-req
// throw OhttpGatewayException on non-2xx
}
}| Package | Version | Purpose |
|---|---|---|
cryptography |
2.9.0 | Pure Dart crypto primitives (X25519, HMAC, AES-GCM) |
http |
1.6.0 | HTTP client for the package:http adapter |
meta |
1.17.0 | Annotations (@visibleForTesting) |
Dev dependencies: test 1.25.6, kiri_check 1.3.1 (property-based testing), lints 3.0.0.
dart testTests cover:
- RFC test vectors for HPKE (RFC 9180 Appendix A.1), HKDF (RFC 5869)
- OHTTP encapsulation/decapsulation (RFC 9458)
- BHTTP encoding/decoding (RFC 9292)
- Key config TTL cache with single-flight deduplication
- Observer lifecycle events and error suppression
- Session orchestration and pipeline integration
package:httpadapter integrationErasableByteArrayzeroing and post-erase guard
HPKE and BHTTP also have property-based tests (via kiri_check) alongside the
fixed RFC vectors, and an integration fuzz suite (test/integration/fuzz_test.dart,
also kiri_check) checks that varint decoding, BHTTP response parsing, and OHTTP
KeyConfig parsing raise only typed OhttpException subtypes on arbitrary bytes.
See LICENSE.