Skip to main content

asimov_http/
digest.rs

1// This is free and unencumbered software released into the public domain.
2
3use alloc::{format, string::String};
4use base64::{Engine as _, engine::general_purpose::STANDARD};
5use sha2::{Digest as _, Sha256};
6
7/// A SHA-256 digest of content bytes, after content coding and before framing.
8///
9/// This value can also describe representation data in `Repr-Digest`, provided
10/// the caller hashes the entire selected representation, not a range or an
11/// empty HEAD response. JSON canonicalization is not part of RFC 9530 hashing.
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub struct ContentDigest([u8; 32]);
14
15impl ContentDigest {
16    /// Hashes the exact bytes to be transmitted.
17    pub fn new(content: &[u8]) -> Self {
18        Self(Sha256::digest(content).into())
19    }
20
21    /// Wraps a previously computed SHA-256 hash.
22    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
23        Self(bytes)
24    }
25
26    /// Returns the raw SHA-256 hash.
27    pub const fn as_bytes(&self) -> &[u8; 32] {
28        &self.0
29    }
30
31    /// Serializes the RFC 9530 dictionary using padded standard Base64.
32    pub fn to_field_value(self) -> String {
33        format!("sha-256=:{}:", STANDARD.encode(self.0))
34    }
35
36    /// Parses a digest dictionary and selects its SHA-256 member.
37    ///
38    /// Unknown algorithms are ignored after validating the dictionary's syntax
39    /// and byte-sequence member types. No weaker algorithm is used as fallback.
40    #[cfg(feature = "integrity")]
41    pub fn parse(value: &str) -> Result<Self, crate::Error> {
42        Self::find(value)?.ok_or(crate::Error::ContentDigest)
43    }
44
45    #[cfg(feature = "integrity")]
46    fn find(value: &str) -> Result<Option<Self>, crate::Error> {
47        let dictionary = crate::fields::dictionary(value)?;
48        for entry in dictionary.values() {
49            if !matches!(entry, sfv::ListEntry::Item(item)
50                if item.bare_item.as_byte_sequence().is_some())
51            {
52                return Err(crate::Error::InvalidField(
53                    "digest members must be byte sequences",
54                ));
55            }
56        }
57        let Some(sfv::ListEntry::Item(item)) = dictionary.get("sha-256") else {
58            return Ok(None);
59        };
60        let bytes = item
61            .bare_item
62            .as_byte_sequence()
63            .ok_or(crate::Error::ContentDigest)?;
64        Ok(Some(Self(
65            bytes.try_into().map_err(|_| crate::Error::ContentDigest)?,
66        )))
67    }
68
69    /// Verifies every supplied SHA-256 digest, preserving header/trailer scope.
70    /// At least one section must contain a SHA-256 digest.
71    #[cfg(feature = "integrity")]
72    pub fn verify(
73        self,
74        headers: &http::HeaderMap,
75        trailers: &http::HeaderMap,
76    ) -> Result<(), crate::Error> {
77        let mut found = false;
78        for section in [headers, trailers] {
79            if let Some(value) = crate::fields::combined(&[section], "content-digest")?
80                && let Some(expected) = Self::find(&value)?
81            {
82                if expected != self {
83                    return Err(crate::Error::ContentDigest);
84                }
85                found = true;
86            }
87        }
88        if found {
89            Ok(())
90        } else {
91            Err(crate::Error::ContentDigest)
92        }
93    }
94}
95
96/// A SHA-256 preference weight for `Want-Content-Digest` or `Want-Repr-Digest`.
97/// Zero means unacceptable, one least preferred, and ten most preferred.
98#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
99pub struct DigestPreference(u8);
100
101impl DigestPreference {
102    /// Returns `None` for a weight outside RFC 9530's `0..=10` range.
103    pub const fn new(value: u8) -> Option<Self> {
104        if value <= 10 { Some(Self(value)) } else { None }
105    }
106
107    /// Returns the integer preference weight.
108    pub const fn value(self) -> u8 {
109        self.0
110    }
111}
112
113/// RFC 9530 output fields for a complete, buffered representation.
114///
115/// The default sends only a SHA-256 `Content-Digest`. Preferences are advisory
116/// and do not activate verification. Representation digests must describe the
117/// complete selected representation, which is the body for ordinary JSON POSTs.
118#[derive(Clone, Copy, Debug, Eq, PartialEq)]
119pub struct DigestOptions {
120    /// Send `Content-Digest` (default true).
121    pub content: bool,
122    /// Send `Repr-Digest` (default false).
123    pub representation: bool,
124    /// Optional `Want-Content-Digest` weight.
125    pub want_content: Option<DigestPreference>,
126    /// Optional `Want-Repr-Digest` weight.
127    pub want_representation: Option<DigestPreference>,
128}
129
130impl Default for DigestOptions {
131    fn default() -> Self {
132        Self {
133            content: true,
134            representation: false,
135            want_content: None,
136            want_representation: None,
137        }
138    }
139}
140
141impl DigestOptions {
142    /// Creates fields for complete representation bytes, usable in either a
143    /// header or trailer map. Partial representations require separate hashing.
144    #[cfg(feature = "integrity")]
145    pub fn headers(&self, body: &[u8]) -> http::HeaderMap {
146        let mut headers = http::HeaderMap::new();
147        if self.content || self.representation {
148            let value = http::HeaderValue::try_from(ContentDigest::new(body).to_field_value())
149                .expect("digest fields contain only visible ASCII");
150            if self.content {
151                headers.insert("content-digest", value.clone());
152            }
153            if self.representation {
154                headers.insert("repr-digest", value);
155            }
156        }
157        for (name, preference) in [
158            ("want-content-digest", self.want_content),
159            ("want-repr-digest", self.want_representation),
160        ] {
161            if let Some(preference) = preference {
162                headers.insert(
163                    name,
164                    http::HeaderValue::try_from(format!("sha-256={}", preference.value()))
165                        .expect("digest preferences contain only visible ASCII"),
166                );
167            }
168        }
169        headers
170    }
171}