Skip to main content

actpub_httpsig/rfc9421/
components.rs

1//! Derived components and header references for RFC 9421 signatures.
2//!
3//! The signature base is a sequence of lines, each of the form
4//! `"<component-identifier>": <canonicalised-value>`. Identifiers starting
5//! with `@` are "derived components" computed from the request itself;
6//! all others name HTTP headers. This module implements the subset
7//! actually used by real-world `ActivityPub` deployments:
8//!
9//! | Identifier        | Value                                    |
10//! | ----------------- | ---------------------------------------- |
11//! | `@method`         | HTTP method, upper-case                  |
12//! | `@target-uri`     | full request target URL                  |
13//! | `@authority`      | `Host` header / authority, lowercase     |
14//! | `@scheme`         | scheme, lowercase (`http` / `https`)     |
15//! | `@path`           | URI path                                 |
16//! | `@query`          | URI query string including the `?`, `?` when absent |
17//! | `@request-target` | `<path-and-query>` (method excluded per §2.2.5)      |
18//! | `<header-name>`   | comma-joined values, OWS-trimmed         |
19//!
20//! `@query-param`, `@status` and the `;req`, `;bs`, `;sf`, `;tr`, `;name`
21//! parameters are intentionally out of scope for the initial release;
22//! they can be added when a real interoperator demands them.
23
24use http::Request;
25
26use crate::error::Error;
27use crate::http_shared::collect_canonical_header_value;
28
29/// A single component in an RFC 9421 signature base.
30///
31/// The [`Component::lexical`] representation is the quoted string that
32/// appears in the signature base and in the `Signature-Input:` header
33/// inner list.
34#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35#[non_exhaustive]
36pub enum Component {
37    /// HTTP method (upper-case).
38    Method,
39    /// Full request target URI.
40    TargetUri,
41    /// Authority (`host` equivalent).
42    Authority,
43    /// Request scheme (`http` / `https`).
44    Scheme,
45    /// URI path component.
46    Path,
47    /// URI query component including the leading `?`. When the
48    /// request URI has no query, the canonical value is the
49    /// single character `?` per [RFC 9421 §2.2.7][q].
50    ///
51    /// [q]: https://www.rfc-editor.org/rfc/rfc9421.html#section-2.2.7
52    Query,
53    /// `@request-target` derived component: path and query of the
54    /// request target, **without the method**, per
55    /// [RFC 9421 §2.2.5][rt]. This is semantically distinct from
56    /// Cavage's `(request-target)` pseudo-header which does include
57    /// the method; the Cavage signer / verifier handles that shape
58    /// in [`crate::cavage`] independently.
59    ///
60    /// [rt]: https://www.rfc-editor.org/rfc/rfc9421.html#section-2.2.5
61    RequestTarget,
62    /// An ordinary lower-cased HTTP header name.
63    Header(String),
64}
65
66impl Component {
67    /// Returns the quoted lexical form that appears in a `Signature-Input:`
68    /// inner list and in the signature base.
69    #[must_use]
70    pub fn lexical(&self) -> String {
71        format!(r#""{}""#, self.identifier())
72    }
73
74    /// Returns the raw identifier without quotes.
75    #[must_use]
76    pub fn identifier(&self) -> &str {
77        match self {
78            Self::Method => "@method",
79            Self::TargetUri => "@target-uri",
80            Self::Authority => "@authority",
81            Self::Scheme => "@scheme",
82            Self::Path => "@path",
83            Self::Query => "@query",
84            Self::RequestTarget => "@request-target",
85            Self::Header(name) => name,
86        }
87    }
88
89    /// Parses an identifier back into a [`Component`].
90    ///
91    /// # Errors
92    ///
93    /// Returns [`Error::UnsupportedAlgorithm`] for any `@`-prefixed
94    /// identifier that is not one of the seven supported derived
95    /// components (`@method`, `@target-uri`, `@authority`, `@scheme`,
96    /// `@path`, `@query`, `@request-target`). Header names are accepted
97    /// verbatim and lower-cased.
98    pub fn parse(identifier: &str) -> Result<Self, Error> {
99        if !identifier.starts_with('@') {
100            return Ok(Self::Header(identifier.to_ascii_lowercase()));
101        }
102        Ok(match identifier {
103            "@method" => Self::Method,
104            "@target-uri" => Self::TargetUri,
105            "@authority" => Self::Authority,
106            "@scheme" => Self::Scheme,
107            "@path" => Self::Path,
108            "@query" => Self::Query,
109            "@request-target" => Self::RequestTarget,
110            other => {
111                return Err(Error::UnsupportedAlgorithm(format!(
112                    "derived component `{other}` is not supported"
113                )));
114            }
115        })
116    }
117}
118
119/// Canonicalises a component's value against `req`.
120///
121/// # Errors
122///
123/// Returns [`Error::RequiredHeaderAbsent`] when a header reference
124/// cannot be resolved on the request, including the [`http::header::HOST`]
125/// header that [`Component::Authority`] and [`Component::TargetUri`]
126/// depend on when the request URI is in relative form.
127pub(crate) fn canonical_value<B>(component: &Component, req: &Request<B>) -> Result<String, Error> {
128    match component {
129        Component::Method => Ok(req.method().as_str().to_uppercase()),
130        Component::TargetUri => target_uri(req),
131        Component::Authority => authority(req),
132        Component::Scheme => Ok(scheme(req)),
133        Component::Path => Ok(req.uri().path().to_owned()),
134        Component::Query => Ok(query_with_leading_q(req)),
135        Component::RequestTarget => Ok(request_target(req)),
136        Component::Header(name) => header_value(req, name),
137    }
138}
139
140fn target_uri<B>(req: &Request<B>) -> Result<String, Error> {
141    let scheme = scheme(req);
142    let authority = authority(req)?;
143    let path_and_query = req
144        .uri()
145        .path_and_query()
146        .map_or_else(|| req.uri().path().to_owned(), ToString::to_string);
147    Ok(format!("{scheme}://{authority}{path_and_query}"))
148}
149
150fn authority<B>(req: &Request<B>) -> Result<String, Error> {
151    // Prefer the URI authority when present (i.e. absolute-form request);
152    // otherwise fall back to the `Host` header per RFC 9421 §2.2.4. If
153    // neither is available we cannot produce a well-formed `@authority`
154    // or `@target-uri`, so report it rather than silently emit an
155    // empty string and let a forged signature slip through.
156    if let Some(auth) = req.uri().authority() {
157        return Ok(auth.as_str().to_ascii_lowercase());
158    }
159    req.headers()
160        .get(http::header::HOST)
161        .and_then(|v| v.to_str().ok())
162        .map(|s| s.trim().to_ascii_lowercase())
163        .filter(|s| !s.is_empty())
164        .ok_or_else(|| Error::RequiredHeaderAbsent(http::header::HOST.as_str().to_owned()))
165}
166
167fn scheme<B>(req: &Request<B>) -> String {
168    req.uri()
169        .scheme_str()
170        .map_or_else(|| "https".to_owned(), str::to_ascii_lowercase)
171}
172
173fn query_with_leading_q<B>(req: &Request<B>) -> String {
174    // RFC 9421 §2.2.7: "If the query string is absent from the request
175    // URI, the value is the leading `?` character alone."
176    req.uri()
177        .query()
178        .map_or_else(|| "?".to_owned(), |q| format!("?{q}"))
179}
180
181fn request_target<B>(req: &Request<B>) -> String {
182    // RFC 9421 §2.2.5: "The request method is not included in the
183    // request target." The canonical value is just the path-and-query.
184    req.uri()
185        .path_and_query()
186        .map_or_else(|| req.uri().path().to_owned(), ToString::to_string)
187}
188
189fn header_value<B>(req: &Request<B>, lower_name: &str) -> Result<String, Error> {
190    collect_canonical_header_value(req, lower_name)
191        .ok_or_else(|| Error::RequiredHeaderAbsent(lower_name.to_owned()))
192}
193
194/// Builds the RFC 9421 signature base for `req` using the given
195/// ordered list of components, ending with the `"@signature-params"`
196/// line that binds the parameter tuple to the signature.
197///
198/// # Errors
199///
200/// Returns [`Error::RequiredHeaderAbsent`] when a referenced header is
201/// not present on `req`.
202#[allow(
203    clippy::expect_used,
204    clippy::unwrap_in_result,
205    reason = "writing to an owned String via core::fmt::Write is infallible; the Result on write! only exists to satisfy the trait"
206)]
207pub(crate) fn build_signature_base<B>(
208    req: &Request<B>,
209    components: &[Component],
210    signature_params_inner_list: &str,
211) -> Result<String, Error> {
212    use core::fmt::Write as _;
213    let mut out = String::new();
214    let infallible = "writing to an owned String is infallible";
215    for component in components {
216        let line = canonical_value(component, req)?;
217        writeln!(out, "{}: {line}", component.lexical()).expect(infallible);
218    }
219    write!(out, r#""@signature-params": {signature_params_inner_list}"#).expect(infallible);
220    Ok(out)
221}
222
223#[cfg(test)]
224mod tests {
225    use http::{Method, Request};
226    use pretty_assertions::assert_eq;
227
228    use super::*;
229
230    fn sample() -> Request<Vec<u8>> {
231        Request::builder()
232            .method(Method::POST)
233            .uri("https://example.com/inbox?a=1")
234            .header("host", "example.com")
235            .header("date", "Sun, 05 Jan 2014 21:31:40 GMT")
236            .body(Vec::new())
237            .expect("valid")
238    }
239
240    #[test]
241    fn method_is_uppercase() {
242        assert_eq!(
243            canonical_value(&Component::Method, &sample()).unwrap(),
244            "POST"
245        );
246    }
247
248    #[test]
249    fn target_uri_includes_scheme_authority_path_and_query() {
250        assert_eq!(
251            canonical_value(&Component::TargetUri, &sample()).unwrap(),
252            "https://example.com/inbox?a=1",
253        );
254    }
255
256    #[test]
257    fn authority_is_lowercase() {
258        let req = Request::builder()
259            .method(Method::POST)
260            .uri("https://EXAMPLE.COM/inbox")
261            .body(Vec::<u8>::new())
262            .expect("valid");
263        assert_eq!(
264            canonical_value(&Component::Authority, &req).unwrap(),
265            "example.com"
266        );
267    }
268
269    #[test]
270    fn path_and_query_are_separate() {
271        let req = sample();
272        assert_eq!(canonical_value(&Component::Path, &req).unwrap(), "/inbox");
273        assert_eq!(canonical_value(&Component::Query, &req).unwrap(), "?a=1");
274    }
275
276    #[test]
277    fn empty_query_canonicalises_to_single_question_mark() {
278        // RFC 9421 §2.2.7 explicitly specifies this edge case.
279        let req = Request::builder()
280            .method(Method::POST)
281            .uri("https://example.com/inbox")
282            .body(Vec::<u8>::new())
283            .expect("valid");
284        assert_eq!(canonical_value(&Component::Query, &req).unwrap(), "?");
285    }
286
287    #[test]
288    fn request_target_excludes_method_per_rfc9421() {
289        // Cavage's `(request-target)` includes the method, but RFC 9421's
290        // `@request-target` MUST NOT (§2.2.5).
291        let req = sample();
292        assert_eq!(
293            canonical_value(&Component::RequestTarget, &req).unwrap(),
294            "/inbox?a=1",
295        );
296    }
297
298    #[test]
299    fn request_target_is_just_path_when_query_absent() {
300        let req = Request::builder()
301            .method(Method::POST)
302            .uri("https://example.com/inbox")
303            .body(Vec::<u8>::new())
304            .expect("valid");
305        assert_eq!(
306            canonical_value(&Component::RequestTarget, &req).unwrap(),
307            "/inbox",
308        );
309    }
310
311    #[test]
312    fn missing_header_reports_required_header_absent() {
313        let req = sample();
314        let err = canonical_value(&Component::Header("authorization".into()), &req)
315            .expect_err("missing header must error");
316        assert!(matches!(err, Error::RequiredHeaderAbsent(name) if name == "authorization"));
317    }
318
319    #[test]
320    fn parse_roundtrips_known_identifiers() {
321        for ident in [
322            "@method",
323            "@target-uri",
324            "@authority",
325            "@scheme",
326            "@path",
327            "@query",
328            "@request-target",
329            "date",
330        ] {
331            let c = Component::parse(ident).expect("known identifier");
332            assert_eq!(c.identifier(), ident);
333        }
334    }
335
336    #[test]
337    fn parse_rejects_unknown_derived_component() {
338        let err = Component::parse("@future").expect_err("unknown derived");
339        assert!(matches!(err, Error::UnsupportedAlgorithm(_)));
340    }
341
342    #[test]
343    fn authority_errors_when_request_has_no_host_and_no_uri_authority() {
344        // Regression: previously produced `""` and silently yielded
345        // `https:///inbox` for `@target-uri`.
346        let req = Request::builder()
347            .method(Method::POST)
348            .uri("/inbox")
349            .body(Vec::<u8>::new())
350            .expect("valid");
351        let err = canonical_value(&Component::Authority, &req)
352            .expect_err("missing authority must surface as an error");
353        assert!(matches!(err, Error::RequiredHeaderAbsent(name) if name == "host"));
354    }
355
356    #[test]
357    fn target_uri_errors_when_request_has_no_host_and_no_uri_authority() {
358        let req = Request::builder()
359            .method(Method::POST)
360            .uri("/inbox")
361            .body(Vec::<u8>::new())
362            .expect("valid");
363        let err = canonical_value(&Component::TargetUri, &req)
364            .expect_err("missing authority must propagate through @target-uri");
365        assert!(matches!(err, Error::RequiredHeaderAbsent(_)));
366    }
367
368    #[test]
369    fn full_signature_base_matches_expected_shape() {
370        let req = sample();
371        let components = [
372            Component::Method,
373            Component::TargetUri,
374            Component::Header("host".into()),
375            Component::Header("date".into()),
376        ];
377        let base = build_signature_base(
378            &req,
379            &components,
380            r#"("@method" "@target-uri" "host" "date");created=1704464900;keyid="kid""#,
381        )
382        .unwrap();
383        assert_eq!(
384            base,
385            concat!(
386                "\"@method\": POST\n",
387                "\"@target-uri\": https://example.com/inbox?a=1\n",
388                "\"host\": example.com\n",
389                "\"date\": Sun, 05 Jan 2014 21:31:40 GMT\n",
390                "\"@signature-params\": (\"@method\" \"@target-uri\" \"host\" \"date\");created=1704464900;keyid=\"kid\"",
391            ),
392        );
393    }
394}