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]
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#[must_use]
93pub fn content_digest_header(body: &[u8]) -> String {
94 content_digest_header_with(body, &[DigestAlgorithm::Sha256])
95}
96
97#[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
124pub fn verify_content_digest_header(header: &str, body: &[u8]) -> Result<(), Error> {
137 verify_specific_digest(header, body, DigestAlgorithm::Sha256)
138}
139
140pub 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
243fn 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 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 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 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}