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    #[must_use]
77    pub fn from_token(token: &str) -> Option<Self> {
78        match token {
79            "sha-256" => Some(Self::Sha256),
80            "sha-512" => Some(Self::Sha512),
81            _ => None,
82        }
83    }
84}
85
86/// Computes the conventional Mastodon-compatible single-algorithm
87/// `Content-Digest:` value carrying only a `sha-256` entry.
88///
89/// Equivalent to
90/// `content_digest_header_with(body, &[DigestAlgorithm::Sha256])` but
91/// kept as a stable convenience entry point.
92#[must_use]
93pub fn content_digest_header(body: &[u8]) -> String {
94    content_digest_header_with(body, &[DigestAlgorithm::Sha256])
95}
96
97/// Computes a multi-algorithm `Content-Digest:` value carrying one
98/// dictionary entry per requested algorithm, in the order they are
99/// supplied.
100///
101/// # Panics
102///
103/// Panics only if `sfv` fails to serialise a byte-sequence-only
104/// dictionary; this is unreachable for any well-formed input, since the
105/// algorithm tokens are hard-coded to valid sf-keys.
106#[must_use]
107#[allow(
108    clippy::expect_used,
109    reason = "algorithm tokens are hard-coded valid sf-keys and byte-sequence dictionaries always serialise"
110)]
111pub fn content_digest_header_with(body: &[u8], algorithms: &[DigestAlgorithm]) -> String {
112    let mut dict = Dictionary::new();
113    for algo in algorithms {
114        let key = Key::try_from(algo.token().to_owned())
115            .expect("algorithm token is always a valid sf-key");
116        dict.insert(
117            key,
118            ListEntry::Item(Item::new(BareItem::ByteSequence(algo.hash(body)))),
119        );
120    }
121    FieldType::serialize(&dict).expect("byte-sequence dictionary is always serialisable")
122}
123
124/// Verifies that the `Content-Digest:` header carries a `sha-256`
125/// entry matching `body`.
126///
127/// This is the strict Mastodon-compatible verifier: it requires SHA-256
128/// specifically. To accept any of several algorithms, use
129/// [`verify_any_content_digest_header`].
130///
131/// # Errors
132///
133/// Returns [`Error::UnsupportedDigestAlgorithm`] if no `sha-256` entry
134/// is present, [`Error::InvalidHeader`] on structured-field parse
135/// failure, and [`Error::DigestMismatch`] if the hash does not match.
136pub fn verify_content_digest_header(header: &str, body: &[u8]) -> Result<(), Error> {
137    verify_specific_digest(header, body, DigestAlgorithm::Sha256)
138}
139
140/// Verifies that the `Content-Digest:` header carries **at least one**
141/// matching entry across the supplied accepted algorithms.
142///
143/// Iterates `accepted` in caller-supplied priority order; the first
144/// algorithm whose dictionary entry matches the body's hash is taken
145/// as proof. Algorithms in the header that are not in `accepted` are
146/// ignored. If no entry verifies (because none of the accepted
147/// algorithms are present, or every matching entry mismatches), the
148/// last error encountered is propagated.
149///
150/// # Errors
151///
152/// Returns [`Error::UnsupportedDigestAlgorithm`] when none of the
153/// accepted algorithms appear in the header, [`Error::InvalidHeader`]
154/// on parse failure, and [`Error::DigestMismatch`] when an accepted
155/// algorithm appears but its byte sequence does not match the body.
156pub fn verify_any_content_digest_header(
157    header: &str,
158    body: &[u8],
159    accepted: &[DigestAlgorithm],
160) -> Result<DigestAlgorithm, Error> {
161    let dict = parse_content_digest_dict(header)?;
162
163    let mut last_err: Option<Error> = None;
164    let mut saw_any = false;
165    for algo in accepted {
166        let Some(entry) = dict.get(algo.token()) else {
167            continue;
168        };
169        saw_any = true;
170        let bytes = match extract_byte_seq(entry, algo.token()) {
171            Ok(b) => b,
172            Err(e) => {
173                last_err = Some(e);
174                continue;
175            }
176        };
177        let expected = algo.hash(body);
178        if constant_time_eq(bytes, &expected) {
179            return Ok(*algo);
180        }
181        last_err = Some(Error::DigestMismatch);
182    }
183
184    if !saw_any {
185        return Err(Error::UnsupportedDigestAlgorithm(format!(
186            "Content-Digest carries no entry for any of the accepted algorithms: {}",
187            accepted
188                .iter()
189                .map(|a| a.token())
190                .collect::<Vec<_>>()
191                .join(", "),
192        )));
193    }
194    Err(last_err.unwrap_or(Error::DigestMismatch))
195}
196
197fn verify_specific_digest(header: &str, body: &[u8], algo: DigestAlgorithm) -> Result<(), Error> {
198    let dict = parse_content_digest_dict(header)?;
199
200    let Some(entry) = dict.get(algo.token()) else {
201        return Err(Error::UnsupportedDigestAlgorithm(format!(
202            "Content-Digest does not contain a {} entry",
203            algo.token()
204        )));
205    };
206
207    let bytes = extract_byte_seq(entry, algo.token())?;
208    let expected = algo.hash(body);
209    if !constant_time_eq(bytes, &expected) {
210        return Err(Error::DigestMismatch);
211    }
212    Ok(())
213}
214
215fn parse_content_digest_dict(header: &str) -> Result<Dictionary, Error> {
216    Parser::new(header)
217        .parse::<Dictionary>()
218        .map_err(|e| Error::InvalidHeader {
219            name: "content-digest",
220            reason: e.to_string(),
221        })
222}
223
224fn extract_byte_seq<'a>(entry: &'a ListEntry, algo_token: &str) -> Result<&'a [u8], Error> {
225    let item = match entry {
226        ListEntry::Item(item) => item,
227        ListEntry::InnerList(_) => {
228            return Err(Error::InvalidHeader {
229                name: "content-digest",
230                reason: format!("{algo_token} entry must be an item, not an inner list"),
231            });
232        }
233    };
234    let BareItem::ByteSequence(bytes) = &item.bare_item else {
235        return Err(Error::InvalidHeader {
236            name: "content-digest",
237            reason: format!("{algo_token} value must be a byte sequence"),
238        });
239    };
240    Ok(bytes)
241}
242
243/// Constant-time byte comparison; see the notes in [`crate::digest`].
244fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
245    if a.len() != b.len() {
246        return false;
247    }
248    let mut diff = 0u8;
249    for (x, y) in a.iter().zip(b.iter()) {
250        diff |= x ^ y;
251    }
252    diff == 0
253}
254
255#[cfg(test)]
256mod tests {
257    use pretty_assertions::assert_eq;
258
259    use super::*;
260
261    #[test]
262    fn emits_rfc9530_value_for_empty_body() {
263        let header = content_digest_header(b"");
264        assert_eq!(
265            header,
266            "sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:"
267        );
268    }
269
270    #[test]
271    fn roundtrips_sign_then_verify() {
272        let body = b"Hello, Fediverse";
273        let header = content_digest_header(body);
274        verify_content_digest_header(&header, body).expect("matching body must verify");
275    }
276
277    #[test]
278    fn tampered_body_fails_verify() {
279        let header = content_digest_header(b"original");
280        let err = verify_content_digest_header(&header, b"tampered")
281            .expect_err("tampered body must not verify");
282        assert!(matches!(err, Error::DigestMismatch));
283    }
284
285    #[test]
286    fn missing_sha256_entry_returns_unsupported_algorithm() {
287        let header = "sha-512=:AAAA:";
288        let err =
289            verify_content_digest_header(header, b"").expect_err("sha-512 only must be rejected");
290        assert!(matches!(err, Error::UnsupportedDigestAlgorithm(_)));
291    }
292
293    #[test]
294    fn malformed_structured_field_is_rejected() {
295        // An unclosed inner list is a genuine sf-dictionary parse failure.
296        let err = verify_content_digest_header("sha-256=(unclosed", b"").expect_err("malformed");
297        assert!(
298            matches!(err, Error::InvalidHeader { .. }),
299            "expected InvalidHeader, got {err:?}",
300        );
301    }
302
303    #[test]
304    fn mixed_algorithm_dictionary_accepts_on_sha256_match() {
305        let body = b"payload";
306        let sha256 = content_digest_header(body)
307            .strip_prefix("sha-256=")
308            .expect("has prefix")
309            .to_owned();
310        let mixed = format!("sha-512=:AAAA:, sha-256={sha256}");
311        verify_content_digest_header(&mixed, body)
312            .expect("dictionaries with extra algorithms are fine");
313    }
314
315    #[test]
316    fn multi_algorithm_header_carries_both_entries_in_order() {
317        let body = b"payload";
318        let header =
319            content_digest_header_with(body, &[DigestAlgorithm::Sha256, DigestAlgorithm::Sha512]);
320        assert!(header.starts_with("sha-256=:"), "sha-256 first: {header}");
321        assert!(header.contains("sha-512=:"), "sha-512 present: {header}");
322    }
323
324    #[test]
325    fn verify_any_picks_first_accepted_match() {
326        let body = b"payload";
327        let header =
328            content_digest_header_with(body, &[DigestAlgorithm::Sha256, DigestAlgorithm::Sha512]);
329        // Caller prefers SHA-512: it must be picked because both verify.
330        let chosen = verify_any_content_digest_header(
331            &header,
332            body,
333            &[DigestAlgorithm::Sha512, DigestAlgorithm::Sha256],
334        )
335        .expect("any-of must verify");
336        assert_eq!(chosen, DigestAlgorithm::Sha512);
337    }
338
339    #[test]
340    fn verify_any_falls_back_to_second_when_first_absent() {
341        let body = b"payload";
342        let sha256_only = content_digest_header_with(body, &[DigestAlgorithm::Sha256]);
343        let chosen = verify_any_content_digest_header(
344            &sha256_only,
345            body,
346            &[DigestAlgorithm::Sha512, DigestAlgorithm::Sha256],
347        )
348        .expect("sha-256 fallback must verify");
349        assert_eq!(chosen, DigestAlgorithm::Sha256);
350    }
351
352    #[test]
353    fn verify_any_returns_unsupported_when_no_accepted_algorithm_present() {
354        let body = b"payload";
355        let sha256_only = content_digest_header_with(body, &[DigestAlgorithm::Sha256]);
356        let err = verify_any_content_digest_header(&sha256_only, body, &[DigestAlgorithm::Sha512])
357            .expect_err("sha-512 only acceptance must fail when only sha-256 is present");
358        assert!(matches!(err, Error::UnsupportedDigestAlgorithm(_)));
359    }
360
361    #[test]
362    fn verify_any_returns_mismatch_when_only_present_algorithm_disagrees() {
363        // SHA-512 entry whose value is a 64-byte zero blob; will not match
364        // the actual hash of any non-empty body.
365        let header = "sha-512=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:";
366        let err = verify_any_content_digest_header(header, b"payload", &[DigestAlgorithm::Sha512])
367            .expect_err("mismatched bytes must not verify");
368        assert!(matches!(err, Error::DigestMismatch));
369    }
370
371    #[test]
372    fn algorithm_round_trips_through_token() {
373        for algo in [DigestAlgorithm::Sha256, DigestAlgorithm::Sha512] {
374            let token = algo.token();
375            assert_eq!(DigestAlgorithm::from_token(token), Some(algo));
376        }
377        assert_eq!(DigestAlgorithm::from_token("sha-1"), None);
378    }
379}