Skip to main content

actpub_httpsig/
content_digest.rs

1//! [RFC 9530] `Content-Digest:` header with multi-algorithm support.
2//!
3//! The modern successor to the legacy `Digest:` header implemented in
4//! [`crate::digest`]. Its value is a Structured Field dictionary whose
5//! keys are hash-algorithm names and whose values are byte sequences
6//! (`:<base64>:`) per RFC 8941, allowing one header to carry several
7//! digest algorithms simultaneously:
8//!
9//! ```text
10//! Content-Digest: sha-256=:X48…=:, sha-512=:9KQ…=:
11//! ```
12//!
13//! Mastodon today emits only `sha-256`; Mitra and Takahē are migrating
14//! to multi-algorithm `sha-256`+`sha-512` headers. This module supports
15//! both the legacy single-algorithm and the modern multi-algorithm
16//! shapes on read and write paths.
17//!
18//! # Wire-up at a glance
19//!
20//! - **Outgoing**: [`content_digest_header`] for the conventional
21//!   single-algorithm SHA-256 case, or
22//!   [`content_digest_header_with`] for arbitrary algorithm sets.
23//! - **Incoming**: [`verify_content_digest_header`] requires SHA-256 to
24//!   match (matches Mastodon today); [`verify_any_content_digest_header`]
25//!   succeeds if **any** algorithm in the supplied accepted list
26//!   verifies, suitable for liberal interoperability.
27//!
28//! [RFC 9530]: https://www.rfc-editor.org/rfc/rfc9530.html
29
30use aws_lc_rs::digest::{self, SHA256, SHA512};
31use sfv::{BareItem, Dictionary, FieldType, Item, Key, ListEntry, Parser};
32
33use crate::error::Error;
34
35/// Name of the `Content-Digest:` HTTP header.
36pub const CONTENT_DIGEST_HEADER: &str = "content-digest";
37
38/// A hash algorithm registered with the IANA Hash Algorithm Names
39/// registry and accepted by RFC 9530 `Content-Digest`.
40///
41/// Only the algorithms the Fediverse actually uses today are
42/// enumerated; future algorithms can be added without breaking the wire
43/// format because the variant set is `#[non_exhaustive]`.
44#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
45#[non_exhaustive]
46pub enum DigestAlgorithm {
47    /// SHA-256 — universally supported by today's Fediverse.
48    Sha256,
49    /// SHA-512 — emitted by Mitra and Takahē, accepted by
50    /// Mastodon 4.5+.
51    Sha512,
52}
53
54impl DigestAlgorithm {
55    /// IANA-registered token used as the dictionary key in the wire
56    /// header.
57    #[must_use]
58    pub const fn token(self) -> &'static str {
59        match self {
60            Self::Sha256 => "sha-256",
61            Self::Sha512 => "sha-512",
62        }
63    }
64
65    /// Computes the digest bytes of `body` under this algorithm.
66    #[must_use]
67    pub fn hash(self, body: &[u8]) -> Vec<u8> {
68        match self {
69            Self::Sha256 => digest::digest(&SHA256, body).as_ref().to_vec(),
70            Self::Sha512 => digest::digest(&SHA512, body).as_ref().to_vec(),
71        }
72    }
73
74    /// Parses the IANA token into an algorithm variant. Returns `None`
75    /// for tokens this crate does not recognise.
76    ///
77    /// RFC 9530 mandates the lowercase canonical form on the wire, but
78    /// real-world senders occasionally emit `SHA-256`; we follow
79    /// Postel's law and accept any ASCII casing on the read side while
80    /// still emitting the canonical form.
81    #[must_use]
82    pub const fn from_token(token: &str) -> Option<Self> {
83        if token.eq_ignore_ascii_case("sha-256") {
84            Some(Self::Sha256)
85        } else if token.eq_ignore_ascii_case("sha-512") {
86            Some(Self::Sha512)
87        } else {
88            None
89        }
90    }
91}
92
93/// Computes the conventional Mastodon-compatible single-algorithm
94/// `Content-Digest:` value carrying only a `sha-256` entry.
95///
96/// Equivalent to
97/// `content_digest_header_with(body, &[DigestAlgorithm::Sha256])` but
98/// kept as a stable convenience entry point.
99#[must_use]
100pub fn content_digest_header(body: &[u8]) -> String {
101    content_digest_header_with(body, &[DigestAlgorithm::Sha256])
102}
103
104/// Computes a multi-algorithm `Content-Digest:` value carrying one
105/// dictionary entry per requested algorithm, in the order they are
106/// supplied.
107///
108/// # Panics
109///
110/// Panics only if `sfv` fails to serialise a byte-sequence-only
111/// dictionary; this is unreachable for any well-formed input, since the
112/// algorithm tokens are hard-coded to valid sf-keys.
113#[must_use]
114#[allow(
115    clippy::expect_used,
116    reason = "algorithm tokens are hard-coded valid sf-keys and byte-sequence dictionaries always serialise"
117)]
118pub fn content_digest_header_with(body: &[u8], algorithms: &[DigestAlgorithm]) -> String {
119    let mut dict = Dictionary::new();
120    for algo in algorithms {
121        let key = Key::try_from(algo.token().to_owned())
122            .expect("algorithm token is always a valid sf-key");
123        dict.insert(
124            key,
125            ListEntry::Item(Item::new(BareItem::ByteSequence(algo.hash(body)))),
126        );
127    }
128    FieldType::serialize(&dict).expect("byte-sequence dictionary is always serialisable")
129}
130
131/// Verifies that the `Content-Digest:` header carries a `sha-256`
132/// entry matching `body`.
133///
134/// This is the strict Mastodon-compatible verifier: it requires SHA-256
135/// specifically. To accept any of several algorithms, use
136/// [`verify_any_content_digest_header`].
137///
138/// # Errors
139///
140/// Returns [`Error::UnsupportedDigestAlgorithm`] if no `sha-256` entry
141/// is present, [`Error::InvalidHeader`] on structured-field parse
142/// failure, and [`Error::DigestMismatch`] if the hash does not match.
143pub fn verify_content_digest_header(header: &str, body: &[u8]) -> Result<(), Error> {
144    verify_specific_digest(header, body, DigestAlgorithm::Sha256)
145}
146
147/// Verifies that the `Content-Digest:` header carries **at least one**
148/// matching entry across the supplied accepted algorithms.
149///
150/// Iterates `accepted` in caller-supplied priority order; the first
151/// algorithm whose dictionary entry matches the body's hash is taken
152/// as proof. Algorithms in the header that are not in `accepted` are
153/// ignored. If no entry verifies (because none of the accepted
154/// algorithms are present, or every matching entry mismatches), the
155/// last error encountered is propagated.
156///
157/// # Errors
158///
159/// Returns [`Error::UnsupportedDigestAlgorithm`] when none of the
160/// accepted algorithms appear in the header, [`Error::InvalidHeader`]
161/// on parse failure, and [`Error::DigestMismatch`] when an accepted
162/// algorithm appears but its byte sequence does not match the body.
163pub fn verify_any_content_digest_header(
164    header: &str,
165    body: &[u8],
166    accepted: &[DigestAlgorithm],
167) -> Result<DigestAlgorithm, Error> {
168    let dict = parse_content_digest_dict(header)?;
169
170    let mut last_err: Option<Error> = None;
171    let mut saw_any = false;
172    for algo in accepted {
173        let Some(entry) = dict.get(algo.token()) else {
174            continue;
175        };
176        saw_any = true;
177        let bytes = match extract_byte_seq(entry, algo.token()) {
178            Ok(b) => b,
179            Err(e) => {
180                last_err = Some(e);
181                continue;
182            }
183        };
184        let expected = algo.hash(body);
185        if constant_time_eq(bytes, &expected) {
186            return Ok(*algo);
187        }
188        last_err = Some(Error::DigestMismatch);
189    }
190
191    if !saw_any {
192        return Err(Error::UnsupportedDigestAlgorithm(format!(
193            "Content-Digest carries no entry for any of the accepted algorithms: {}",
194            accepted
195                .iter()
196                .map(|a| a.token())
197                .collect::<Vec<_>>()
198                .join(", "),
199        )));
200    }
201    Err(last_err.unwrap_or(Error::DigestMismatch))
202}
203
204fn verify_specific_digest(header: &str, body: &[u8], algo: DigestAlgorithm) -> Result<(), Error> {
205    let dict = parse_content_digest_dict(header)?;
206
207    let Some(entry) = dict.get(algo.token()) else {
208        return Err(Error::UnsupportedDigestAlgorithm(format!(
209            "Content-Digest does not contain a {} entry",
210            algo.token()
211        )));
212    };
213
214    let bytes = extract_byte_seq(entry, algo.token())?;
215    let expected = algo.hash(body);
216    if !constant_time_eq(bytes, &expected) {
217        return Err(Error::DigestMismatch);
218    }
219    Ok(())
220}
221
222fn parse_content_digest_dict(header: &str) -> Result<Dictionary, Error> {
223    Parser::new(header)
224        .parse::<Dictionary>()
225        .map_err(|e| Error::InvalidHeader {
226            name: "content-digest",
227            reason: e.to_string(),
228        })
229}
230
231fn extract_byte_seq<'a>(entry: &'a ListEntry, algo_token: &str) -> Result<&'a [u8], Error> {
232    let item = match entry {
233        ListEntry::Item(item) => item,
234        ListEntry::InnerList(_) => {
235            return Err(Error::InvalidHeader {
236                name: "content-digest",
237                reason: format!("{algo_token} entry must be an item, not an inner list"),
238            });
239        }
240    };
241    let BareItem::ByteSequence(bytes) = &item.bare_item else {
242        return Err(Error::InvalidHeader {
243            name: "content-digest",
244            reason: format!("{algo_token} value must be a byte sequence"),
245        });
246    };
247    Ok(bytes)
248}
249
250/// Constant-time byte comparison; see the notes in [`crate::digest`].
251fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
252    if a.len() != b.len() {
253        return false;
254    }
255    let mut diff = 0u8;
256    for (x, y) in a.iter().zip(b.iter()) {
257        diff |= x ^ y;
258    }
259    diff == 0
260}
261
262#[cfg(test)]
263mod tests {
264    use pretty_assertions::assert_eq;
265
266    use super::*;
267
268    #[test]
269    fn emits_rfc9530_value_for_empty_body() {
270        let header = content_digest_header(b"");
271        assert_eq!(
272            header,
273            "sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:"
274        );
275    }
276
277    #[test]
278    fn roundtrips_sign_then_verify() {
279        let body = b"Hello, Fediverse";
280        let header = content_digest_header(body);
281        verify_content_digest_header(&header, body).expect("matching body must verify");
282    }
283
284    #[test]
285    fn tampered_body_fails_verify() {
286        let header = content_digest_header(b"original");
287        let err = verify_content_digest_header(&header, b"tampered")
288            .expect_err("tampered body must not verify");
289        assert!(matches!(err, Error::DigestMismatch));
290    }
291
292    #[test]
293    fn missing_sha256_entry_returns_unsupported_algorithm() {
294        let header = "sha-512=:AAAA:";
295        let err =
296            verify_content_digest_header(header, b"").expect_err("sha-512 only must be rejected");
297        assert!(matches!(err, Error::UnsupportedDigestAlgorithm(_)));
298    }
299
300    #[test]
301    fn malformed_structured_field_is_rejected() {
302        // An unclosed inner list is a genuine sf-dictionary parse failure.
303        let err = verify_content_digest_header("sha-256=(unclosed", b"").expect_err("malformed");
304        assert!(
305            matches!(err, Error::InvalidHeader { .. }),
306            "expected InvalidHeader, got {err:?}",
307        );
308    }
309
310    #[test]
311    fn mixed_algorithm_dictionary_accepts_on_sha256_match() {
312        let body = b"payload";
313        let sha256 = content_digest_header(body)
314            .strip_prefix("sha-256=")
315            .expect("has prefix")
316            .to_owned();
317        let mixed = format!("sha-512=:AAAA:, sha-256={sha256}");
318        verify_content_digest_header(&mixed, body)
319            .expect("dictionaries with extra algorithms are fine");
320    }
321
322    #[test]
323    fn multi_algorithm_header_carries_both_entries_in_order() {
324        let body = b"payload";
325        let header =
326            content_digest_header_with(body, &[DigestAlgorithm::Sha256, DigestAlgorithm::Sha512]);
327        assert!(header.starts_with("sha-256=:"), "sha-256 first: {header}");
328        assert!(header.contains("sha-512=:"), "sha-512 present: {header}");
329    }
330
331    #[test]
332    fn verify_any_picks_first_accepted_match() {
333        let body = b"payload";
334        let header =
335            content_digest_header_with(body, &[DigestAlgorithm::Sha256, DigestAlgorithm::Sha512]);
336        // Caller prefers SHA-512: it must be picked because both verify.
337        let chosen = verify_any_content_digest_header(
338            &header,
339            body,
340            &[DigestAlgorithm::Sha512, DigestAlgorithm::Sha256],
341        )
342        .expect("any-of must verify");
343        assert_eq!(chosen, DigestAlgorithm::Sha512);
344    }
345
346    #[test]
347    fn verify_any_falls_back_to_second_when_first_absent() {
348        let body = b"payload";
349        let sha256_only = content_digest_header_with(body, &[DigestAlgorithm::Sha256]);
350        let chosen = verify_any_content_digest_header(
351            &sha256_only,
352            body,
353            &[DigestAlgorithm::Sha512, DigestAlgorithm::Sha256],
354        )
355        .expect("sha-256 fallback must verify");
356        assert_eq!(chosen, DigestAlgorithm::Sha256);
357    }
358
359    #[test]
360    fn verify_any_returns_unsupported_when_no_accepted_algorithm_present() {
361        let body = b"payload";
362        let sha256_only = content_digest_header_with(body, &[DigestAlgorithm::Sha256]);
363        let err = verify_any_content_digest_header(&sha256_only, body, &[DigestAlgorithm::Sha512])
364            .expect_err("sha-512 only acceptance must fail when only sha-256 is present");
365        assert!(matches!(err, Error::UnsupportedDigestAlgorithm(_)));
366    }
367
368    #[test]
369    fn verify_any_returns_mismatch_when_only_present_algorithm_disagrees() {
370        // SHA-512 entry whose value is a 64-byte zero blob; will not match
371        // the actual hash of any non-empty body.
372        let header = "sha-512=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:";
373        let err = verify_any_content_digest_header(header, b"payload", &[DigestAlgorithm::Sha512])
374            .expect_err("mismatched bytes must not verify");
375        assert!(matches!(err, Error::DigestMismatch));
376    }
377
378    #[test]
379    fn algorithm_round_trips_through_token() {
380        for algo in [DigestAlgorithm::Sha256, DigestAlgorithm::Sha512] {
381            let token = algo.token();
382            assert_eq!(DigestAlgorithm::from_token(token), Some(algo));
383        }
384        assert_eq!(DigestAlgorithm::from_token("sha-1"), None);
385    }
386
387    #[test]
388    fn from_token_is_case_insensitive_for_postel_tolerance() {
389        assert_eq!(
390            DigestAlgorithm::from_token("SHA-256"),
391            Some(DigestAlgorithm::Sha256)
392        );
393        assert_eq!(
394            DigestAlgorithm::from_token("Sha-512"),
395            Some(DigestAlgorithm::Sha512)
396        );
397    }
398}