Skip to main content

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}