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.
125pub(crate) fn canonical_value<B>(component: &Component, req: &Request<B>) -> Result<String, Error> {
126    match component {
127        Component::Method => Ok(req.method().as_str().to_uppercase()),
128        Component::TargetUri => Ok(target_uri(req)),
129        Component::Authority => Ok(authority(req)),
130        Component::Scheme => Ok(scheme(req)),
131        Component::Path => Ok(req.uri().path().to_owned()),
132        Component::Query => Ok(query_with_leading_q(req)),
133        Component::RequestTarget => Ok(request_target(req)),
134        Component::Header(name) => header_value(req, name),
135    }
136}
137
138fn target_uri<B>(req: &Request<B>) -> String {
139    let scheme = scheme(req);
140    let authority = authority(req);
141    let path_and_query = req
142        .uri()
143        .path_and_query()
144        .map_or_else(|| req.uri().path().to_owned(), ToString::to_string);
145    format!("{scheme}://{authority}{path_and_query}")
146}
147
148fn authority<B>(req: &Request<B>) -> String {
149    // Prefer the URI authority when present (i.e. absolute-form request);
150    // otherwise fall back to the `Host` header per RFC 9421 §2.2.4.
151    if let Some(auth) = req.uri().authority() {
152        return auth.as_str().to_ascii_lowercase();
153    }
154    req.headers()
155        .get(http::header::HOST)
156        .and_then(|v| v.to_str().ok())
157        .map(|s| s.trim().to_ascii_lowercase())
158        .unwrap_or_default()
159}
160
161fn scheme<B>(req: &Request<B>) -> String {
162    req.uri()
163        .scheme_str()
164        .map_or_else(|| "https".to_owned(), str::to_ascii_lowercase)
165}
166
167fn query_with_leading_q<B>(req: &Request<B>) -> String {
168    // RFC 9421 §2.2.7: "If the query string is absent from the request
169    // URI, the value is the leading `?` character alone."
170    req.uri()
171        .query()
172        .map_or_else(|| "?".to_owned(), |q| format!("?{q}"))
173}
174
175fn request_target<B>(req: &Request<B>) -> String {
176    // RFC 9421 §2.2.5: "The request method is not included in the
177    // request target." The canonical value is just the path-and-query.
178    req.uri()
179        .path_and_query()
180        .map_or_else(|| req.uri().path().to_owned(), ToString::to_string)
181}
182
183fn header_value<B>(req: &Request<B>, lower_name: &str) -> Result<String, Error> {
184    collect_canonical_header_value(req, lower_name)
185        .ok_or_else(|| Error::RequiredHeaderAbsent(lower_name.to_owned()))
186}
187
188/// Builds the RFC 9421 signature base for `req` using the given
189/// ordered list of components, ending with the `"@signature-params"`
190/// line that binds the parameter tuple to the signature.
191///
192/// # Errors
193///
194/// Returns [`Error::RequiredHeaderAbsent`] when a referenced header is
195/// not present on `req`.
196#[allow(
197    clippy::expect_used,
198    clippy::unwrap_in_result,
199    reason = "writing to an owned String via core::fmt::Write is infallible; the Result on write! only exists to satisfy the trait"
200)]
201pub(crate) fn build_signature_base<B>(
202    req: &Request<B>,
203    components: &[Component],
204    signature_params_inner_list: &str,
205) -> Result<String, Error> {
206    use core::fmt::Write as _;
207    let mut out = String::new();
208    let infallible = "writing to an owned String is infallible";
209    for component in components {
210        let line = canonical_value(component, req)?;
211        writeln!(out, "{}: {line}", component.lexical()).expect(infallible);
212    }
213    write!(out, r#""@signature-params": {signature_params_inner_list}"#).expect(infallible);
214    Ok(out)
215}
216
217#[cfg(test)]
218mod tests {
219    use http::{Method, Request};
220    use pretty_assertions::assert_eq;
221
222    use super::*;
223
224    fn sample() -> Request<Vec<u8>> {
225        Request::builder()
226            .method(Method::POST)
227            .uri("https://example.com/inbox?a=1")
228            .header("host", "example.com")
229            .header("date", "Sun, 05 Jan 2014 21:31:40 GMT")
230            .body(Vec::new())
231            .expect("valid")
232    }
233
234    #[test]
235    fn method_is_uppercase() {
236        assert_eq!(
237            canonical_value(&Component::Method, &sample()).unwrap(),
238            "POST"
239        );
240    }
241
242    #[test]
243    fn target_uri_includes_scheme_authority_path_and_query() {
244        assert_eq!(
245            canonical_value(&Component::TargetUri, &sample()).unwrap(),
246            "https://example.com/inbox?a=1",
247        );
248    }
249
250    #[test]
251    fn authority_is_lowercase() {
252        let req = Request::builder()
253            .method(Method::POST)
254            .uri("https://EXAMPLE.COM/inbox")
255            .body(Vec::<u8>::new())
256            .expect("valid");
257        assert_eq!(
258            canonical_value(&Component::Authority, &req).unwrap(),
259            "example.com"
260        );
261    }
262
263    #[test]
264    fn path_and_query_are_separate() {
265        let req = sample();
266        assert_eq!(canonical_value(&Component::Path, &req).unwrap(), "/inbox");
267        assert_eq!(canonical_value(&Component::Query, &req).unwrap(), "?a=1");
268    }
269
270    #[test]
271    fn empty_query_canonicalises_to_single_question_mark() {
272        // RFC 9421 §2.2.7 explicitly specifies this edge case.
273        let req = Request::builder()
274            .method(Method::POST)
275            .uri("https://example.com/inbox")
276            .body(Vec::<u8>::new())
277            .expect("valid");
278        assert_eq!(canonical_value(&Component::Query, &req).unwrap(), "?");
279    }
280
281    #[test]
282    fn request_target_excludes_method_per_rfc9421() {
283        // Cavage's `(request-target)` includes the method, but RFC 9421's
284        // `@request-target` MUST NOT (§2.2.5).
285        let req = sample();
286        assert_eq!(
287            canonical_value(&Component::RequestTarget, &req).unwrap(),
288            "/inbox?a=1",
289        );
290    }
291
292    #[test]
293    fn request_target_is_just_path_when_query_absent() {
294        let req = Request::builder()
295            .method(Method::POST)
296            .uri("https://example.com/inbox")
297            .body(Vec::<u8>::new())
298            .expect("valid");
299        assert_eq!(
300            canonical_value(&Component::RequestTarget, &req).unwrap(),
301            "/inbox",
302        );
303    }
304
305    #[test]
306    fn missing_header_reports_required_header_absent() {
307        let req = sample();
308        let err = canonical_value(&Component::Header("authorization".into()), &req)
309            .expect_err("missing header must error");
310        assert!(matches!(err, Error::RequiredHeaderAbsent(name) if name == "authorization"));
311    }
312
313    #[test]
314    fn parse_roundtrips_known_identifiers() {
315        for ident in [
316            "@method",
317            "@target-uri",
318            "@authority",
319            "@scheme",
320            "@path",
321            "@query",
322            "@request-target",
323            "date",
324        ] {
325            let c = Component::parse(ident).expect("known identifier");
326            assert_eq!(c.identifier(), ident);
327        }
328    }
329
330    #[test]
331    fn parse_rejects_unknown_derived_component() {
332        let err = Component::parse("@future").expect_err("unknown derived");
333        assert!(matches!(err, Error::UnsupportedAlgorithm(_)));
334    }
335
336    #[test]
337    fn full_signature_base_matches_expected_shape() {
338        let req = sample();
339        let components = [
340            Component::Method,
341            Component::TargetUri,
342            Component::Header("host".into()),
343            Component::Header("date".into()),
344        ];
345        let base = build_signature_base(
346            &req,
347            &components,
348            r#"("@method" "@target-uri" "host" "date");created=1704464900;keyid="kid""#,
349        )
350        .unwrap();
351        assert_eq!(
352            base,
353            concat!(
354                "\"@method\": POST\n",
355                "\"@target-uri\": https://example.com/inbox?a=1\n",
356                "\"host\": example.com\n",
357                "\"date\": Sun, 05 Jan 2014 21:31:40 GMT\n",
358                "\"@signature-params\": (\"@method\" \"@target-uri\" \"host\" \"date\");created=1704464900;keyid=\"kid\"",
359            ),
360        );
361    }
362}