hpke_http/lib.rs
1//! HPKE requests and checked response records for HTTP.
2//!
3//! This crate defines one wire contract, [`PROTOCOL_ID`] `hpke-http/3`.
4//! It does no network I/O. Hosts must use HTTPS. The protocol does not hide
5//! endpoints, public IDs, payload size, record counts, or timing, and it does
6//! not add padding. It uses RFC 9180 HPKE PSK mode with X25519,
7//! HKDF-SHA256, and ChaCha20-Poly1305. It is not Oblivious HTTP.
8//!
9//! # Request bytes
10//!
11//! All fixed-width integers use big-endian order. Every request, including
12//! an empty one, uses this one START/DATA/END form:
13//!
14//! ```text
15//! "HHRQ" || 0x03 || key_id_len:u8 || psk_id_len:u8
16//! || 0x0020 || 0x0001 || 0x0003 || issued_at_unix_s:u64be
17//! || key_id || psk_id || enc[32] || records...
18//! record = ciphertext_len:u32be || ciphertext[ciphertext_len]
19//! ```
20//!
21//! Both IDs have 1 to 255 bytes. The public header ends after the PSK ID.
22//! Each ciphertext includes a 16-byte HPKE tag. The first clear record is
23//! `0x01 || method:vector || authority:vector || path:vector || fields:vector`.
24//! These four fields use shortest RFC 9292 QUIC variable-length integers.
25//! `fields` contains ordered name/value vector pairs. The scheme is HTTPS.
26//! Each DATA is `0x02 || coding:u8 || coded_body`, with a nonempty clear
27//! body of at most 64 KiB. Coding 0 means raw; 1 means one zstd frame. Rust
28//! picks zstd only when it makes that DATA record shorter. It compresses
29//! clear body bytes before HPKE encryption. A reader accepts a valid zstd
30//! frame within record limits even if it is longer than its clear bytes.
31//! END is exactly `0x03`.
32//! There is one START, zero or more DATA records, one END, and then true
33//! outer HTTP body EOF. Extra bytes, missing END, and partial frames fail.
34//!
35//! One HPKE PSK context seals all request records. Its `info` is
36//! `"message/hpke-http request\0v3\0" || public_header`. Record number
37//! `n` starts at zero. Its AAD is
38//! `"hpke-http/3 request record\0" || public_header || n:u64be ||
39//! ciphertext_len:u32be`. HPKE also advances its own nonce sequence per
40//! record. A host must check END and true outer EOF before app dispatch.
41//! A logical `Content-Length`, if present, names the sum of clear DATA bytes.
42//! [`Limits::max_request_bytes`] defaults to 1 GiB and has a 4 GiB hard cap.
43//! There can be at most 1,048,576 DATA records.
44//! Complete in-memory request helpers also use [`Limits::max_body_len`],
45//! which defaults to 8 MiB. Incremental requests use the total request cap.
46//!
47//! # Public key discovery
48//!
49//! The crate does no HTTP I/O. [`Server::public_key`] returns the advertised
50//! X25519 public key. The host key GET record is `"HHKD" || 0x02 ||
51//! id_len:u8 || id || public_key[32] || use_for_s:u32be`. The ID has 1 to
52//! 255 bytes. The service sets a positive `use_for_s` lease in seconds. The
53//! record has 43 to 297 bytes. Its format version is separate from
54//! [`PROTOCOL_ID`]. There is no HHKD v1 discovery path.
55//!
56//! A host serves the record on GET at the protected HTTPS endpoint. A valid
57//! GET reply has status 200, `Content-Type: application/octet-stream`,
58//! `Cache-Control: no-store`, and an unencoded body that holds only the record.
59//! The same URL accepts a protected POST with
60//! `Content-Type: message/hpke-http-request`. A successful POST has outer
61//! status 200, `Content-Type: message/hpke-http-response`, and an unencoded
62//! body. See the [HTTP discovery guide](https://github.com/dualeai/hpke-http#key-discovery)
63//! for client checks and host key-switch rules.
64//!
65//! [`Server::with_accepted_keys`] advertises one key and accepts other keys.
66//! To switch from A to B, first make all workers advertise A and accept B.
67//! Then make all workers advertise B and accept A. After the last A lease,
68//! the bound to deliver and parse POST START, and a worker clock margin end,
69//! make all workers advertise B alone. Each accepted pair holds a private key
70//! followed by its public key ID. Do not reuse a KID while keys overlap. The
71//! host must not replay a POST after a failed or lost reply.
72//!
73//! # Replay check
74//!
75//! The replay ID is SHA-256 of
76//! `"hpke-http/replay\0v3\0" || public_header || enc`. The server checks
77//! the request time against [`REQUEST_LIFETIME_SECS`] and [`CLOCK_SKEW_SECS`].
78//! The host must make one atomic replay decision and keep it through the
79//! authenticated deadline. The app must not see clear bytes before the host
80//! checks the full request, including END and EOF.
81//!
82//! # Response bytes
83//!
84//! The HPKE context exports 32 bytes with context
85//! `"message/hpke-http response\0v3"`. The server samples a fresh 32-byte
86//! nonce. HKDF-SHA256 uses `enc || server_nonce` as salt and that exported
87//! secret as input. It expands the key with `"hpke-http/3 response key"`
88//! and nonce with `"hpke-http/3 response nonce"`. The response is:
89//!
90//! ```text
91//! "HHRP" || 0x03 || server_nonce[32] || records...
92//! record = ciphertext_len:u32be || ciphertext[ciphertext_len]
93//! ```
94//!
95//! START is `0x01 || status:u16be || fields`, with ordered name/value
96//! vectors. DATA is `0x02 || coding:u8 || coded_body`; coding 0 is raw and
97//! 1 is one zstd frame. Rust picks zstd only when it saves bytes. Each SSE
98//! DATA holds one complete SSE block after decoding.
99//! END is exactly `0x03`. The response reader needs true outer EOF after
100//! END. The record AAD is `"hpke-http/3 response record\0" ||
101//! prefix[37] || n:u64be || ciphertext_len:u32be`. The record nonce is the
102//! derived base nonce with its last eight bytes combined with `n:u64be` by XOR.
103//!
104//! [`Limits`] also bounds each finite reply, SSE block, head, and target.
105//! Errors do not include clear or secret bytes. The crate version,
106//! [`PROTOCOL_ID`], and [`BINDING_ABI_VERSION`] are separate values.
107//!
108//! # Complete transaction
109//!
110//! The host must make a shared atomic replay decision before it admits a
111//! request. This example accepts one request to show the API sequence.
112//!
113//! ```
114//! use hpke_http::{Client, Limits, Method, Request, Response, Server, generate_key_pair};
115//!
116//! fn main() -> Result<(), hpke_http::Error> {
117//! let keys = generate_key_pair()?;
118//! let (private_key, public_key) = keys.into_parts();
119//! let psk = b"example pre-shared key with 32 bytes!!";
120//! let client = Client::new(
121//! &public_key, b"key-1".to_vec(), psk.to_vec(), b"tenant-1".to_vec(),
122//! Limits::default(),
123//! )?;
124//! let server = Server::new(&private_key, b"key-1".to_vec(), Limits::default())?;
125//! let request = Request {
126//! method: Method::Post,
127//! authority: b"api.example.test".to_vec(),
128//! path: b"/items".to_vec(),
129//! headers: Vec::new(),
130//! body: b"hello".to_vec(),
131//! };
132//! let (request_bytes, response_right) = client.protect(&request)?.into_parts();
133//! let parsed = server.preparse(&request_bytes)?;
134//! let authenticated = server.authenticate(parsed.token, psk)?;
135//! let opened = authenticated.token.admit(authenticated.replay.decision(true))?;
136//! assert_eq!(opened.request, request);
137//! let reply = Response { status: 200, headers: Vec::new(), body: b"ok".to_vec() };
138//! let reply_bytes = opened.response.protect_finite(&reply)?;
139//! assert_eq!(response_right.open_finite(&reply_bytes)?, reply);
140//! Ok(())
141//! }
142//! ```
143//!
144//! # Streamed upload
145//!
146//! Send the `begin_stream` bytes first, then each record from `push`, then the
147//! bytes from `finish`. End the outer HTTP body after `finish`. On the server,
148//! hold checked DATA until `finish_eof` confirms END and real outer EOF. This
149//! example accepts one replay decision and passes bytes directly between the
150//! client and server in place of an HTTP transport. A host must use a shared
151//! atomic replay store:
152//!
153//! ```
154//! use hpke_http::{
155//! Client, Error, Limits, Method, RequestHead, Server, StreamRequestOpener,
156//! StreamRequestRecord, generate_key_pair,
157//! };
158//!
159//! fn feed(reader: &mut StreamRequestOpener, bytes: &[u8], body: &mut Vec<u8>) -> Result<(), Error> {
160//! let mut remaining = bytes;
161//! while !remaining.is_empty() {
162//! let (used, record) = reader.feed(remaining)?;
163//! remaining = &remaining[used..];
164//! if let Some(StreamRequestRecord::Data(part)) = record {
165//! body.extend_from_slice(&part);
166//! }
167//! }
168//! Ok(())
169//! }
170//!
171//! fn main() -> Result<(), Error> {
172//! let keys = generate_key_pair()?;
173//! let (private_key, public_key) = keys.into_parts();
174//! let psk = b"example pre-shared key with 32 bytes!!";
175//! let client = Client::new(&public_key, b"key-1".to_vec(), psk.to_vec(), b"tenant-1".to_vec(), Limits::default())?;
176//! let server = Server::new(&private_key, b"key-1".to_vec(), Limits::default())?;
177//! let head = RequestHead {
178//! method: Method::Post,
179//! authority: b"api.example.test".to_vec(),
180//! path: b"/upload".to_vec(),
181//! headers: Vec::new(),
182//! };
183//! let (mut writer, first) = client.begin_stream(&head)?;
184//! let parsed = server.preparse_stream(&first)?;
185//! let authenticated = server.authenticate_stream(parsed.token, psk)?;
186//! let opened = authenticated.token.admit(authenticated.replay.decision(true))?;
187//! let mut reader = opened.reader;
188//! let mut body = Vec::new();
189//! for part in [b"hello".as_slice(), b" world".as_slice()] {
190//! let mut remaining = part;
191//! while !remaining.is_empty() {
192//! let (used, record) = writer.push(remaining)?;
193//! remaining = &remaining[used..];
194//! if let Some(record) = record {
195//! feed(&mut reader, &record, &mut body)?;
196//! }
197//! }
198//! }
199//! let (end, _response_right) = writer.finish()?;
200//! feed(&mut reader, &end, &mut body)?;
201//! let _server_response_right = reader.finish_eof()?;
202//! assert_eq!(body.as_slice(), b"hello world");
203//! Ok(())
204//! }
205//! ```
206//!
207#![forbid(unsafe_code)]
208
209mod codec;
210mod compression;
211mod engine;
212mod entropy;
213mod error;
214mod limits;
215mod message;
216mod method;
217mod response;
218mod sse;
219
220pub use engine::{
221 AuthenticatedRequest, AuthenticatedStreamRequest, Client, CredentialRequest, KeyPair,
222 OpenedRequest, OpenedStreamRequest, PreparsedRequest, PreparsedStreamRequest, ProtectedRequest,
223 ReplayDecision, ReplayRequest, ReplayToken, ResponseCapability, ResponseToken, STREAM_DATA_LEN,
224 Server, StartToken, StreamReplayToken, StreamRequestOpener, StreamRequestRecord,
225 StreamRequestSealer, StreamStartToken, generate_key_pair, generate_key_pair_with_entropy,
226};
227pub use entropy::{EntropySource, SystemEntropy};
228pub use error::Error;
229pub use limits::{
230 HARD_MAX_BODY_LEN, HARD_MAX_HEADER_BYTES, HARD_MAX_HEADER_COUNT, HARD_MAX_ID_LEN,
231 HARD_MAX_REQUEST_BYTES, HARD_MAX_TARGET_LEN, Limits,
232};
233pub use message::{HeaderField, Request, RequestHead, Response};
234pub use method::Method;
235pub use response::{ResponseHead, ResponseMode, ResponseOpener, ResponseRecord, ResponseSealer};
236pub use sse::SseSplitter;
237
238/// Language-neutral protocol identifier.
239pub const PROTOCOL_ID: &str = "hpke-http/3";
240/// Boundary ABI version used by the first-party bindings.
241pub const BINDING_ABI_VERSION: u32 = 8;
242/// Time for which a newly created request can be accepted (five minutes).
243pub const REQUEST_LIFETIME_SECS: u64 = 300;
244/// Maximum accepted client/server clock difference (30 seconds).
245pub const CLOCK_SKEW_SECS: u64 = 30;
246
247/// Immutable engine identity returned before a binding accepts secrets.
248#[derive(Clone, Copy, Debug, Eq, PartialEq)]
249pub struct BuildInfo {
250 /// Rust crate version.
251 pub version: &'static str,
252 /// Language-neutral protocol identifier.
253 pub protocol: &'static str,
254 /// Boundary ABI version.
255 pub binding_abi: u32,
256}
257
258/// Return the engine identity used for binding skew checks.
259#[must_use]
260pub const fn build_info() -> BuildInfo {
261 BuildInfo {
262 version: env!("CARGO_PKG_VERSION"),
263 protocol: PROTOCOL_ID,
264 binding_abi: BINDING_ABI_VERSION,
265 }
266}