Skip to main content

actpub_httpsig/cavage/
header.rs

1//! Parsing and emitting the Cavage `Signature:` header value.
2//!
3//! The header is a comma-separated list of `name=value` pairs where
4//! string-typed parameters (`keyId`, `algorithm`, `headers`, `signature`)
5//! are double-quoted and numeric ones (`created`, `expires`) appear
6//! without quotes. Parameter order is not significant.
7
8use crate::cavage::canonical::CavageHeaderSet;
9use crate::error::Error;
10
11/// Name of the `Signature:` HTTP header.
12pub const SIGNATURE_HEADER: &str = "signature";
13
14/// Parsed `Signature:` header parameters.
15#[derive(Debug, Clone, PartialEq, Eq)]
16#[non_exhaustive]
17pub struct CavageHeaderParams {
18    /// Reference to the public key to verify against.
19    pub key_id: String,
20    /// Algorithm hint; `None` means "detect from the key itself".
21    pub algorithm: Option<String>,
22    /// Which headers participate in the signature base string.
23    pub headers: CavageHeaderSet,
24    /// Base64-encoded signature bytes.
25    pub signature: String,
26    /// Optional `(created)` timestamp in seconds since the UNIX epoch.
27    pub created: Option<i64>,
28    /// Optional `(expires)` timestamp in seconds since the UNIX epoch.
29    pub expires: Option<i64>,
30}
31
32impl CavageHeaderParams {
33    /// Parses the raw `Signature:` header value.
34    ///
35    /// # Errors
36    ///
37    /// Returns [`Error::MalformedSignatureHeader`] if the parameter list
38    /// cannot be decoded, and [`Error::MissingSignatureParameter`] if
39    /// `keyId` or `signature` is absent.
40    pub fn parse(raw: &str) -> Result<Self, Error> {
41        let mut key_id = None;
42        let mut algorithm = None;
43        let mut headers_field: Option<String> = None;
44        let mut signature = None;
45        let mut created = None;
46        let mut expires = None;
47
48        for pair in split_top_level_commas(raw) {
49            let pair = pair.trim();
50            if pair.is_empty() {
51                continue;
52            }
53            let (name, value) = split_once_trim(pair, '=').ok_or_else(|| {
54                Error::MalformedSignatureHeader(format!("missing `=` in `{pair}`"))
55            })?;
56            let value = unquote(value)?;
57            match name {
58                "keyId" | "keyid" => key_id = Some(value.into_owned()),
59                "algorithm" => algorithm = Some(value.into_owned()),
60                "headers" => headers_field = Some(value.into_owned()),
61                "signature" => signature = Some(value.into_owned()),
62                "created" => created = Some(parse_i64_param("created", &value)?),
63                "expires" => expires = Some(parse_i64_param("expires", &value)?),
64                _ => {
65                    // Unknown parameters are ignored per draft §2.1.
66                }
67            }
68        }
69
70        let key_id = key_id.ok_or(Error::MissingSignatureParameter("keyId"))?;
71        let signature = signature.ok_or(Error::MissingSignatureParameter("signature"))?;
72
73        // Per draft §2.1.3: "If not specified, [headers] defaults to
74        // the single value `(created)`. If the `created` signature
75        // parameter is not provided, this parameter defaults to
76        // the single value `date`."
77        //
78        // Fediverse actors always set `headers` explicitly, so the
79        // defaulting branch is a pure fallback for spec-correctness.
80        let headers = headers_field.map_or_else(
81            || {
82                if created.is_some() {
83                    CavageHeaderSet::new(["(created)"])
84                } else {
85                    CavageHeaderSet::new(["date"])
86                }
87            },
88            |v| CavageHeaderSet::new(v.split_ascii_whitespace().map(str::to_owned)),
89        );
90
91        Ok(Self {
92            key_id,
93            algorithm,
94            headers,
95            signature,
96            created,
97            expires,
98        })
99    }
100
101    /// Serialises back into a `Signature:` header value.
102    #[must_use]
103    #[allow(
104        clippy::expect_used,
105        reason = "writing to an owned `String` via `core::fmt::Write` is infallible; the `Result` only exists to satisfy the trait"
106    )]
107    pub fn to_header_value(&self) -> String {
108        use core::fmt::Write as _;
109        let mut out = String::new();
110        let infallible = "writing to an owned String is infallible";
111        write!(out, r#"keyId="{}""#, self.key_id).expect(infallible);
112        if let Some(alg) = &self.algorithm {
113            write!(out, r#",algorithm="{alg}""#).expect(infallible);
114        }
115        write!(out, r#",headers="{}""#, self.headers.join_spaces()).expect(infallible);
116        if let Some(c) = self.created {
117            write!(out, ",created={c}").expect(infallible);
118        }
119        if let Some(e) = self.expires {
120            write!(out, ",expires={e}").expect(infallible);
121        }
122        write!(out, r#",signature="{}""#, self.signature).expect(infallible);
123        out
124    }
125}
126
127/// Splits the raw header on top-level commas, skipping commas inside
128/// double-quoted regions so that `signature="a,b,c"` does not break.
129fn split_top_level_commas(raw: &str) -> impl Iterator<Item = &str> {
130    let mut parts = Vec::new();
131    let mut start = 0;
132    let mut in_quotes = false;
133    for (i, c) in raw.char_indices() {
134        match c {
135            '"' => in_quotes = !in_quotes,
136            ',' if !in_quotes => {
137                parts.push(&raw[start..i]);
138                start = i + 1;
139            }
140            _ => {}
141        }
142    }
143    parts.push(&raw[start..]);
144    parts.into_iter()
145}
146
147fn split_once_trim(s: &str, c: char) -> Option<(&str, &str)> {
148    s.split_once(c).map(|(a, b)| (a.trim(), b.trim()))
149}
150
151fn parse_i64_param(name: &'static str, value: &str) -> Result<i64, Error> {
152    value.parse::<i64>().map_err(|_| {
153        Error::MalformedSignatureHeader(format!("`{name}` is not an integer: `{value}`"))
154    })
155}
156
157fn unquote(raw: &str) -> Result<std::borrow::Cow<'_, str>, Error> {
158    if raw.len() < 2 || !raw.starts_with('"') || !raw.ends_with('"') {
159        return Ok(std::borrow::Cow::Borrowed(raw));
160    }
161    let inner = &raw[1..raw.len() - 1];
162    if !inner.contains('\\') {
163        return Ok(std::borrow::Cow::Borrowed(inner));
164    }
165    Ok(std::borrow::Cow::Owned(unescape(inner)?))
166}
167
168/// Unescapes a quoted-string body: `\<X>` becomes `<X>` for any
169/// `<X>`. A trailing lone backslash is an encoding error and is
170/// signalled by [`Error::MalformedSignatureHeader`] via the caller
171/// re-wrapping; `unquote` upgrades the result to `Cow::Owned` only
172/// after confirming the escape sequence is well-formed.
173fn unescape(inner: &str) -> Result<String, Error> {
174    let mut out = String::with_capacity(inner.len());
175    let mut chars = inner.chars();
176    while let Some(c) = chars.next() {
177        if c == '\\' {
178            let next = chars.next().ok_or_else(|| {
179                Error::MalformedSignatureHeader(
180                    "quoted-string ends with a lone backslash".to_owned(),
181                )
182            })?;
183            out.push(next);
184        } else {
185            out.push(c);
186        }
187    }
188    Ok(out)
189}
190
191#[cfg(test)]
192mod tests {
193    use pretty_assertions::assert_eq;
194
195    use super::*;
196
197    const MASTODON_SAMPLE: &str = r#"keyId="https://mastodon.social/users/alice#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="Zm9v""#;
198
199    #[test]
200    fn parses_mastodon_style_header() {
201        let params = CavageHeaderParams::parse(MASTODON_SAMPLE).expect("parse");
202        assert_eq!(
203            params.key_id,
204            "https://mastodon.social/users/alice#main-key"
205        );
206        assert_eq!(params.algorithm.as_deref(), Some("rsa-sha256"));
207        assert_eq!(params.headers.len(), 4);
208        assert_eq!(params.signature, "Zm9v");
209        assert_eq!(params.created, None);
210        assert_eq!(params.expires, None);
211    }
212
213    #[test]
214    fn header_roundtrips_through_serialisation() {
215        let params = CavageHeaderParams::parse(MASTODON_SAMPLE).expect("parse");
216        let emitted = params.to_header_value();
217        let reparsed = CavageHeaderParams::parse(&emitted).expect("reparse");
218        assert_eq!(reparsed, params);
219    }
220
221    #[test]
222    fn missing_key_id_produces_specific_error() {
223        let err =
224            CavageHeaderParams::parse(r#"algorithm="rsa-sha256",headers="host",signature="Zm9v""#)
225                .expect_err("missing keyId");
226        assert!(matches!(err, Error::MissingSignatureParameter("keyId")));
227    }
228
229    #[test]
230    fn missing_signature_produces_specific_error() {
231        let err = CavageHeaderParams::parse(r#"keyId="foo",algorithm="rsa-sha256",headers="host""#)
232            .expect_err("missing signature");
233        assert!(matches!(err, Error::MissingSignatureParameter("signature")));
234    }
235
236    #[test]
237    fn unquoted_parameters_are_tolerated() {
238        // Some server implementations emit bare tokens for created/expires.
239        let raw =
240            r#"keyId="foo",headers="host",created=1700000000,expires=1700001000,signature="Zm9v""#;
241        let params = CavageHeaderParams::parse(raw).expect("parse");
242        assert_eq!(params.created, Some(1_700_000_000));
243        assert_eq!(params.expires, Some(1_700_001_000));
244    }
245
246    #[test]
247    fn unknown_parameters_are_silently_skipped() {
248        let raw = r#"keyId="foo",headers="host",signature="Zm9v",future_thing="ignored""#;
249        let params = CavageHeaderParams::parse(raw).expect("parse");
250        assert_eq!(params.key_id, "foo");
251    }
252
253    #[test]
254    fn commas_inside_quoted_signature_do_not_split_parameters() {
255        let raw = r#"keyId="has,comma",headers="host",signature="ZmF,vo""#;
256        let params = CavageHeaderParams::parse(raw).expect("parse");
257        assert_eq!(params.key_id, "has,comma");
258        assert_eq!(params.signature, "ZmF,vo");
259    }
260
261    #[test]
262    fn missing_headers_parameter_with_created_defaults_to_created_pseudo() {
263        // Per §2.1.3 the default `headers` value is `(created)` when
264        // the `created` parameter is present.
265        let raw = r#"keyId="k",created=1700000000,signature="Zm9v""#;
266        let params = CavageHeaderParams::parse(raw).expect("parse");
267        assert_eq!(params.headers.len(), 1);
268        assert!(params.headers.iter().any(|h| h == "(created)"));
269    }
270
271    #[test]
272    fn missing_headers_parameter_without_created_defaults_to_date() {
273        // §2.1.3 falls back to `date` when both `headers` and
274        // `created` are absent -- the Mastodon-compatible corner case.
275        let raw = r#"keyId="k",signature="Zm9v""#;
276        let params = CavageHeaderParams::parse(raw).expect("parse");
277        assert_eq!(params.headers.len(), 1);
278        assert!(params.headers.iter().any(|h| h == "date"));
279    }
280
281    #[test]
282    fn quoted_string_with_trailing_backslash_is_rejected() {
283        // A lone trailing backslash inside a quoted string is an
284        // encoding error; previously this was silently dropped.
285        let raw = r#"keyId="k\",headers="host",signature="Zm9v""#;
286        let err = CavageHeaderParams::parse(raw).expect_err("malformed escape must fail");
287        assert!(matches!(err, Error::MalformedSignatureHeader(_)));
288    }
289}