1use aws_lc_rs::digest::{self, SHA256, SHA512};
31use sfv::{BareItem, Dictionary, FieldType, Item, Key, ListEntry, Parser};
32
33use crate::error::Error;
34
35pub const CONTENT_DIGEST_HEADER: &str = "content-digest";
37
38#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
45#[non_exhaustive]
46pub enum DigestAlgorithm {
47 Sha256,
49 Sha512,
52}
53
54impl DigestAlgorithm {
55 #[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 #[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 #[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#[must_use]
100pub fn content_digest_header(body: &[u8]) -> String {
101 content_digest_header_with(body, &[DigestAlgorithm::Sha256])
102}
103
104#[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
131pub fn verify_content_digest_header(header: &str, body: &[u8]) -> Result<(), Error> {
144 verify_specific_digest(header, body, DigestAlgorithm::Sha256)
145}
146
147pub 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
250fn 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 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 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 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}