Skip to main content

actpub_httpsig/cavage/
canonical.rs

1//! Building the Cavage signature base string.
2//!
3//! Per [Cavage draft-12 §2.3][canon] the "signing string" is formed from
4//! the requested header list: each entry produces a single line of the
5//! form `<name>: <value>`, except for the pseudo-headers
6//! `(request-target)`, `(created)` and `(expires)`, which expand to
7//! implementation-defined canonical values. Lines are joined with
8//! `\n` (no trailing newline).
9//!
10//! [canon]: https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12#section-2.3
11
12use http::Request;
13
14use crate::error::Error;
15use crate::http_shared::collect_canonical_header_value;
16
17/// The `(request-target)` pseudo-header.
18pub(crate) const REQUEST_TARGET: &str = "(request-target)";
19
20/// The `(created)` pseudo-header.
21pub(crate) const CREATED: &str = "(created)";
22
23/// The `(expires)` pseudo-header.
24pub(crate) const EXPIRES: &str = "(expires)";
25
26/// Which headers to include in the signature base string, in order.
27///
28/// The order is meaningful: it must exactly match the `headers="…"`
29/// parameter emitted in the `Signature:` header so that verifiers can
30/// reproduce the same string.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct CavageHeaderSet {
33    names: Vec<String>,
34}
35
36impl CavageHeaderSet {
37    /// Creates a header set from an iterator of lowercase header names
38    /// (or pseudo-header tokens like `(request-target)`).
39    pub fn new<I, S>(names: I) -> Self
40    where
41        I: IntoIterator<Item = S>,
42        S: Into<String>,
43    {
44        Self {
45            names: names.into_iter().map(Into::into).collect(),
46        }
47    }
48
49    /// Iterates over the names in signing order.
50    pub fn iter(&self) -> std::slice::Iter<'_, String> {
51        self.names.iter()
52    }
53
54    /// Returns the space-separated `headers="…"` parameter value.
55    #[must_use]
56    pub fn join_spaces(&self) -> String {
57        self.names.join(" ")
58    }
59
60    /// Number of entries.
61    #[must_use]
62    pub const fn len(&self) -> usize {
63        self.names.len()
64    }
65
66    /// Whether the set is empty.
67    #[must_use]
68    pub const fn is_empty(&self) -> bool {
69        self.names.is_empty()
70    }
71}
72
73impl IntoIterator for CavageHeaderSet {
74    type Item = String;
75    type IntoIter = std::vec::IntoIter<String>;
76
77    fn into_iter(self) -> Self::IntoIter {
78        self.names.into_iter()
79    }
80}
81
82impl<'a> IntoIterator for &'a CavageHeaderSet {
83    type Item = &'a String;
84    type IntoIter = std::slice::Iter<'a, String>;
85
86    fn into_iter(self) -> Self::IntoIter {
87        self.names.iter()
88    }
89}
90
91impl<S: Into<String>> FromIterator<S> for CavageHeaderSet {
92    fn from_iter<I: IntoIterator<Item = S>>(iter: I) -> Self {
93        Self::new(iter)
94    }
95}
96
97/// Parameters required to expand `(created)` / `(expires)`.
98///
99/// When the signer does not emit these pseudo-headers the values are
100/// ignored. Verifiers receive them via [`CavageHeaderParams`](super::CavageHeaderParams).
101#[derive(Debug, Clone, Copy, Default)]
102pub(crate) struct Timestamps {
103    pub created: Option<i64>,
104    pub expires: Option<i64>,
105}
106
107/// Builds the canonical signature base string for `req` using `headers`.
108///
109/// # Errors
110///
111/// Returns [`Error::RequiredHeaderAbsent`] if any requested header is not
112/// present on the request.
113pub(crate) fn build_signature_base<B>(
114    req: &Request<B>,
115    headers: &CavageHeaderSet,
116    timestamps: Timestamps,
117) -> Result<String, Error> {
118    if headers.is_empty() {
119        return Err(Error::MalformedSignatureHeader(
120            "`headers` parameter must not be empty".into(),
121        ));
122    }
123
124    let mut out = String::new();
125    for (i, name) in headers.iter().enumerate() {
126        if i > 0 {
127            out.push('\n');
128        }
129        write_line(req, name, timestamps, &mut out)?;
130    }
131    Ok(out)
132}
133
134#[allow(
135    clippy::expect_used,
136    clippy::unwrap_in_result,
137    reason = "writing to an owned `String` via `core::fmt::Write` is infallible; the `Result` only exists to satisfy the trait"
138)]
139fn write_line<B>(
140    req: &Request<B>,
141    name: &str,
142    ts: Timestamps,
143    out: &mut String,
144) -> Result<(), Error> {
145    use core::fmt::Write as _;
146    let infallible = "writing to an owned String is infallible";
147    match name {
148        REQUEST_TARGET => {
149            let method = req.method().as_str().to_lowercase();
150            let target = req
151                .uri()
152                .path_and_query()
153                .map_or_else(|| req.uri().path().to_owned(), ToString::to_string);
154            write!(out, "{REQUEST_TARGET}: {method} {target}").expect(infallible);
155        }
156        CREATED => {
157            let value = ts
158                .created
159                .ok_or(Error::MissingSignatureParameter("created"))?;
160            write!(out, "{CREATED}: {value}").expect(infallible);
161        }
162        EXPIRES => {
163            let value = ts
164                .expires
165                .ok_or(Error::MissingSignatureParameter("expires"))?;
166            write!(out, "{EXPIRES}: {value}").expect(infallible);
167        }
168        other => {
169            let lowered = other.to_ascii_lowercase();
170            let value = collect_canonical_header_value(req, &lowered)
171                .ok_or_else(|| Error::RequiredHeaderAbsent(lowered.clone()))?;
172            write!(out, "{lowered}: {value}").expect(infallible);
173        }
174    }
175    Ok(())
176}
177
178#[cfg(test)]
179mod tests {
180    use http::{Method, Request};
181    use pretty_assertions::assert_eq;
182
183    use super::*;
184
185    fn sample_request() -> Request<Vec<u8>> {
186        Request::builder()
187            .method(Method::POST)
188            .uri("https://example.com/inbox?a=1")
189            .header("host", "example.com")
190            .header("date", "Sun, 05 Jan 2014 21:31:40 GMT")
191            .header("digest", "SHA-256=X48E9qOok=")
192            .header("content-type", "application/activity+json")
193            .body(Vec::new())
194            .expect("valid request")
195    }
196
197    #[test]
198    fn request_target_expands_to_lowercase_method_and_path_query() {
199        let req = sample_request();
200        let set = CavageHeaderSet::new([REQUEST_TARGET]);
201        let base = build_signature_base(&req, &set, Timestamps::default()).unwrap();
202        assert_eq!(base, "(request-target): post /inbox?a=1");
203    }
204
205    #[test]
206    fn header_values_are_trimmed_and_lowercased_name() {
207        let mut req = sample_request();
208        req.headers_mut().insert(
209            "x-custom",
210            "   spaces around   ".parse().expect("valid header"),
211        );
212        let set = CavageHeaderSet::new(["Host", "X-Custom"]);
213        let base = build_signature_base(&req, &set, Timestamps::default()).unwrap();
214        assert_eq!(base, "host: example.com\nx-custom: spaces around");
215    }
216
217    #[test]
218    fn missing_header_produces_required_header_absent() {
219        let req = sample_request();
220        let set = CavageHeaderSet::new(["authorization"]);
221        let err = build_signature_base(&req, &set, Timestamps::default())
222            .expect_err("missing header must error");
223        match err {
224            Error::RequiredHeaderAbsent(name) => assert_eq!(name, "authorization"),
225            other => panic!("unexpected error: {other:?}"),
226        }
227    }
228
229    #[test]
230    fn full_cavage_default_base_string() {
231        let req = sample_request();
232        let set = CavageHeaderSet::new([REQUEST_TARGET, "host", "date", "digest"]);
233        let base = build_signature_base(&req, &set, Timestamps::default()).unwrap();
234        assert_eq!(
235            base,
236            "(request-target): post /inbox?a=1\n\
237             host: example.com\n\
238             date: Sun, 05 Jan 2014 21:31:40 GMT\n\
239             digest: SHA-256=X48E9qOok=",
240        );
241    }
242
243    #[test]
244    fn created_and_expires_consume_timestamps() {
245        let req = sample_request();
246        let set = CavageHeaderSet::new([CREATED, EXPIRES]);
247        let ts = Timestamps {
248            created: Some(1_234_567_890),
249            expires: Some(1_234_568_000),
250        };
251        let base = build_signature_base(&req, &set, ts).unwrap();
252        assert_eq!(base, "(created): 1234567890\n(expires): 1234568000",);
253    }
254
255    #[test]
256    fn repeated_header_values_are_concatenated_comma_space() {
257        let req = Request::builder()
258            .method(Method::GET)
259            .uri("https://example.com/")
260            .header("forwarded", "for=1.1.1.1")
261            .header("forwarded", "for=2.2.2.2")
262            .body(Vec::<u8>::new())
263            .expect("request");
264        let set = CavageHeaderSet::new(["forwarded"]);
265        let base = build_signature_base(&req, &set, Timestamps::default()).unwrap();
266        assert_eq!(base, "forwarded: for=1.1.1.1, for=2.2.2.2");
267    }
268}