1use crate::cavage::canonical::CavageHeaderSet;
9use crate::error::Error;
10
11pub const SIGNATURE_HEADER: &str = "signature";
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16#[non_exhaustive]
17pub struct CavageHeaderParams {
18 pub key_id: String,
20 pub algorithm: Option<String>,
22 pub headers: CavageHeaderSet,
24 pub signature: String,
26 pub created: Option<i64>,
28 pub expires: Option<i64>,
30}
31
32impl CavageHeaderParams {
33 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 {
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 }
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 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 #[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
162fn 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
175fn 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
210fn 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
239fn 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 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 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 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 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 let reparsed = CavageHeaderParams::parse(&emitted).expect("reparse escaped");
369 assert_eq!(reparsed.key_id, r#"legit"evil\trail"#);
370 assert!(emitted.contains(r#"\""#));
372 assert!(emitted.contains(r"\\"));
373 }
374
375 #[test]
376 fn parameter_value_with_lone_trailing_backslash_is_rejected() {
377 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 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 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}