Expand description
HPKE requests and checked response records for HTTP.
This crate defines one wire contract, PROTOCOL_ID hpke-http/3.
It does no network I/O. Hosts must use HTTPS. The protocol does not hide
endpoints, public IDs, payload size, record counts, or timing, and it does
not add padding. It uses RFC 9180 HPKE PSK mode with X25519,
HKDF-SHA256, and ChaCha20-Poly1305. It is not Oblivious HTTP.
§Request bytes
All fixed-width integers use big-endian order. Every request, including an empty one, uses this one START/DATA/END form:
"HHRQ" || 0x03 || key_id_len:u8 || psk_id_len:u8
|| 0x0020 || 0x0001 || 0x0003 || issued_at_unix_s:u64be
|| key_id || psk_id || enc[32] || records...
record = ciphertext_len:u32be || ciphertext[ciphertext_len]Both IDs have 1 to 255 bytes. The public header ends after the PSK ID.
Each ciphertext includes a 16-byte HPKE tag. The first clear record is
0x01 || method:vector || authority:vector || path:vector || fields:vector.
These four fields use shortest RFC 9292 QUIC variable-length integers.
fields contains ordered name/value vector pairs. The scheme is HTTPS.
Each DATA is 0x02 || coding:u8 || coded_body, with a nonempty clear
body of at most 64 KiB. Coding 0 means raw; 1 means one zstd frame. Rust
picks zstd only when it makes that DATA record shorter. It compresses
clear body bytes before HPKE encryption. A reader accepts a valid zstd
frame within record limits even if it is longer than its clear bytes.
END is exactly 0x03.
There is one START, zero or more DATA records, one END, and then true
outer HTTP body EOF. Extra bytes, missing END, and partial frames fail.
One HPKE PSK context seals all request records. Its info is
"message/hpke-http request\0v3\0" || public_header. Record number
n starts at zero. Its AAD is
"hpke-http/3 request record\0" || public_header || n:u64be || ciphertext_len:u32be. HPKE also advances its own nonce sequence per
record. A host must check END and true outer EOF before app dispatch.
A logical Content-Length, if present, names the sum of clear DATA bytes.
Limits::max_request_bytes defaults to 1 GiB and has a 4 GiB hard cap.
There can be at most 1,048,576 DATA records.
Complete in-memory request helpers also use Limits::max_body_len,
which defaults to 8 MiB. Incremental requests use the total request cap.
§Public key discovery
The crate does no HTTP I/O. Server::public_key returns the advertised
X25519 public key. The host key GET record is "HHKD" || 0x02 || id_len:u8 || id || public_key[32] || use_for_s:u32be. The ID has 1 to
255 bytes. The service sets a positive use_for_s lease in seconds. The
record has 43 to 297 bytes. Its format version is separate from
PROTOCOL_ID. There is no HHKD v1 discovery path.
A host serves the record on GET at the protected HTTPS endpoint. A valid
GET reply has status 200, Content-Type: application/octet-stream,
Cache-Control: no-store, and an unencoded body that holds only the record.
The same URL accepts a protected POST with
Content-Type: message/hpke-http-request. A successful POST has outer
status 200, Content-Type: message/hpke-http-response, and an unencoded
body. See the HTTP discovery guide
for client checks and host key-switch rules.
Server::with_accepted_keys advertises one key and accepts other keys.
To switch from A to B, first make all workers advertise A and accept B.
Then make all workers advertise B and accept A. After the last A lease,
the bound to deliver and parse POST START, and a worker clock margin end,
make all workers advertise B alone. Each accepted pair holds a private key
followed by its public key ID. Do not reuse a KID while keys overlap. The
host must not replay a POST after a failed or lost reply.
§Replay check
The replay ID is SHA-256 of
"hpke-http/replay\0v3\0" || public_header || enc. The server checks
the request time against REQUEST_LIFETIME_SECS and CLOCK_SKEW_SECS.
The host must make one atomic replay decision and keep it through the
authenticated deadline. The app must not see clear bytes before the host
checks the full request, including END and EOF.
§Response bytes
The HPKE context exports 32 bytes with context
"message/hpke-http response\0v3". The server samples a fresh 32-byte
nonce. HKDF-SHA256 uses enc || server_nonce as salt and that exported
secret as input. It expands the key with "hpke-http/3 response key"
and nonce with "hpke-http/3 response nonce". The response is:
"HHRP" || 0x03 || server_nonce[32] || records...
record = ciphertext_len:u32be || ciphertext[ciphertext_len]START is 0x01 || status:u16be || fields, with ordered name/value
vectors. DATA is 0x02 || coding:u8 || coded_body; coding 0 is raw and
1 is one zstd frame. Rust picks zstd only when it saves bytes. Each SSE
DATA holds one complete SSE block after decoding.
END is exactly 0x03. The response reader needs true outer EOF after
END. The record AAD is "hpke-http/3 response record\0" || prefix[37] || n:u64be || ciphertext_len:u32be. The record nonce is the
derived base nonce with its last eight bytes combined with n:u64be by XOR.
Limits also bounds each finite reply, SSE block, head, and target.
Errors do not include clear or secret bytes. The crate version,
PROTOCOL_ID, and BINDING_ABI_VERSION are separate values.
§Complete transaction
The host must make a shared atomic replay decision before it admits a request. This example accepts one request to show the API sequence.
use hpke_http::{Client, Limits, Method, Request, Response, Server, generate_key_pair};
fn main() -> Result<(), hpke_http::Error> {
let keys = generate_key_pair()?;
let (private_key, public_key) = keys.into_parts();
let psk = b"example pre-shared key with 32 bytes!!";
let client = Client::new(
&public_key, b"key-1".to_vec(), psk.to_vec(), b"tenant-1".to_vec(),
Limits::default(),
)?;
let server = Server::new(&private_key, b"key-1".to_vec(), 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"hello".to_vec(),
};
let (request_bytes, response_right) = client.protect(&request)?.into_parts();
let parsed = server.preparse(&request_bytes)?;
let authenticated = server.authenticate(parsed.token, psk)?;
let opened = authenticated.token.admit(authenticated.replay.decision(true))?;
assert_eq!(opened.request, request);
let reply = Response { status: 200, headers: Vec::new(), body: b"ok".to_vec() };
let reply_bytes = opened.response.protect_finite(&reply)?;
assert_eq!(response_right.open_finite(&reply_bytes)?, reply);
Ok(())
}§Streamed upload
Send the begin_stream bytes first, then each record from push, then the
bytes from finish. End the outer HTTP body after finish. On the server,
hold checked DATA until finish_eof confirms END and real outer EOF. This
example accepts one replay decision and passes bytes directly between the
client and server in place of an HTTP transport. A host must use a shared
atomic replay store:
use hpke_http::{
Client, Error, Limits, Method, RequestHead, Server, StreamRequestOpener,
StreamRequestRecord, generate_key_pair,
};
fn feed(reader: &mut StreamRequestOpener, bytes: &[u8], body: &mut Vec<u8>) -> Result<(), Error> {
let mut remaining = bytes;
while !remaining.is_empty() {
let (used, record) = reader.feed(remaining)?;
remaining = &remaining[used..];
if let Some(StreamRequestRecord::Data(part)) = record {
body.extend_from_slice(&part);
}
}
Ok(())
}
fn main() -> Result<(), Error> {
let keys = generate_key_pair()?;
let (private_key, public_key) = keys.into_parts();
let psk = b"example pre-shared key with 32 bytes!!";
let client = Client::new(&public_key, b"key-1".to_vec(), psk.to_vec(), b"tenant-1".to_vec(), Limits::default())?;
let server = Server::new(&private_key, b"key-1".to_vec(), Limits::default())?;
let head = RequestHead {
method: Method::Post,
authority: b"api.example.test".to_vec(),
path: b"/upload".to_vec(),
headers: Vec::new(),
};
let (mut writer, first) = client.begin_stream(&head)?;
let parsed = server.preparse_stream(&first)?;
let authenticated = server.authenticate_stream(parsed.token, psk)?;
let opened = authenticated.token.admit(authenticated.replay.decision(true))?;
let mut reader = opened.reader;
let mut body = Vec::new();
for part in [b"hello".as_slice(), b" world".as_slice()] {
let mut remaining = part;
while !remaining.is_empty() {
let (used, record) = writer.push(remaining)?;
remaining = &remaining[used..];
if let Some(record) = record {
feed(&mut reader, &record, &mut body)?;
}
}
}
let (end, _response_right) = writer.finish()?;
feed(&mut reader, &end, &mut body)?;
let _server_response_right = reader.finish_eof()?;
assert_eq!(body.as_slice(), b"hello world");
Ok(())
}Structs§
- Authenticated
Request - Result of authentication, paused before replay admission and plaintext release.
- Authenticated
Stream Request - Authenticated START, with plaintext withheld until replay admission.
- Build
Info - Immutable engine identity returned before a binding accepts secrets.
- Client
- A client configured for one recipient key and one explicit PSK identity.
- Credential
Request - Public information needed by a host credential resolver.
- Header
Field - One canonical end-to-end HTTP field in an ordered field section.
- KeyPair
- A generated X25519 recipient key pair.
- Limits
- Per-engine limits. Set values within the hard limits.
- Opened
Request - A fully authenticated request and its one-use response capability.
- Opened
Stream Request - Checked request head and a live body reader. The response right waits for END and EOF.
- Preparsed
Request - Result of the bounded public preparse stage.
- Preparsed
Stream Request - Public credential ID and a one-use bounded START token.
- Protected
Request - A request envelope and the one-use capability needed to open its response.
- Replay
Decision - One atomic replay-provider result bound to its request ID.
- Replay
Request - Stable request identifier passed to an atomic replay-admission provider.
- Replay
Token - Opaque one-use continuation after request authentication.
- Request
- A complete plaintext request supplied to or returned by the engine.
- Request
Head - Request fields without a body. Client input before protection and checked fields after server admission.
- Response
- A complete plaintext response supplied to or returned by the engine.
- Response
Capability - One-use server capability for protecting the response to one opened request.
- Response
Head - Fields released after a valid START tag.
- Response
Opener - A checked record reader that accepts any byte cuts.
- Response
Sealer - One response writer. Its key and sequence cannot be reused after failure.
- Response
Token - One-use client capability for opening the response to one request.
- Server
- A server with one advertised recipient key and other accepted keys.
- SseSplitter
- One bounded partial SSE block. Line endings on the wire are LF.
- Start
Token - Opaque one-use continuation after public preparse.
- Stream
Replay Token - One-use continuation after START authentication.
- Stream
Request Opener - Incremental request reader with bounded frame storage.
- Stream
Request Sealer - One request writer. A failed call closes it and prevents a valid END.
- Stream
Start Token - Opaque START bytes before credential resolution.
- System
Entropy - The operating system or Web Crypto entropy source selected by
getrandom.
Enums§
- Error
- Errors returned by the protocol engine.
- Method
- Supported HTTP request methods.
- Response
Mode - The checked response kind, based on its single Content-Type field.
- Response
Record - One checked record. Finite body bytes remain in the opener until END.
- Stream
Request Record - One checked upload record.
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_ REQUEST_ BYTES - Absolute clear request body limit (4 GiB).
- 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).
- STREAM_
DATA_ LEN - Largest clear DATA part in a protected upload.
Traits§
- Entropy
Source - 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.