Skip to content

Repository files navigation

ohttp-ts

NPM License

TypeScript implementation of Oblivious HTTP (RFC 9458) with streaming support.

Features

  • 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)

Installation

npm install ohttp-ts hpke

Or 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";

Quick Start

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.

Gateway Key Configuration

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.

Protocol Flow

+---------+       +-------+       +---------+    +--------+
| 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.

Binary HTTP

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.

Chunked OHTTP (Streaming)

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.

Examples

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

Post-Quantum Support

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-noble
import { 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 usual

A 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.

Response Encryption

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.

Security considerations

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

Gateway error handling

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;
}

License

MIT

About

Oblivious HTTP (OHTTP) implementation in TypeScript

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages