hpke_http/lib.rs
1//! Safe, synchronous, bounded whole-message HPKE transactions for HTTP.
2//!
3//! This crate is the self-documenting source contract for stable protocol
4//! [`PROTOCOL_ID`]. It performs no networking and exposes no transport or
5//! framework objects. Protocol version 1 composes:
6//!
7//! - RFC 9180 HPKE in PSK mode with DHKEM(X25519, HKDF-SHA256), HKDF-SHA256,
8//! and ChaCha20-Poly1305;
9//! - canonical RFC 9292 known-length Binary HTTP Messages as plaintext, with
10//! an optional authenticated body-coding extension; and
11//! - the response-secret, recipient-nonce, and `enc || response_nonce`
12//! derivation pattern from RFC 9458 section 4.4.
13//!
14//! The protocol is not wire-compatible with RFC 9458 Oblivious HTTP. Its narrow PSK
15//! request envelope is project-specific. HTTPS remains required because the
16//! protocol does not hide its endpoint, public identifiers, sizes, or timing.
17//!
18//! # Request bytes
19//!
20//! Multi-byte integers use network byte order. The authenticated HPKE `info`
21//! is `"message/hpke-http request" || 0x00 || "v1" || 0x00 || header`, where `header`
22//! ends after the PSK ID. HPKE seals the request plaintext with empty AAD.
23//!
24//! | Field | Size | Value |
25//! | --- | ---: | --- |
26//! | magic | 4 | ASCII `HHRQ` |
27//! | version | 1 | `0x01` |
28//! | flags | 1 | `0x00` |
29//! | recipient key-ID length | 1 | `1..=255` |
30//! | PSK-ID length | 1 | `1..=255` |
31//! | KEM ID | 2 | `0x0020` |
32//! | KDF ID | 2 | `0x0001` |
33//! | AEAD ID | 2 | `0x0003` |
34//! | issued-at Unix seconds | 8 | unsigned network-order client time |
35//! | recipient key ID | variable | opaque public bytes |
36//! | PSK ID | variable | opaque public bytes, never the PSK |
37//! | `enc` | 32 | encoded X25519 encapsulated key |
38//! | ciphertext | variable | request plaintext plus 16-byte tag |
39//!
40//! The PSK is at least 32 bytes. Both IDs are non-empty, at most 255 bytes,
41//! and the PSK ID must not equal the PSK.
42//!
43//! # Optional authenticated body coding
44//!
45//! Identity requests seal the original canonical BHTTP bytes, unchanged. A
46//! client that explicitly selects [`CompressionCoding`] instead seals
47//! `0x04 || request_coding || response_coding || BHTTP_request`, where coding
48//! IDs are 0 for identity, 1 for gzip, and 2 for zstd. `response_coding` is
49//! the client's advertised preference. `0x04` is not a BHTTP request framing
50//! indicator, so a peer without this extension rejects the request. The
51//! server must explicitly enable the extension; it never compresses a response
52//! for an identity-only client. When compression saves bytes, the server seals
53//! `0x04 || response_coding || BHTTP_response`; otherwise it seals the original
54//! canonical BHTTP response. Unadvertised response coding is rejected.
55//!
56//! Only the BHTTP body vector is coded. Its fields remain logical HTTP fields;
57//! in particular, `content-length` names the decoded logical body and is
58//! checked after bounded decompression. This private, authenticated transform
59//! is independent of HTTP `Content-Encoding`, which remains ordinary opaque
60//! representation metadata at the low-level API. Gzip accepts concatenated
61//! members; zstd accepts up to 16 concatenated or skippable frames without
62//! dictionaries, with history windows capped at 8 MiB. Both coded and decoded
63//! bodies have the configured body limit. The request is decompressed only after replay
64//! admission. Compression is off by default because ciphertext length can
65//! reveal information about a body containing both secrets and attacker input.
66//!
67//! # Authenticated HTTP subset
68//!
69//! Requests use the fixed `https` scheme and one of [`Method`]'s seven values.
70//! The authority and path are bounded ASCII. Authorities follow the RFC 3986
71//! host plus optional decimal-port grammar, without userinfo or normalization;
72//! IPv6 and `IPvFuture` literals require brackets. Request targets reject
73//! malformed percent escapes, backslashes, fragments, and dot segments; `*` is
74//! limited to `OPTIONS`. Headers are ordered fields with lower-case HTTP token names and
75//! canonical ASCII values without leading or trailing optional whitespace;
76//! repeated names remain ordered. Connection-specific framing, unsupported
77//! proxy semantics, and `Expect` are rejected: `connection`, `expect`, `host`,
78//! `keep-alive`, `proxy-authenticate`, `proxy-authentication-info`,
79//! `proxy-authorization`, `proxy-connection`, `te`, `trailer`,
80//! `transfer-encoding`, and `upgrade`. A single decimal `content-length` is
81//! optional. It must equal the authenticated body length on requests and normal
82//! responses, is metadata on `HEAD` and 304 responses, and is forbidden on 204.
83//! Responses contain one status from 200 through 599. `HEAD` responses and
84//! status 204, 205, or 304 responses cannot contain a body. Empty bodies remain
85//! part of an authenticated canonical message.
86//!
87//! # Replay pause and one-shot response
88//!
89//! After complete request authentication and canonical parsing, [`Server`]
90//! checks the authenticated issued-at time against [`REQUEST_LIFETIME_SECS`]
91//! and [`CLOCK_SKEW_SECS`]. It then returns
92//! `SHA-256("hpke-http/replay" || 0x00 || "v1" || 0x00 || header || enc)` and an
93//! exclusive retention deadline through [`ReplayRequest`]. The verified
94//! plaintext stays in an opaque [`ReplayToken`] until the host reports one
95//! matching atomic replay-admission decision. A provider must reserve the ID
96//! until that deadline and fail closed on an uncertain result. This crate does
97//! not provide or claim a distributed replay store. [`ReplayToken::admit`]
98//! reads the trusted clock again and refuses to release plaintext at or after
99//! the authenticated deadline, including when a provider call was delayed.
100//!
101//! Both HPKE contexts export 32 bytes with context
102//! `"message/hpke-http response" || 0x00 || "v1"`. A server samples a fresh 32-byte
103//! `response_nonce`, uses `enc || response_nonce` as HKDF-SHA256 salt, and
104//! expands the exported secret with raw info `"key"` (32 bytes) and
105//! `"nonce"` (12 bytes). The response bytes are:
106//!
107//! ```text
108//! response_nonce[32] || ChaCha20Poly1305(response_plaintext, empty_aad)
109//! ```
110//!
111//! [`ResponseToken`] and [`ResponseCapability`] consume ownership, so each
112//! request can open and create at most one response.
113//!
114//! # Complete transaction
115//!
116//! ```
117//! use hpke_http::{
118//! Client, Limits, Method, Request, Response, Server, generate_key_pair,
119//! };
120//!
121//! let key_pair = generate_key_pair()?;
122//! let (recipient_private_key, recipient_public_key) = key_pair.into_parts();
123//! let recipient_key_id = b"primary-2026-09".to_vec();
124//! let psk = vec![0x42; 32];
125//! let psk_id = b"tenant-42".to_vec();
126//! let limits = Limits::default();
127//!
128//! let client = Client::new(
129//! &recipient_public_key,
130//! recipient_key_id.clone(),
131//! psk.clone(),
132//! psk_id.clone(),
133//! limits,
134//! )?;
135//! let server = Server::new(&recipient_private_key, recipient_key_id, limits)?;
136//! let request = Request {
137//! method: Method::Post,
138//! authority: b"api.example.test".to_vec(),
139//! path: b"/items".to_vec(),
140//! headers: Vec::new(),
141//! body: b"payload".to_vec(),
142//! };
143//!
144//! let protected = client.protect(&request)?;
145//! let (request_envelope, response_token) = protected.into_parts();
146//! let preparsed = server.preparse(&request_envelope)?;
147//! assert_eq!(preparsed.credential.psk_id, psk_id);
148//! let authenticated = server.authenticate(preparsed.token, &psk)?;
149//! let replay_decision = authenticated.replay.decision(true);
150//! let opened = authenticated.token.admit(replay_decision)?;
151//! assert_eq!(opened.request, request);
152//!
153//! let expected = Response {
154//! status: 200,
155//! headers: Vec::new(),
156//! body: b"ok".to_vec(),
157//! };
158//! let response_envelope = opened.response.protect(&expected)?;
159//! assert_eq!(response_token.open(&response_envelope)?, expected);
160//! # Ok::<(), hpke_http::Error>(())
161//! ```
162//!
163//! # Limits and errors
164//!
165//! [`Limits`] defaults to an 8 MiB body, 16 KiB of header names/values, 64
166//! fields, and an 8 KiB authority-plus-path. Its documented hard ceilings are
167//! enforced before attacker-controlled lengths drive allocations. [`Error`]
168//! provides stable coarse categories and contains no credentials, plaintext,
169//! ciphertext, or parser offsets.
170//!
171//! The crate version, [`PROTOCOL_ID`], and [`BINDING_ABI_VERSION`] are distinct
172//! identities. First-party bindings call [`build_info`] before accepting
173//! credentials. Incompatible wire changes require a new protocol identifier and
174//! envelope version rather than reinterpretation of request version `0x01`.
175
176#![forbid(unsafe_code)]
177
178mod codec;
179mod compression;
180mod engine;
181mod entropy;
182mod error;
183mod limits;
184mod message;
185mod method;
186
187pub use compression::CompressionCoding;
188pub use engine::{
189 AuthenticatedRequest, Client, CredentialRequest, KeyPair, OpenedRequest, PreparsedRequest,
190 ProtectedRequest, ReplayDecision, ReplayRequest, ReplayToken, ResponseCapability,
191 ResponseToken, Server, StartToken, generate_key_pair, generate_key_pair_with_entropy,
192};
193pub use entropy::{EntropySource, SystemEntropy};
194pub use error::Error;
195pub use limits::{
196 HARD_MAX_BODY_LEN, HARD_MAX_HEADER_BYTES, HARD_MAX_HEADER_COUNT, HARD_MAX_ID_LEN,
197 HARD_MAX_TARGET_LEN, Limits,
198};
199pub use message::{HeaderField, Request, Response};
200pub use method::Method;
201
202/// Language-neutral protocol identifier.
203pub const PROTOCOL_ID: &str = "hpke-http/1";
204/// Boundary ABI version used by the first-party bindings.
205pub const BINDING_ABI_VERSION: u32 = 1;
206/// Time for which a newly created request can be accepted (five minutes).
207pub const REQUEST_LIFETIME_SECS: u64 = 300;
208/// Maximum accepted client/server clock difference (30 seconds).
209pub const CLOCK_SKEW_SECS: u64 = 30;
210
211/// Immutable engine identity returned before a binding accepts secrets.
212#[derive(Clone, Copy, Debug, Eq, PartialEq)]
213pub struct BuildInfo {
214 /// Rust crate version.
215 pub version: &'static str,
216 /// Language-neutral protocol identifier.
217 pub protocol: &'static str,
218 /// Boundary ABI version.
219 pub binding_abi: u32,
220}
221
222/// Return the engine identity used for binding skew checks.
223#[must_use]
224pub const fn build_info() -> BuildInfo {
225 BuildInfo {
226 version: env!("CARGO_PKG_VERSION"),
227 protocol: PROTOCOL_ID,
228 binding_abi: BINDING_ABI_VERSION,
229 }
230}