npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ohttp-ts

v0.6.1

Published

Oblivious HTTP (RFC 9458) implementation with chunked extension support

Readme

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

OHTTP protocol flow

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

Normal OHTTP limits each Binary HTTP message to 1 MiB by default. Pass { maxMessageSize } to the client and gateway to use a different limit.

HTTP helpers default to max(1024, padme(size)) for normal and chunked OHTTP. size includes BHTTP framing, headers and body. Padmé (PURBs, 2019) groups nearby lengths with at most 12% padding overhead; the 1 KiB floor can add more overhead for small messages. Padding hides exact lengths within these groups. Chunk count and timing still leak in streaming mode; authentication tags and framing add wire overhead.

Set padding: 0 to disable outgoing padding, use a positive safe integer to round to a byte multiple, or pass a function returning a padded total. Function results must be safe integers at least as large as the input and must never decrease as input grows. Exported padme and padmeWithFloor help compose policies.

maxMessageSize includes padding on send and receive. Constructors throw RangeError if policy(1) (or a numeric multiple) exceeds the limit. Larger messages that exceed the padded limit fail with MessageTooLarge, including during streaming. Senders and receivers need compatible limits, but may use different padding policies. Raw byte APIs preserve supplied bytes and enforce the limit without padding.

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 sequence and size limit 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);
  // The client chooses this URL. The gateway MUST check it before fetching.
  if (new URL(request.url).host !== "example.com") {
    return new Response(null, { status: 400 });
  }
  return await context.encapsulateResponse(await fetch(request));
} catch (err) {
  if (isOHTTPError(err)) return new Response(null, { status: 400 });
  throw err;
}

License

MIT