wimsey_httpsig/message.rs
1//! Minimal HTTP request and response models, and the covered-component values
2//! derived from them, per RFC 9421 Section 2.
3
4use base64::{engine::general_purpose::STANDARD, Engine};
5use sha2::{Digest, Sha256};
6
7use crate::error::HttpSigError;
8
9/// A covered component of an HTTP message signature.
10///
11/// This crate supports the derived components `@method`, `@authority`, `@path`,
12/// `@query`, `@request-target` and `@status`, plus plain header fields and the
13/// `;req` component parameter. `@target-uri` and the other parameters (for
14/// example `;sf` or `;key`) are not modeled.
15#[derive(Debug, Clone)]
16pub enum Component {
17 /// The request method (`@method`).
18 Method,
19 /// The request authority (`@authority`), lowercased.
20 Authority,
21 /// The absolute path (`@path`).
22 Path,
23 /// The query string including the leading `?` (`@query`).
24 Query,
25 /// The request target (`@request-target`): the absolute path followed by
26 /// `?` and the query when one is present (RFC 9421 Section 2.2.5,
27 /// origin-form). The WIMSE profile requires this component to be signed.
28 RequestTarget,
29 /// The response status code (`@status`), RFC 9421 Section 2.2.9. Only
30 /// meaningful on a response.
31 Status,
32 /// A component taken from the *request* a response answers, written with
33 /// the `;req` parameter (RFC 9421 Section 2.4) — for example
34 /// `"@method";req`. The WIMSE profile requires two of these on a signed
35 /// response, so that the response cannot be lifted onto a different
36 /// request.
37 Req(Box<Component>),
38 /// A header field, identified by its lowercase name.
39 Header(String),
40}
41
42// Header names are compared case-insensitively so a component built directly as
43// `Component::Header("Content-Type".into())` still matches a parsed, lowercased
44// one — for example in `VerifyConfig::required_components`.
45impl PartialEq for Component {
46 fn eq(&self, other: &Self) -> bool {
47 match (self, other) {
48 (Self::Method, Self::Method)
49 | (Self::Authority, Self::Authority)
50 | (Self::Path, Self::Path)
51 | (Self::Query, Self::Query)
52 | (Self::RequestTarget, Self::RequestTarget)
53 | (Self::Status, Self::Status) => true,
54 (Self::Req(a), Self::Req(b)) => a == b,
55 (Self::Header(a), Self::Header(b)) => a.eq_ignore_ascii_case(b),
56 _ => false,
57 }
58 }
59}
60
61impl Eq for Component {}
62
63impl Component {
64 /// A header component from any-case `name`.
65 #[must_use]
66 pub fn header(name: &str) -> Self {
67 Self::Header(name.to_ascii_lowercase())
68 }
69
70 /// The quoted component identifier as it appears in the signature base and
71 /// the inner list (for example `"@method"` or `"content-type"`).
72 #[must_use]
73 pub fn quoted_id(&self) -> String {
74 match self {
75 Self::Method => "\"@method\"".to_owned(),
76 Self::Authority => "\"@authority\"".to_owned(),
77 Self::Path => "\"@path\"".to_owned(),
78 Self::Query => "\"@query\"".to_owned(),
79 Self::RequestTarget => "\"@request-target\"".to_owned(),
80 Self::Status => "\"@status\"".to_owned(),
81 // The parameter sits outside the quotes: `"@method";req`.
82 Self::Req(inner) => format!("{};req", inner.quoted_id()),
83 Self::Header(name) => format!("\"{name}\""),
84 }
85 }
86
87 /// Parses a quoted component identifier back into a [`Component`].
88 ///
89 /// # Errors
90 ///
91 /// Returns [`HttpSigError::UnsupportedComponent`] for an identifier this
92 /// crate does not model (including any carrying parameters), and
93 /// [`HttpSigError::Parse`] if the token is not a quoted string.
94 pub fn from_quoted_id(token: &str) -> Result<Self, HttpSigError> {
95 // `;req` is the only component parameter this crate models.
96 if let Some(base) = token.strip_suffix(";req") {
97 return Ok(Self::Req(Box::new(Self::from_quoted_id(base)?)));
98 }
99 let inner = token
100 .strip_prefix('"')
101 .and_then(|t| t.strip_suffix('"'))
102 .ok_or_else(|| HttpSigError::Parse(format!("not a quoted identifier: {token}")))?;
103 match inner {
104 "@method" => Ok(Self::Method),
105 "@authority" => Ok(Self::Authority),
106 "@path" => Ok(Self::Path),
107 "@query" => Ok(Self::Query),
108 "@request-target" => Ok(Self::RequestTarget),
109 "@status" => Ok(Self::Status),
110 name if name.starts_with('@') => {
111 Err(HttpSigError::UnsupportedComponent(inner.to_owned()))
112 }
113 // RFC 9421 Section 2.1: header component identifiers are lowercase.
114 name => Ok(Self::Header(name.to_ascii_lowercase())),
115 }
116 }
117}
118
119/// A minimal HTTP request, sufficient to derive RFC 9421 component values.
120#[derive(Debug, Clone)]
121pub struct HttpRequest {
122 /// The request method, used as-is (case sensitive).
123 pub method: String,
124 /// The authority (`host[:port]`). It is lowercased for `@authority`, but the
125 /// default port is not stripped (the scheme is not modeled), so the caller
126 /// must remove a default port (`:80`/`:443`) itself to interoperate.
127 pub authority: String,
128 /// The absolute path; an empty path derives as `/`.
129 pub path: String,
130 /// The query string without the leading `?`, if any.
131 pub query: Option<String>,
132 /// Header fields as `(name, value)` pairs; names may be any case.
133 pub headers: Vec<(String, String)>,
134}
135
136impl HttpRequest {
137 /// The derived value of `component` for this request.
138 ///
139 /// # Errors
140 ///
141 /// Returns [`HttpSigError::MissingComponent`] if a header component is not
142 /// present in the request.
143 pub fn component_value(&self, component: &Component) -> Result<String, HttpSigError> {
144 match component {
145 Component::Method => Ok(self.method.clone()),
146 Component::Authority => Ok(self.authority.to_ascii_lowercase()),
147 Component::Path => Ok(if self.path.is_empty() {
148 "/".to_owned()
149 } else {
150 self.path.clone()
151 }),
152 Component::Query => Ok(format!("?{}", self.query.as_deref().unwrap_or(""))),
153 Component::RequestTarget => Ok(self.request_target()),
154 Component::Header(name) => self.header_value(name),
155 // A request has no status, and nothing for `;req` to refer back to.
156 Component::Status | Component::Req(_) => {
157 Err(HttpSigError::UnsupportedComponent(component.quoted_id()))
158 }
159 }
160 }
161
162 /// The origin-form request target: the absolute path, with `?` and the query
163 /// appended only when a query component is present.
164 ///
165 /// Unlike `@query` — which derives as a bare `?` when there is no query —
166 /// `@request-target` omits the delimiter entirely, so `/foo` and `/foo?`
167 /// stay distinguishable.
168 fn request_target(&self) -> String {
169 let path = if self.path.is_empty() {
170 "/"
171 } else {
172 &self.path
173 };
174 match &self.query {
175 Some(query) => format!("{path}?{query}"),
176 None => path.to_owned(),
177 }
178 }
179
180 /// The RFC 9421 field value for header `name`.
181 fn header_value(&self, name: &str) -> Result<String, HttpSigError> {
182 header_value(&self.headers, name)
183 }
184}
185
186/// A minimal HTTP response, sufficient to derive RFC 9421 component values.
187#[derive(Debug, Clone)]
188pub struct HttpResponse {
189 /// The status code, derived as `@status`.
190 pub status: u16,
191 /// Header fields as `(name, value)` pairs; names may be any case.
192 pub headers: Vec<(String, String)>,
193}
194
195/// A response together with the request it answers.
196///
197/// Both are needed to sign or verify a response: `;req` components are taken
198/// from the request, which is what stops a signed response being lifted onto a
199/// different one.
200#[derive(Debug, Clone, Copy)]
201pub struct HttpExchange<'a> {
202 /// The response being signed or verified.
203 pub response: &'a HttpResponse,
204 /// The request it answers.
205 pub request: &'a HttpRequest,
206}
207
208/// Something a signature base can read covered component values from.
209///
210/// Requests and response exchanges resolve components differently, but the
211/// signature base is built by one piece of code over this trait rather than
212/// duplicated per message kind — the base is byte-exact, and two
213/// implementations of it would eventually disagree.
214pub trait ComponentSource {
215 /// The derived value of `component` for this message.
216 ///
217 /// # Errors
218 ///
219 /// Returns [`HttpSigError::MissingComponent`] if a covered header is absent,
220 /// or [`HttpSigError::UnsupportedComponent`] if the component cannot be
221 /// derived from this kind of message.
222 fn component_value(&self, component: &Component) -> Result<String, HttpSigError>;
223}
224
225impl ComponentSource for HttpRequest {
226 fn component_value(&self, component: &Component) -> Result<String, HttpSigError> {
227 HttpRequest::component_value(self, component)
228 }
229}
230
231impl ComponentSource for HttpExchange<'_> {
232 fn component_value(&self, component: &Component) -> Result<String, HttpSigError> {
233 match component {
234 Component::Status => Ok(self.response.status.to_string()),
235 Component::Req(inner) => self.request.component_value(inner),
236 Component::Header(name) => header_value(&self.response.headers, name),
237 // The rest are request-only, and on a response must be written `;req`.
238 other => Err(HttpSigError::UnsupportedComponent(other.quoted_id())),
239 }
240 }
241}
242
243/// The RFC 9421 field value for header `name`: every matching field, each
244/// trimmed of leading and trailing whitespace, joined with `, `.
245fn header_value(headers: &[(String, String)], name: &str) -> Result<String, HttpSigError> {
246 let mut values = headers
247 .iter()
248 .filter(|(n, _)| n.eq_ignore_ascii_case(name))
249 .map(|(_, v)| v.trim())
250 .peekable();
251 if values.peek().is_none() {
252 return Err(HttpSigError::MissingComponent(name.to_owned()));
253 }
254 Ok(values.collect::<Vec<_>>().join(", "))
255}
256
257/// Computes a `Content-Digest` field value over `body` using SHA-256, in the
258/// RFC 9530 dictionary form `sha-256=:<base64>:`.
259#[must_use]
260pub fn content_digest_sha256(body: &[u8]) -> String {
261 format!("sha-256=:{}:", STANDARD.encode(Sha256::digest(body)))
262}
263
264/// Checks a `Content-Digest` header value against `body` for the SHA-256 form.
265///
266/// Covering the `content-digest` header in a signature only integrity-protects
267/// the header *string*. To bind the actual body, the receiver MUST also call
268/// this (and MUST have covered `content-digest` in the signature). Returns
269/// `true` only if `header_value` exactly equals the recomputed
270/// `sha-256=:<base64>:` digest of `body`; other digest algorithms or multi-member
271/// values are not recognized and return `false`.
272#[must_use]
273pub fn verify_content_digest(header_value: &str, body: &[u8]) -> bool {
274 header_value == content_digest_sha256(body)
275}
276
277#[cfg(test)]
278mod tests {
279 use super::{content_digest_sha256, verify_content_digest, Component, HttpRequest};
280
281 #[test]
282 fn parses_header_identifiers_case_insensitively() {
283 // RFC 9421 identifiers are lowercase; a mixed-case one normalizes so it
284 // matches a component built with `Component::header`.
285 let parsed = Component::from_quoted_id("\"Content-Type\"").unwrap();
286 assert_eq!(parsed, Component::header("content-type"));
287 }
288
289 #[test]
290 fn joins_repeated_headers_and_trims() {
291 let request = HttpRequest {
292 method: "GET".to_owned(),
293 authority: "EXAMPLE.com".to_owned(),
294 path: String::new(),
295 query: None,
296 headers: vec![
297 ("Accept".to_owned(), " text/plain ".to_owned()),
298 ("accept".to_owned(), "application/json".to_owned()),
299 ],
300 };
301 assert_eq!(
302 request
303 .component_value(&Component::header("accept"))
304 .unwrap(),
305 "text/plain, application/json"
306 );
307 // `@authority` is lowercased; an empty `@path` becomes `/`.
308 assert_eq!(
309 request.component_value(&Component::Authority).unwrap(),
310 "example.com"
311 );
312 assert_eq!(request.component_value(&Component::Path).unwrap(), "/");
313 }
314
315 // `@request-target` is origin-form: path plus `?query` only when a query is
316 // actually present, unlike `@query`, which always emits the `?`.
317 #[test]
318 fn derives_the_request_target() {
319 let mut request = HttpRequest {
320 method: "POST".to_owned(),
321 authority: "example.com".to_owned(),
322 path: "/foo".to_owned(),
323 query: Some("param=Value&Pet=dog".to_owned()),
324 headers: vec![],
325 };
326 assert_eq!(
327 request.component_value(&Component::RequestTarget).unwrap(),
328 "/foo?param=Value&Pet=dog"
329 );
330
331 request.query = None;
332 assert_eq!(
333 request.component_value(&Component::RequestTarget).unwrap(),
334 "/foo"
335 );
336
337 request.path = String::new();
338 assert_eq!(
339 request.component_value(&Component::RequestTarget).unwrap(),
340 "/"
341 );
342
343 // An empty-but-present query keeps its delimiter.
344 request.query = Some(String::new());
345 assert_eq!(
346 request.component_value(&Component::RequestTarget).unwrap(),
347 "/?"
348 );
349 }
350
351 #[test]
352 fn parses_the_request_target_identifier() {
353 assert_eq!(
354 Component::from_quoted_id("\"@request-target\"").unwrap(),
355 Component::RequestTarget
356 );
357 assert_eq!(Component::RequestTarget.quoted_id(), "\"@request-target\"");
358 }
359
360 #[test]
361 fn content_digest_round_trips() {
362 let body = b"payload";
363 assert!(verify_content_digest(&content_digest_sha256(body), body));
364 }
365
366 #[test]
367 fn header_components_compare_case_insensitively() {
368 assert_eq!(
369 Component::Header("Content-Type".to_owned()),
370 Component::header("content-type")
371 );
372 assert_ne!(Component::header("a"), Component::header("b"));
373 }
374}