hpke_http/lib.rs
1//! Bounded HPKE requests and checked response records for HTTP.
2//!
3//! This crate defines the sole wire contract, [`PROTOCOL_ID`] `hpke-http/2`.
4//! It does no network I/O. HTTP hosts must use HTTPS because this protocol
5//! does not hide endpoints, public IDs, record sizes, counts, or timing.
6//! The suite is RFC 9180 HPKE PSK mode with X25519, HKDF-SHA256, and
7//! ChaCha20-Poly1305. The request plaintext uses canonical known-length
8//! RFC 9292 Binary HTTP. This protocol is not RFC 9458 Oblivious HTTP.
9//!
10//! # Request bytes
11//!
12//! All fixed-width integers are big-endian. The request is the following
13//! byte string, with no trailing fields:
14//!
15//! | Field | Bytes | Value |
16//! | --- | ---: | --- |
17//! | magic | 4 | ASCII `HHRQ` |
18//! | version, flags | 1 each | `0x02`, `0x00` |
19//! | key ID length, PSK ID length | 1 each | `1..=255` |
20//! | KEM, KDF, AEAD IDs | 2 each | `0x0020`, `0x0001`, `0x0003` |
21//! | issued-at Unix seconds | 8 | client time |
22//! | key ID, PSK ID | stated lengths | opaque public IDs |
23//! | `enc` | 32 | X25519 HPKE encapsulated key |
24//! | ciphertext | variable | request plaintext and 16-byte tag |
25//!
26//! `header` ends after the PSK ID. HPKE `info` is
27//! `"message/hpke-http request\0v2\0" || header`; request AEAD AAD is empty.
28//! The PSK must be at least 32 bytes long, contain at least 32 bytes of
29//! entropy, and differ from its public ID.
30//! Normal request plaintext is a canonical known-length Binary HTTP request.
31//! If the client opts into private body coding, plaintext is
32//! `0x04 || request_coding:u8 || response_coding:u8 || BHTTP_request`.
33//! Coding IDs are 0 (identity), 1 (gzip), and 2 (zstd). Only the body bytes
34//! are coded; the logical headers and `Content-Length` name clear bytes.
35//! Coding is off by default because compressed length can leak information.
36//!
37//! # Public key discovery
38//!
39//! The crate does no HTTP I/O. A host can call [`Server::public_key`] to
40//! publish its current X25519 public key. The Python ASGI host serves one
41//! binary record through GET at the same HTTPS URL that accepts protected
42//! POST: `"HHKD" || 0x01 || id_len:u8 || id || public_key[32]`. `id_len` is
43//! 1 through 255, and the complete record is 39 through 293 bytes. The key
44//! record version is separate from [`PROTOCOL_ID`]. Clients bind this GET and
45//! the later POST to one configured HTTPS endpoint and one logical origin.
46//! The host must keep the one Server key equal across its workers.
47//!
48//! After request authentication and parsing, the server checks the client
49//! time against [`REQUEST_LIFETIME_SECS`] and [`CLOCK_SKEW_SECS`]. The replay
50//! ID is SHA-256 of `"hpke-http/replay\0v2\0" || header || enc`.
51//! [`ReplayToken::admit`] needs one host replay decision and checks the
52//! deadline again. The host must reserve the ID through that deadline.
53//!
54//! # Response bytes
55//!
56//! Each HPKE context exports 32 bytes with context
57//! `"message/hpke-http response\0v2"`. The server samples a fresh 32-byte
58//! `server_nonce`. HKDF-SHA256 uses `enc || server_nonce` as salt and the
59//! exported secret as input key material. It expands 32 bytes with info
60//! `"hpke-http/2 response key"` and 12 bytes with info
61//! `"hpke-http/2 response nonce"`. A response has this exact form:
62//!
63//! ```text
64//! "HHRP" || 0x02 || server_nonce[32] || records...
65//! record = ciphertext_len:u32be || ciphertext[ciphertext_len]
66//! ```
67//!
68//! The 37-byte prefix stays fixed for all records. Each record plaintext
69//! starts with a one-byte kind. `START` is `0x01 || status:u16be ||
70//! coding:u8 || fields`. `fields` is the ordered sequence of name/value
71//! pairs, each encoded as an RFC 9292 QUIC variable-length integer and that
72//! many raw bytes. There is no field count or end marker: the START record
73//! end terminates the sequence. `DATA` is `0x02 || body`; `END` is only
74//! `0x03`. The record length includes the 16-byte tag and must be at least
75//! 17. The stream has one START at sequence 0, zero or more DATA records,
76//! then one END; END must be the last record. Each sequence is a `u64`.
77//! The writer reserves `u64::MAX` for END and rejects DATA that would use
78//! it. The reader rejects a sequence overflow.
79//!
80//! For record number `n`, the 12-byte nonce is the derived base nonce with
81//! its last 8 bytes combined with `n:u64be` by XOR. Its `AAD` is the exact byte string
82//! `"hpke-http/2 response record\0" || prefix[37] || n:u64be ||
83//! ciphertext_len:u32be`. ChaCha20-Poly1305 seals the kind and body under
84//! this nonce and AAD. The client checks the full tag before using any
85//! START fields or yielding a DATA block. Any bad tag, form, order, or limit
86//! closes the state. A checked END is not success until the caller reports
87//! actual outer HTTP body EOF through [`ResponseOpener::finish_eof`]. Extra
88//! bytes after END, including bytes in the same transport chunk, fail.
89//!
90//! A single checked `Content-Type` whose media type is `text/event-stream`
91//! selects SSE. SSE requires status 200, a request other than HEAD, identity
92//! private coding, and no logical `Content-Length` or `Content-Encoding`.
93//! Each SSE DATA holds exactly one complete clear SSE block: line endings
94//! are LF, and the block ends with one blank line. This includes comment
95//! and control blocks. [`SseSplitter`] maps CR, LF, and CRLF to LF as the
96//! server reads app bytes. It drops an unfinished tail at a clean end.
97//! The caller parses SSE fields; this crate returns checked block bytes.
98//! Each block has its own [`Limits::max_body_len`] bound; an SSE stream has
99//! no total-body bound. A finite response has at most one DATA and becomes
100//! public only after END and true EOF. Its body may use the one advertised
101//! private gzip or zstd coding, with bounded clear and coded lengths.
102//! HEAD and status 204, 205, or 304 have no body.
103//!
104//! # Complete finite transaction
105//!
106//! ```
107//! use hpke_http::{
108//! Client, EntropySource, Limits, Method, Request, Response, Server, SystemEntropy,
109//! generate_key_pair,
110//! };
111//!
112//! let keys = generate_key_pair()?;
113//! let (private, public) = keys.into_parts();
114//! let key_id = b"primary".to_vec();
115//! let mut psk = vec![0; 32];
116//! let mut entropy = SystemEntropy;
117//! entropy.fill(&mut psk)?;
118//! let psk_id = b"tenant".to_vec();
119//! let client = Client::new(&public, key_id.clone(), psk.clone(), psk_id.clone(), Limits::default())?;
120//! let server = Server::new(&private, key_id, Limits::default())?;
121//! let request = Request {
122//! method: Method::Post, authority: b"api.example.test".to_vec(),
123//! path: b"/items".to_vec(), headers: Vec::new(), body: b"payload".to_vec(),
124//! };
125//! let (envelope, token) = client.protect(&request)?.into_parts();
126//! let preparsed = server.preparse(&envelope)?;
127//! assert_eq!(preparsed.credential.psk_id, psk_id);
128//! let authenticated = server.authenticate(preparsed.token, &psk)?;
129//! let opened = authenticated.token.admit(authenticated.replay.decision(true))?;
130//! assert_eq!(opened.request, request);
131//! let expected = Response { status: 200, headers: Vec::new(), body: b"ok".to_vec() };
132//! let response = opened.response.protect_finite(&expected)?;
133//! assert_eq!(token.open_finite(&response)?, expected);
134//! # Ok::<(), hpke_http::Error>(())
135//! ```
136//!
137//! [`Limits`] defaults to an 8 MiB body, 16 KiB of header bytes, 64
138//! fields, and an 8 KiB authority plus path. Bounds apply before lengths
139//! drive allocations. [`Error`] has coarse codes and no clear or secret
140//! bytes. The crate version, [`PROTOCOL_ID`], and [`BINDING_ABI_VERSION`]
141//! are separate values; bindings check all three before accepting secrets.
142
143#![forbid(unsafe_code)]
144
145mod codec;
146mod compression;
147mod engine;
148mod entropy;
149mod error;
150mod limits;
151mod message;
152mod method;
153mod response;
154mod sse;
155
156pub use compression::CompressionCoding;
157pub use engine::{
158 AuthenticatedRequest, Client, CredentialRequest, KeyPair, OpenedRequest, PreparsedRequest,
159 ProtectedRequest, ReplayDecision, ReplayRequest, ReplayToken, ResponseCapability,
160 ResponseToken, Server, StartToken, generate_key_pair, generate_key_pair_with_entropy,
161};
162pub use entropy::{EntropySource, SystemEntropy};
163pub use error::Error;
164pub use limits::{
165 HARD_MAX_BODY_LEN, HARD_MAX_HEADER_BYTES, HARD_MAX_HEADER_COUNT, HARD_MAX_ID_LEN,
166 HARD_MAX_TARGET_LEN, Limits,
167};
168pub use message::{HeaderField, Request, Response};
169pub use method::Method;
170pub use response::{ResponseHead, ResponseMode, ResponseOpener, ResponseRecord, ResponseSealer};
171pub use sse::SseSplitter;
172
173/// Language-neutral protocol identifier.
174pub const PROTOCOL_ID: &str = "hpke-http/2";
175/// Boundary ABI version used by the first-party bindings.
176pub const BINDING_ABI_VERSION: u32 = 3;
177/// Time for which a newly created request can be accepted (five minutes).
178pub const REQUEST_LIFETIME_SECS: u64 = 300;
179/// Maximum accepted client/server clock difference (30 seconds).
180pub const CLOCK_SKEW_SECS: u64 = 30;
181
182/// Immutable engine identity returned before a binding accepts secrets.
183#[derive(Clone, Copy, Debug, Eq, PartialEq)]
184pub struct BuildInfo {
185 /// Rust crate version.
186 pub version: &'static str,
187 /// Language-neutral protocol identifier.
188 pub protocol: &'static str,
189 /// Boundary ABI version.
190 pub binding_abi: u32,
191}
192
193/// Return the engine identity used for binding skew checks.
194#[must_use]
195pub const fn build_info() -> BuildInfo {
196 BuildInfo {
197 version: env!("CARGO_PKG_VERSION"),
198 protocol: PROTOCOL_ID,
199 binding_abi: BINDING_ABI_VERSION,
200 }
201}