Skip to main content

Crate hpke_http

Crate hpke_http 

Source
Expand description

Bounded HPKE requests and checked response records for HTTP.

This crate defines the sole wire contract, PROTOCOL_ID hpke-http/2. It does no network I/O. HTTP hosts must use HTTPS because this protocol does not hide endpoints, public IDs, record sizes, counts, or timing. The suite is RFC 9180 HPKE PSK mode with X25519, HKDF-SHA256, and ChaCha20-Poly1305. The request plaintext uses canonical known-length RFC 9292 Binary HTTP. This protocol is not RFC 9458 Oblivious HTTP.

§Request bytes

All fixed-width integers are big-endian. The request is the following byte string, with no trailing fields:

FieldBytesValue
magic4ASCII HHRQ
version, flags1 each0x02, 0x00
key ID length, PSK ID length1 each1..=255
KEM, KDF, AEAD IDs2 each0x0020, 0x0001, 0x0003
issued-at Unix seconds8client time
key ID, PSK IDstated lengthsopaque public IDs
enc32X25519 HPKE encapsulated key
ciphertextvariablerequest plaintext and 16-byte tag

header ends after the PSK ID. HPKE info is "message/hpke-http request\0v2\0" || header; request AEAD AAD is empty. The PSK must be at least 32 bytes long, contain at least 32 bytes of entropy, and differ from its public ID. Normal request plaintext is a canonical known-length Binary HTTP request. If the client opts into private body coding, plaintext is 0x04 || request_coding:u8 || response_coding:u8 || BHTTP_request. Coding IDs are 0 (identity), 1 (gzip), and 2 (zstd). Only the body bytes are coded; the logical headers and Content-Length name clear bytes. Coding is off by default because compressed length can leak information.

§Public key discovery

The crate does no HTTP I/O. A host can call Server::public_key to publish its current X25519 public key. The Python ASGI host serves one binary record through GET at the same HTTPS URL that accepts protected POST: "HHKD" || 0x01 || id_len:u8 || id || public_key[32]. id_len is 1 through 255, and the complete record is 39 through 293 bytes. The key record version is separate from PROTOCOL_ID. Clients bind this GET and the later POST to one configured HTTPS endpoint and one logical origin. The host must keep the one Server key equal across its workers.

After request authentication and parsing, the server checks the client time against REQUEST_LIFETIME_SECS and CLOCK_SKEW_SECS. The replay ID is SHA-256 of "hpke-http/replay\0v2\0" || header || enc. ReplayToken::admit needs one host replay decision and checks the deadline again. The host must reserve the ID through that deadline.

§Response bytes

Each HPKE context exports 32 bytes with context "message/hpke-http response\0v2". The server samples a fresh 32-byte server_nonce. HKDF-SHA256 uses enc || server_nonce as salt and the exported secret as input key material. It expands 32 bytes with info "hpke-http/2 response key" and 12 bytes with info "hpke-http/2 response nonce". A response has this exact form:

"HHRP" || 0x02 || server_nonce[32] || records...
record = ciphertext_len:u32be || ciphertext[ciphertext_len]

The 37-byte prefix stays fixed for all records. Each record plaintext starts with a one-byte kind. START is 0x01 || status:u16be || coding:u8 || fields. fields is the ordered sequence of name/value pairs, each encoded as an RFC 9292 QUIC variable-length integer and that many raw bytes. There is no field count or end marker: the START record end terminates the sequence. DATA is 0x02 || body; END is only 0x03. The record length includes the 16-byte tag and must be at least 17. The stream has one START at sequence 0, zero or more DATA records, then one END; END must be the last record. Each sequence is a u64. The writer reserves u64::MAX for END and rejects DATA that would use it. The reader rejects a sequence overflow.

For record number n, the 12-byte nonce is the derived base nonce with its last 8 bytes combined with n:u64be by XOR. Its AAD is the exact byte string "hpke-http/2 response record\0" || prefix[37] || n:u64be || ciphertext_len:u32be. ChaCha20-Poly1305 seals the kind and body under this nonce and AAD. The client checks the full tag before using any START fields or yielding a DATA block. Any bad tag, form, order, or limit closes the state. A checked END is not success until the caller reports actual outer HTTP body EOF through ResponseOpener::finish_eof. Extra bytes after END, including bytes in the same transport chunk, fail.

A single checked Content-Type whose media type is text/event-stream selects SSE. SSE requires status 200, a request other than HEAD, identity private coding, and no logical Content-Length or Content-Encoding. Each SSE DATA holds exactly one complete clear SSE block: line endings are LF, and the block ends with one blank line. This includes comment and control blocks. SseSplitter maps CR, LF, and CRLF to LF as the server reads app bytes. It drops an unfinished tail at a clean end. The caller parses SSE fields; this crate returns checked block bytes. Each block has its own Limits::max_body_len bound; an SSE stream has no total-body bound. A finite response has at most one DATA and becomes public only after END and true EOF. Its body may use the one advertised private gzip or zstd coding, with bounded clear and coded lengths. HEAD and status 204, 205, or 304 have no body.

§Complete finite transaction

use hpke_http::{
    Client, EntropySource, Limits, Method, Request, Response, Server, SystemEntropy,
    generate_key_pair,
};

let keys = generate_key_pair()?;
let (private, public) = keys.into_parts();
let key_id = b"primary".to_vec();
let mut psk = vec![0; 32];
let mut entropy = SystemEntropy;
entropy.fill(&mut psk)?;
let psk_id = b"tenant".to_vec();
let client = Client::new(&public, key_id.clone(), psk.clone(), psk_id.clone(), Limits::default())?;
let server = Server::new(&private, key_id, Limits::default())?;
let request = Request {
    method: Method::Post, authority: b"api.example.test".to_vec(),
    path: b"/items".to_vec(), headers: Vec::new(), body: b"payload".to_vec(),
};
let (envelope, token) = client.protect(&request)?.into_parts();
let preparsed = server.preparse(&envelope)?;
assert_eq!(preparsed.credential.psk_id, psk_id);
let authenticated = server.authenticate(preparsed.token, &psk)?;
let opened = authenticated.token.admit(authenticated.replay.decision(true))?;
assert_eq!(opened.request, request);
let expected = Response { status: 200, headers: Vec::new(), body: b"ok".to_vec() };
let response = opened.response.protect_finite(&expected)?;
assert_eq!(token.open_finite(&response)?, expected);

Limits defaults to an 8 MiB body, 16 KiB of header bytes, 64 fields, and an 8 KiB authority plus path. Bounds apply before lengths drive allocations. Error has coarse codes and no clear or secret bytes. The crate version, PROTOCOL_ID, and BINDING_ABI_VERSION are separate values; bindings check all three before accepting secrets.

Structs§

AuthenticatedRequest
Result of authentication, paused before replay admission and plaintext release.
BuildInfo
Immutable engine identity returned before a binding accepts secrets.
Client
A client configured for one recipient key and one explicit PSK identity.
CredentialRequest
Public information needed by a host credential resolver.
HeaderField
One canonical end-to-end HTTP field in an ordered field section.
KeyPair
A generated X25519 recipient key pair.
Limits
Per-engine limits. Values can become stricter but never exceed hard limits.
OpenedRequest
A fully authenticated request and its one-use response capability.
PreparsedRequest
Result of the bounded public preparse stage.
ProtectedRequest
A request envelope and the one-use capability needed to open its response.
ReplayDecision
One atomic replay-provider result bound to its request ID.
ReplayRequest
Stable request identifier passed to an atomic replay-admission provider.
ReplayToken
Opaque one-use continuation after request authentication.
Request
A complete plaintext request supplied to or returned by the engine.
Response
A complete plaintext response supplied to or returned by the engine.
ResponseCapability
One-use server capability for protecting the response to one opened request.
ResponseHead
Fields released after a valid START tag.
ResponseOpener
A checked record reader that accepts any byte cuts.
ResponseSealer
One response writer. Its key and sequence cannot be reused after failure.
ResponseToken
One-use client capability for opening the response to one request.
Server
A server configured for one static recipient key.
SseSplitter
One bounded partial SSE block. Line endings on the wire are LF.
StartToken
Opaque one-use continuation after public preparse.
SystemEntropy
The operating system or Web Crypto entropy source selected by getrandom.

Enums§

CompressionCoding
Opt-in, authenticated protocol body coding. This is not HTTP Content-Encoding.
Error
Errors returned by the protocol engine.
Method
Supported HTTP request methods.
ResponseMode
The checked response kind, based on its single Content-Type field.
ResponseRecord
One checked record. Finite body bytes remain in the opener until END.

Constants§

BINDING_ABI_VERSION
Boundary ABI version used by the first-party bindings.
CLOCK_SKEW_SECS
Maximum accepted client/server clock difference (30 seconds).
HARD_MAX_BODY_LEN
Absolute body limit accepted by the implementation (64 MiB).
HARD_MAX_HEADER_BYTES
Absolute combined header-name and header-value limit (64 KiB).
HARD_MAX_HEADER_COUNT
Absolute number of header fields accepted in one message.
HARD_MAX_ID_LEN
Absolute public identifier limit accepted by the implementation.
HARD_MAX_TARGET_LEN
Absolute combined authority and path limit (8 KiB).
PROTOCOL_ID
Language-neutral protocol identifier.
REQUEST_LIFETIME_SECS
Time for which a newly created request can be accepted (five minutes).

Traits§

EntropySource
A source of cryptographically secure random bytes.

Functions§

build_info
Return the engine identity used for binding skew checks.
generate_key_pair
Generate a recipient key pair with the platform entropy source.
generate_key_pair_with_entropy
Generate a recipient key pair with an injected fallible entropy source.