Skip to main content

actpub_httpsig/
digest.rs

1//! Legacy `Digest:` header (Cavage-style), RFC 3230 / RFC 5843.
2//!
3//! Fediverse servers add a `Digest: SHA-256=<base64>` header to signed
4//! `POST` requests so that the signature can bind the body without
5//! actually hashing it into the signature base itself. The modern
6//! replacement is RFC 9530 `Content-Digest` (see [`crate::content_digest`]),
7//! but Mastodon and its siblings still mandate the legacy form today.
8
9use aws_lc_rs::digest::{self, SHA256};
10use base64ct::{Base64, Encoding};
11
12use crate::error::Error;
13
14/// Prefix emitted in the legacy `Digest:` header for SHA-256.
15///
16/// RFC 5843 defines the token case-insensitively as `SHA-256`; this crate
17/// emits it in the exact casing used by every major Fediverse
18/// implementation.
19pub const SHA256_DIGEST_PREFIX: &str = "SHA-256=";
20
21/// Computes the legacy `Digest:` header value for `body`.
22///
23/// Returns a string of the form `SHA-256=<base64>`, ready to insert as an
24/// HTTP header value.
25#[must_use]
26pub fn sha256_digest_header(body: &[u8]) -> String {
27    let hash = digest::digest(&SHA256, body);
28    let encoded = Base64::encode_string(hash.as_ref());
29    format!("{SHA256_DIGEST_PREFIX}{encoded}")
30}
31
32/// Verifies that the `Digest:` header value matches the computed digest of
33/// `body`.
34///
35/// Accepts only the `SHA-256` algorithm; other algorithms result in
36/// [`Error::UnsupportedDigestAlgorithm`], matching Fediverse practice.
37///
38/// # Errors
39///
40/// Returns [`Error::UnsupportedDigestAlgorithm`] if the header prefix is
41/// not `SHA-256=`, [`Error::InvalidBase64`] if the base64 body is
42/// malformed, and [`Error::DigestMismatch`] if the hash does not match.
43pub fn verify_digest_header(header: &str, body: &[u8]) -> Result<(), Error> {
44    let encoded = header
45        .strip_prefix(SHA256_DIGEST_PREFIX)
46        .or_else(|| header.strip_prefix("sha-256="))
47        .or_else(|| header.strip_prefix("Sha-256="))
48        .ok_or_else(|| {
49            let algo = header
50                .split_once('=')
51                .map_or_else(|| header.to_owned(), |(a, _)| a.to_owned());
52            Error::UnsupportedDigestAlgorithm(algo)
53        })?;
54
55    let expected = digest::digest(&SHA256, body);
56
57    // Decode into a heap buffer sized from the payload: if the
58    // sender declared `SHA-256=` but attached a 64-byte digest
59    // (e.g. SHA-512 bytes), we still want to surface the result
60    // as `DigestMismatch` rather than `InvalidBase64`, so the
61    // calling code has one consistent failure mode for every
62    // "body and digest disagree" shape.
63    let decoded = Base64::decode_vec(encoded).map_err(|_| Error::DigestMismatch)?;
64    if !constant_time_eq(&decoded, expected.as_ref()) {
65        return Err(Error::DigestMismatch);
66    }
67    Ok(())
68}
69
70/// Constant-time byte comparison.
71///
72/// Using a variable-time `==` here would leak timing information about
73/// where a mismatch occurs, which in turn would let an attacker forge
74/// partial digests byte-by-byte.
75fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
76    if a.len() != b.len() {
77        return false;
78    }
79    let mut diff = 0u8;
80    for (x, y) in a.iter().zip(b.iter()) {
81        diff |= x ^ y;
82    }
83    diff == 0
84}
85
86#[cfg(test)]
87mod tests {
88    use pretty_assertions::assert_eq;
89
90    use super::*;
91
92    /// SHA-256 of the empty string, pre-computed and base64-encoded.
93    const EMPTY_SHA256_B64: &str = "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=";
94
95    #[test]
96    fn sha256_digest_of_empty_body_matches_vector() {
97        let header = sha256_digest_header(b"");
98        assert_eq!(header, format!("{SHA256_DIGEST_PREFIX}{EMPTY_SHA256_B64}"));
99    }
100
101    #[test]
102    fn digest_roundtrips_through_verify() {
103        let body = b"Hello, Fediverse!";
104        let header = sha256_digest_header(body);
105        verify_digest_header(&header, body).expect("digest must verify");
106    }
107
108    #[test]
109    fn tampered_body_fails_verify() {
110        let header = sha256_digest_header(b"original body");
111        let err = verify_digest_header(&header, b"tampered body")
112            .expect_err("tampered body must not verify");
113        assert!(matches!(err, Error::DigestMismatch));
114    }
115
116    #[test]
117    fn unknown_algorithm_is_rejected() {
118        let err =
119            verify_digest_header("SHA-512=abcdef", b"").expect_err("SHA-512 must be rejected");
120        match err {
121            Error::UnsupportedDigestAlgorithm(algo) => assert_eq!(algo, "SHA-512"),
122            other => panic!("expected UnsupportedDigestAlgorithm, got {other:?}"),
123        }
124    }
125
126    #[test]
127    fn lowercase_algorithm_token_is_accepted() {
128        // Some client libraries emit the token lowercased.
129        let body = b"interop tolerance";
130        let upper = sha256_digest_header(body);
131        let encoded = upper
132            .strip_prefix(SHA256_DIGEST_PREFIX)
133            .expect("has prefix");
134        let lower = format!("sha-256={encoded}");
135        verify_digest_header(&lower, body).expect("lowercase prefix must be accepted");
136    }
137}