Skip to main content

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}