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            // Draft §2.1 says "all parameters SHALL appear at most
58            // once". Silently overwriting a repeated `keyId` (or any
59            // other parameter) is a **parameter-pollution** bug: a
60            // peer — or an attacker on the path with trivial header
61            // injection — can append a second `keyId` that wins the
62            // `=` assignment and reroutes the verifier to a key they
63            // control. Fail the parse whenever any parameter we
64            // actually consume appears more than once.
65            match name {
66                "keyId" | "keyid" => {
67                    reject_if_set(key_id.as_ref(), "keyId")?;
68                    key_id = Some(value.into_owned());
69                }
70                "algorithm" => {
71                    reject_if_set(algorithm.as_ref(), "algorithm")?;
72                    algorithm = Some(value.into_owned());
73                }
74                "headers" => {
75                    reject_if_set(headers_field.as_ref(), "headers")?;
76                    headers_field = Some(value.into_owned());
77                }
78                "signature" => {
79                    reject_if_set(signature.as_ref(), "signature")?;
80                    signature = Some(value.into_owned());
81                }
82                "created" => {
83                    reject_if_set(created.as_ref(), "created")?;
84                    created = Some(parse_i64_param("created", &value)?);
85                }
86                "expires" => {
87                    reject_if_set(expires.as_ref(), "expires")?;
88                    expires = Some(parse_i64_param("expires", &value)?);
89                }
90                _ => {
91                    // Unknown parameters are ignored per draft §2.1.
92                }
93            }
94        }
95
96        let key_id = key_id.ok_or(Error::MissingSignatureParameter("keyId"))?;
97        let signature = signature.ok_or(Error::MissingSignatureParameter("signature"))?;
98
99        // Per draft §2.1.3: "If not specified, [headers] defaults to
100        // the single value `(created)`. If the `created` signature
101        // parameter is not provided, this parameter defaults to
102        // the single value `date`."
103        //
104        // Fediverse actors always set `headers` explicitly, so the
105        // defaulting branch is a pure fallback for spec-correctness.
106        let headers = headers_field.map_or_else(
107            || {
108                if created.is_some() {
109                    CavageHeaderSet::new(["(created)"])
110                } else {
111                    CavageHeaderSet::new(["date"])
112                }
113            },
114            |v| CavageHeaderSet::new(v.split_ascii_whitespace().map(str::to_owned)),
115        );
116
117        Ok(Self {
118            key_id,
119            algorithm,
120            headers,
121            signature,
122            created,
123            expires,
124        })
125    }
126
127    /// Serialises back into a `Signature:` header value.
128    ///
129    /// String-valued parameters (`keyId`, `algorithm`, `headers`,
130    /// `signature`) are quoted and any `\` or `"` character inside
131    /// them is backslash-escaped per the Cavage quoted-string grammar.
132    #[must_use]
133    #[allow(
134        clippy::expect_used,
135        reason = "writing to an owned `String` via `core::fmt::Write` is infallible; the `Result` only exists to satisfy the trait"
136    )]
137    pub fn to_header_value(&self) -> String {
138        use core::fmt::Write as _;
139        let mut out = String::new();
140        let infallible = "writing to an owned String is infallible";
141        write!(out, r#"keyId="{}""#, escape_quoted(&self.key_id)).expect(infallible);
142        if let Some(alg) = &self.algorithm {
143            write!(out, r#",algorithm="{}""#, escape_quoted(alg)).expect(infallible);
144        }
145        write!(
146            out,
147            r#",headers="{}""#,
148            escape_quoted(&self.headers.join_spaces()),
149        )
150        .expect(infallible);
151        if let Some(c) = self.created {
152            write!(out, ",created={c}").expect(infallible);
153        }
154        if let Some(e) = self.expires {
155            write!(out, ",expires={e}").expect(infallible);
156        }
157        write!(out, r#",signature="{}""#, escape_quoted(&self.signature)).expect(infallible);
158        out
159    }
160}
161
162/// Applies the Cavage quoted-string escape rules: `\` → `\\` and
163/// `"` → `\"`. All other bytes pass through unchanged.
164fn escape_quoted(raw: &str) -> String {
165    let mut out = String::with_capacity(raw.len());
166    for c in raw.chars() {
167        if c == '\\' || c == '"' {
168            out.push('\\');
169        }
170        out.push(c);
171    }
172    out
173}
174
175/// Splits the raw header on top-level commas, skipping commas inside
176/// double-quoted regions so that `signature="a,b,c"` does not break.
177///
178/// The scanner also honours the quoted-string escape sequence `\X`,
179/// treating the next character as literal content. Without this a
180/// payload containing `\"` would prematurely terminate the quoted
181/// region and let an attacker inject additional parameter pairs.
182fn split_top_level_commas(raw: &str) -> impl Iterator<Item = &str> {
183    let mut parts = Vec::new();
184    let mut start = 0;
185    let mut in_quotes = false;
186    let mut escaped = false;
187    for (i, c) in raw.char_indices() {
188        if escaped {
189            escaped = false;
190            continue;
191        }
192        match c {
193            '\\' if in_quotes => escaped = true,
194            '"' => in_quotes = !in_quotes,
195            ',' if !in_quotes => {
196                parts.push(&raw[start..i]);
197                start = i + 1;
198            }
199            _ => {}
200        }
201    }
202    parts.push(&raw[start..]);
203    parts.into_iter()
204}
205
206fn split_once_trim(s: &str, c: char) -> Option<(&str, &str)> {
207    s.split_once(c).map(|(a, b)| (a.trim(), b.trim()))
208}
209
210/// Returns `Err` if `slot` is already `Some`, signalling a repeated
211/// parameter. Extracted so the at-most-once check stays a single
212/// flat branch per match arm instead of a deeply-nested `if`.
213fn reject_if_set<T>(slot: Option<&T>, name: &'static str) -> Result<(), Error> {
214    if slot.is_some() {
215        return Err(Error::MalformedSignatureHeader(format!(
216            "duplicate `{name}` parameter"
217        )));
218    }
219    Ok(())
220}
221
222fn parse_i64_param(name: &'static str, value: &str) -> Result<i64, Error> {
223    value.parse::<i64>().map_err(|_| {
224        Error::MalformedSignatureHeader(format!("`{name}` is not an integer: `{value}`"))
225    })
226}
227
228fn unquote(raw: &str) -> Result<std::borrow::Cow<'_, str>, Error> {
229    if raw.len() < 2 || !raw.starts_with('"') || !raw.ends_with('"') {
230        return Ok(std::borrow::Cow::Borrowed(raw));
231    }
232    let inner = &raw[1..raw.len() - 1];
233    if !inner.contains('\\') {
234        return Ok(std::borrow::Cow::Borrowed(inner));
235    }
236    Ok(std::borrow::Cow::Owned(unescape(inner)?))
237}
238
239/// Unescapes a quoted-string body: `\<X>` becomes `<X>` for any
240/// `<X>`. A trailing lone backslash is an encoding error and is
241/// signalled by [`Error::MalformedSignatureHeader`] via the caller
242/// re-wrapping; `unquote` upgrades the result to `Cow::Owned` only
243/// after confirming the escape sequence is well-formed.
244fn unescape(inner: &str) -> Result<String, Error> {
245    let mut out = String::with_capacity(inner.len());
246    let mut chars = inner.chars();
247    while let Some(c) = chars.next() {
248        if c == '\\' {
249            let next = chars.next().ok_or_else(|| {
250                Error::MalformedSignatureHeader(
251                    "quoted-string ends with a lone backslash".to_owned(),
252                )
253            })?;
254            out.push(next);
255        } else {
256            out.push(c);
257        }
258    }
259    Ok(out)
260}
261
262#[cfg(test)]
263mod tests {
264    use pretty_assertions::assert_eq;
265
266    use super::*;
267
268    const MASTODON_SAMPLE: &str = r#"keyId="https://mastodon.social/users/alice#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="Zm9v""#;
269
270    #[test]
271    fn parses_mastodon_style_header() {
272        let params = CavageHeaderParams::parse(MASTODON_SAMPLE).expect("parse");
273        assert_eq!(
274            params.key_id,
275            "https://mastodon.social/users/alice#main-key"
276        );
277        assert_eq!(params.algorithm.as_deref(), Some("rsa-sha256"));
278        assert_eq!(params.headers.len(), 4);
279        assert_eq!(params.signature, "Zm9v");
280        assert_eq!(params.created, None);
281        assert_eq!(params.expires, None);
282    }
283
284    #[test]
285    fn header_roundtrips_through_serialisation() {
286        let params = CavageHeaderParams::parse(MASTODON_SAMPLE).expect("parse");
287        let emitted = params.to_header_value();
288        let reparsed = CavageHeaderParams::parse(&emitted).expect("reparse");
289        assert_eq!(reparsed, params);
290    }
291
292    #[test]
293    fn missing_key_id_produces_specific_error() {
294        let err =
295            CavageHeaderParams::parse(r#"algorithm="rsa-sha256",headers="host",signature="Zm9v""#)
296                .expect_err("missing keyId");
297        assert!(matches!(err, Error::MissingSignatureParameter("keyId")));
298    }
299
300    #[test]
301    fn missing_signature_produces_specific_error() {
302        let err = CavageHeaderParams::parse(r#"keyId="foo",algorithm="rsa-sha256",headers="host""#)
303            .expect_err("missing signature");
304        assert!(matches!(err, Error::MissingSignatureParameter("signature")));
305    }
306
307    #[test]
308    fn unquoted_parameters_are_tolerated() {
309        // Some server implementations emit bare tokens for created/expires.
310        let raw =
311            r#"keyId="foo",headers="host",created=1700000000,expires=1700001000,signature="Zm9v""#;
312        let params = CavageHeaderParams::parse(raw).expect("parse");
313        assert_eq!(params.created, Some(1_700_000_000));
314        assert_eq!(params.expires, Some(1_700_001_000));
315    }
316
317    #[test]
318    fn unknown_parameters_are_silently_skipped() {
319        let raw = r#"keyId="foo",headers="host",signature="Zm9v",future_thing="ignored""#;
320        let params = CavageHeaderParams::parse(raw).expect("parse");
321        assert_eq!(params.key_id, "foo");
322    }
323
324    #[test]
325    fn commas_inside_quoted_signature_do_not_split_parameters() {
326        let raw = r#"keyId="has,comma",headers="host",signature="ZmF,vo""#;
327        let params = CavageHeaderParams::parse(raw).expect("parse");
328        assert_eq!(params.key_id, "has,comma");
329        assert_eq!(params.signature, "ZmF,vo");
330    }
331
332    #[test]
333    fn missing_headers_parameter_with_created_defaults_to_created_pseudo() {
334        // Per §2.1.3 the default `headers` value is `(created)` when
335        // the `created` parameter is present.
336        let raw = r#"keyId="k",created=1700000000,signature="Zm9v""#;
337        let params = CavageHeaderParams::parse(raw).expect("parse");
338        assert_eq!(params.headers.len(), 1);
339        assert!(params.headers.iter().any(|h| h == "(created)"));
340    }
341
342    #[test]
343    fn missing_headers_parameter_without_created_defaults_to_date() {
344        // §2.1.3 falls back to `date` when both `headers` and
345        // `created` are absent -- the Mastodon-compatible corner case.
346        let raw = r#"keyId="k",signature="Zm9v""#;
347        let params = CavageHeaderParams::parse(raw).expect("parse");
348        assert_eq!(params.headers.len(), 1);
349        assert!(params.headers.iter().any(|h| h == "date"));
350    }
351
352    #[test]
353    fn escaped_double_quote_inside_quoted_string_survives_splitting() {
354        // Without the escape-aware splitter this payload would split
355        // early at the `"` inside `evil"`, dropping the `,attacker=`
356        // trailer into a separate parameter.
357        let raw = r#"keyId="legit\"evil",headers="host",signature="Zm9v""#;
358        let params = CavageHeaderParams::parse(raw).expect("parse");
359        assert_eq!(params.key_id, r#"legit"evil"#);
360    }
361
362    #[test]
363    fn to_header_value_escapes_embedded_quote_and_backslash() {
364        let raw = r#"keyId="legit\"evil\\trail",headers="host",signature="Zm9v""#;
365        let params = CavageHeaderParams::parse(raw).expect("parse");
366        let emitted = params.to_header_value();
367        // Re-parsing must recover the same logical value.
368        let reparsed = CavageHeaderParams::parse(&emitted).expect("reparse escaped");
369        assert_eq!(reparsed.key_id, r#"legit"evil\trail"#);
370        // And the escapes must actually be present on the wire.
371        assert!(emitted.contains(r#"\""#));
372        assert!(emitted.contains(r"\\"));
373    }
374
375    #[test]
376    fn parameter_value_with_lone_trailing_backslash_is_rejected() {
377        // A quoted value whose contents end in an unpaired backslash
378        // is an encoding error: the parser has no way to know which
379        // character the `\` was meant to escape.
380        let raw = r#"keyId="abc\""#;
381        let err = CavageHeaderParams::parse(raw).expect_err("malformed escape must fail");
382        assert!(matches!(err, Error::MalformedSignatureHeader(_)));
383    }
384
385    #[test]
386    fn malformed_escape_reports_error() {
387        // A quoted value whose contents end in an unpaired backslash
388        // is an encoding error: the parser has no way to know which
389        // character the `\` was meant to escape.
390        let raw = r#"keyId="abc\""#;
391        let err = CavageHeaderParams::parse(raw).expect_err("malformed escape must fail");
392        assert!(matches!(err, Error::MalformedSignatureHeader(_)));
393    }
394
395    #[test]
396    fn duplicate_key_id_is_rejected() {
397        // P2-N15 regression: accepting a repeated `keyId` and taking
398        // the last value would let an attacker append a second
399        // parameter that reroutes the verifier to their own key.
400        // The draft mandates at-most-once, and we now enforce it.
401        let raw = r#"keyId="https://victim.example/#k",keyId="https://attacker.example/#k",headers="host",signature="Zm9v""#;
402        let err = CavageHeaderParams::parse(raw).expect_err("duplicate keyId must be rejected");
403        assert!(
404            matches!(err, Error::MalformedSignatureHeader(ref s) if s.contains("keyId")),
405            "unexpected: {err:?}",
406        );
407    }
408
409    #[test]
410    fn duplicate_signature_is_rejected() {
411        let raw = r#"keyId="k",headers="host",signature="Zm9v",signature="YmFy""#;
412        let err = CavageHeaderParams::parse(raw).expect_err("duplicate signature must be rejected");
413        assert!(matches!(err, Error::MalformedSignatureHeader(_)));
414    }
415
416    #[test]
417    fn duplicate_algorithm_is_rejected() {
418        let raw = r#"keyId="k",algorithm="rsa-sha256",algorithm="hs2019",headers="host",signature="Zm9v""#;
419        let err = CavageHeaderParams::parse(raw).expect_err("duplicate algorithm must be rejected");
420        assert!(matches!(err, Error::MalformedSignatureHeader(_)));
421    }
422
423    #[test]
424    fn duplicate_created_is_rejected() {
425        let raw =
426            r#"keyId="k",created=1700000000,created=1700000001,headers="host",signature="Zm9v""#;
427        let err = CavageHeaderParams::parse(raw).expect_err("duplicate created must be rejected");
428        assert!(matches!(err, Error::MalformedSignatureHeader(_)));
429    }
430}