TypeScript implementation of Oblivious HTTP (RFC 9458) with streaming support.
- RFC 9458 - Oblivious HTTP
- Chunked OHTTP - Streaming extension (draft-ietf-ohai-chunked-ohttp-08)
- WebCrypto - Works in browsers, Cloudflare Workers, and Node.js
- Pluggable crypto - Supply your own response KDF/AEAD factories for any other environment or cryptographic constraint (see Response Encryption)
npm install ohttp-ts hpkeOr via CDN (no install):
import { KeyConfig, OHTTPClient, OHTTPServer } from "https://esm.sh/ohttp-ts";
import { CipherSuite, KEM_DHKEM_X25519_HKDF_SHA256, KDF_HKDF_SHA256, AEAD_AES_128_GCM } from "https://esm.sh/hpke";import {
AEAD_AES_128_GCM,
CipherSuite,
KDF_HKDF_SHA256,
KEM_DHKEM_X25519_HKDF_SHA256,
} from "hpke";
import { ChunkedOHTTPClient, ChunkedOHTTPServer, KeyConfig } from "ohttp-ts";
const suite = new CipherSuite(
KEM_DHKEM_X25519_HKDF_SHA256,
KDF_HKDF_SHA256,
AEAD_AES_128_GCM,
);
// Gateway setup. Publish only KeyConfig.serialize(keyConfig).
const keyConfig = await KeyConfig.generate(suite, 0x01);
const gateway = new ChunkedOHTTPServer([keyConfig]);
// Client setup from the gateway's published configuration.
const publicConfig = KeyConfig.parse(KeyConfig.serialize(keyConfig));
const client = new ChunkedOHTTPClient(suite, publicConfig);
const originalRequest = new Request("https://target.example/api", {
method: "POST",
body: JSON.stringify({ data: "sensitive" }),
});
const { init, context: clientContext } =
await client.encapsulateRequest(originalRequest);
// A relay forwards this request unchanged to the gateway.
const relayRequest = new Request("https://gateway.example/ohttp", init);
const { request: innerRequest, context: gatewayContext } =
await gateway.decapsulateRequest(relayRequest);
const innerResponse = new Response(
JSON.stringify({ received: await innerRequest.json() }),
{ headers: { "Content-Type": "application/json" } },
);
const encapsulatedResponse =
await gatewayContext.encapsulateResponse(innerResponse);
// The relay forwards the gateway response unchanged to the client.
const response = await clientContext.decapsulateResponse(encapsulatedResponse);
console.log(await response.json());Chunked endpoints use the draft's standard 16 KiB plaintext chunks. The only
resource-policy option is the aggregate plaintext bound, for example
{ maxMessageSize: 64 * 1024 * 1024 }; use the same deployment policy at both
ends. High-level streaming operations also accept { signal } as their second
argument. The default message limit is 1 GiB.
KeyConfig.parse reads a single serialized config, which is what the Quick Start
passes it. A gateway's application/ohttp-keys resource is a list, and it may
name algorithms this client does not implement - during a post-quantum
migration, for instance. Use parseMultiple to read the list and select to
pick a config the client's suite can actually use:
const response = await fetch("https://gateway.example/.well-known/ohttp-gateway");
const configs = KeyConfig.parseMultiple(new Uint8Array(await response.arrayBuffer()));
// Configs naming algorithms this library lacks are skipped, so `configs` may be
// empty. `select` throws UnsupportedCipherSuite when none match the suite -
// re-fetch, try another suite, or fall back to a direct request.
const client = new OHTTPClient(suite, KeyConfig.select(suite, configs));Do not reach for configs[0]: it picks by the gateway's order, not by what the
client implements, and hands you a config for the wrong KEM as readily as the
right one.
One key configuration can advertise several (KDF, AEAD) pairs under a single
key identifier, as RFC 9458 Appendix A does. Pass the suites it serves, all
sharing one KEM:
import {
AEAD_AES_128_GCM,
AEAD_ChaCha20Poly1305,
CipherSuite,
KDF_HKDF_SHA256,
KEM_DHKEM_X25519_HKDF_SHA256,
} from "hpke";
const keyConfig = await KeyConfig.generate(
[
new CipherSuite(KEM_DHKEM_X25519_HKDF_SHA256, KDF_HKDF_SHA256, AEAD_AES_128_GCM),
new CipherSuite(KEM_DHKEM_X25519_HKDF_SHA256, KDF_HKDF_SHA256, AEAD_ChaCha20Poly1305),
],
0x01,
);
const gateway = new OHTTPServer([keyConfig]);One key pair covers every suite, since it belongs to the KEM they share, and the advertised pairs are derived from the suites. The request header says which pair it used.
+---------+ +-------+ +---------+ +--------+
| Client | | Relay | | Gateway | | Target |
+---------+ +-------+ +---------+ +--------+
| | | |
| Encapsulated | | |
| Request | | |
+--------------->| Forward | |
| +--------------->| Decrypt & |
| | | Forward |
| | +------------>|
| | | |
| | |<------------+
| | | Encrypt |
| |<---------------+ Response |
|<---------------+ | |
| Decapsulated | | |
| Response | | |
This library encapsulates and decapsulates; it never sends anything. Each hop
is a fetch() you make yourself, which is what keeps relay and gateway
authentication - mTLS, bearer tokens, whatever your deployment needs - out of
the library and under your control.
OHTTP encapsulates Binary HTTP (RFC 9292) messages. The high-level API (encapsulateRequest, decapsulateRequest, etc.) handles encoding automatically.
For advanced use cases, the low-level bytes API is also available:
// Low-level API: work with raw Binary HTTP bytes
const { encapsulatedRequest, context } = await client.encapsulate(binaryHttpBytes);
const { request: binaryBytes, context: serverCtx } = await gateway.decapsulate(encapsulatedRequest);See examples/bhttp.example.ts for a complete example.
Use chunked OHTTP when:
- Large payloads (>1MB) that would exceed memory limits
- Incremental sources - data arrives over time (file uploads, network streams)
- Early processing - need to start processing before full body arrives
- Memory-constrained - Workers (128MB), mobile, edge
Use normal OHTTP when:
- Small payloads (<100KB)
- Need full body - JSON.parse(), image processing, etc.
- Latency-sensitive - streaming has async overhead
// Normal: ~3x payload memory, faster for in-memory data
const client = new OHTTPClient(suite, keyConfig);
// Chunked: ~64KB constant memory, better for large/streaming data
const client = new ChunkedOHTTPClient(suite, keyConfig);For streaming large requests/responses, use ChunkedOHTTPClient/ChunkedOHTTPServer:
import { ChunkedOHTTPClient, ChunkedOHTTPServer, type StreamingRequestInit } from "ohttp-ts";
// Setup (same key configuration as above)
const gateway = new ChunkedOHTTPServer([keyConfig]);
const client = new ChunkedOHTTPClient(suite, keyConfig);
// Client: encapsulate streaming request
// StreamingRequestInit supplies the duplex the DOM lib types still lack
const uploadInit: StreamingRequestInit = {
method: "POST",
body: largeReadableStream,
duplex: "half",
};
const streamingRequest = new Request("https://target.example/upload", uploadInit);
const { init, context } = await client.encapsulateRequest(streamingRequest);
// Send to relay (init includes duplex: "half" for streaming)
const relayResponse = await fetch("https://relay.example/ohttp", init);
// Gateway: decapsulate (body streams through)
// relayRequest is what the relay receives and forwards to the gateway
const { request: innerRequest, context: serverContext } =
await gateway.decapsulateRequest(relayRequest);
// Process body incrementally
for await (const chunk of innerRequest.body!) {
// Process chunk without buffering entire body
}
// Gateway: stream response back
const streamingResponse = new Response(responseStream, { status: 200 });
const encapsulatedResponse = await serverContext.encapsulateResponse(streamingResponse);
// Client: decapsulate and consume streaming response
const finalResponse = await context.decapsulateResponse(relayResponse);
for await (const chunk of finalResponse.body!) {
// Process chunk as it arrives
}Note: Request/Response bodies stream through without full buffering. Only the BHTTP preamble (method/status, headers) is buffered before the body can flow.
For the low-level bytes API, see examples/chunked.example.ts.
| Example | Description |
|---|---|
ohttp.example.ts |
Basic OHTTP round-trip |
chunked-http.example.ts |
Streaming Request/Response API |
chunked.example.ts |
Low-level bytes API |
bhttp.example.ts |
Request/Response API (non-streaming) |
mlkem.example.ts |
Post-quantum with ML-KEM-768 |
response-chacha.example.ts |
ChaCha20-Poly1305 response via a custom crypto factory |
For post-quantum key encapsulation (ML-KEM), use @panva/hpke-noble. hpke's own
ML-KEM goes through WebCrypto, which browsers, Cloudflare Workers, and Bun do
not implement:
npm install @panva/hpke-nobleimport { CipherSuite } from "hpke";
import { KEM_ML_KEM_768, KDF_HKDF_SHA256, AEAD_AES_128_GCM } from "@panva/hpke-noble";
const suite = new CipherSuite(KEM_ML_KEM_768, KDF_HKDF_SHA256, AEAD_AES_128_GCM);
// Use with KeyConfig.generate(), OHTTPClient, OHTTPServer as usualA gateway migrating to ML-KEM publishes both keys for a while, so read its list
with KeyConfig.parseMultiple and pick with KeyConfig.select rather than
assuming which one comes first. See Gateway Key Configuration
and examples/mlkem.example.ts.
OHTTP responses are not HPKE: the response key is derived with HKDF over an HPKE-exported secret and then used with a raw AEAD (RFC 9458 Section 4.4). By default these primitives are resolved from the suite using hpke's built-in (WebCrypto-backed) factories.
Pass non-WebCrypto factories via the responseCrypto option (the same pattern
as swapping the KEM above) when the default doesn't fit.
Below is an example that sets a ChaCha20-Poly1305 implementation:
import { CipherSuite, KEM_DHKEM_X25519_HKDF_SHA256 } from "hpke";
import { AEAD_ChaCha20Poly1305, KDF_HKDF_SHA256 } from "@panva/hpke-noble";
const suite = new CipherSuite(KEM_DHKEM_X25519_HKDF_SHA256, KDF_HKDF_SHA256, AEAD_ChaCha20Poly1305);
const responseCrypto = { aead: AEAD_ChaCha20Poly1305 };
const gateway = new OHTTPServer([keyConfig], { responseCrypto });
const client = new OHTTPClient(suite, clientKeyConfig, { responseCrypto });The override is byte-compatible with the default factory, so a client and gateway may use a different implementation for the same algorithm.
A gateway serving several AEADs passes one factory per algorithm, since the request decides which a response uses. Each factory must produce the algorithm it stands in for, so mixing implementations is fine:
import { AEAD_AES_128_GCM } from "hpke";
import { AEAD_ChaCha20Poly1305 } from "@panva/hpke-noble";
const gateway = new OHTTPServer([keyConfig], {
responseCrypto: { aead: [AEAD_AES_128_GCM, AEAD_ChaCha20Poly1305] },
});An override with no factory for an algorithm you serve throws
UnsupportedCipherSuite rather than falling back.
Not audited. Use at your own risk.
Security-sensitive behavior is tested against the RFC 9458 and chunked OHTTP
draft vectors, plus interoperability vectors from ohttp-js.
fast-check properties exercise wire formats, round
trips, fragmentation, chunk ordering, malformed input, and concurrent
operations. CI runs the suite in Node.js, browsers, and Cloudflare Workers,
while Bun runs the examples. Allocation and crypto-overlap benchmarks cover the
main buffered and streaming paths.
The chunked API deliberately exposes one resource setting: maxMessageSize.
Plaintext chunks use the draft's 16 KiB size, and the ciphertext bound is
derived from it. Streaming operations accept an AbortSignal, and the BHTTP
decoder applies a separate metadata limit. Keeping these related bounds inside
the library avoids contradictory settings at the client and gateway.
- Replay protection is out of scope (RFC 9458 Section 6.5)
- Decryption errors are opaque to prevent oracle attacks
Every failure is an OHTTPError carrying a code: InvalidKeyConfig,
UnknownKeyId, UnsupportedCipherSuite, DecryptionFailed,
EncryptionFailed, InvalidMessage, and the two chunked sequence codes. Keep
that detail for your own logs and answer the relay with a plain 400, as
RFC 9458 Section 4.3
requires of any decapsulation failure:
import { isOHTTPError } from "ohttp-ts";
try {
const { request, context } = await gateway.decapsulateRequest(ohttpRequest);
return await context.encapsulateResponse(await fetch(request));
} catch (err) {
if (isOHTTPError(err)) return new Response(null, { status: 400 });
throw err;
}MIT