Skip to main content

xmtp_attachments/
derive.rs

1use sha2::{Digest as _, Sha256};
2use xmtp_content_types::remote_attachment::RemoteAttachment;
3
4use crate::{AttachmentError, KeyMaterial, local_file_name};
5
6/// Append a ciphertext digest to the configured base URL.
7pub fn download_url(base_url: &str, content_digest_hex: &str) -> String {
8    format!("{base_url}/{content_digest_hex}")
9}
10
11/// Build the metadata published for a staged ciphertext.
12pub fn remote_attachment(
13    base_url: &str,
14    content_digest_hex: &str,
15    material: &KeyMaterial,
16    content_length: u32,
17    filename: Option<&str>,
18) -> RemoteAttachment {
19    let url = download_url(base_url, content_digest_hex);
20    let scheme = url
21        .split_once("://")
22        .map_or(String::new(), |(scheme, _)| format!("{scheme}://"));
23    RemoteAttachment {
24        url,
25        content_digest: content_digest_hex.to_owned(),
26        secret: material.secret.to_vec(),
27        salt: material.salt.to_vec(),
28        nonce: material.nonce.to_vec(),
29        content_length: Some(content_length),
30        filename: filename.map(str::to_owned),
31        scheme,
32    }
33}
34
35/// Hash all key material to name the local attachment directory.
36pub fn attachment_key(ra: &RemoteAttachment) -> Result<String, AttachmentError> {
37    let material = KeyMaterial::from_remote(ra)?;
38    let mut digest = [0u8; 32];
39    hex::decode_to_slice(&ra.content_digest, &mut digest)
40        .map_err(|_| AttachmentError::new(crate::AttachmentFailureCause::Malformed))?;
41    let mut hash = Sha256::new();
42    hash.update(digest);
43    hash.update(material.secret);
44    hash.update(material.salt);
45    hash.update(material.nonce);
46    Ok(hex::encode(hash.finalize()))
47}
48
49/// Derive the relative plaintext path without reading storage or a network.
50pub fn plaintext_rel_path(ra: &RemoteAttachment) -> Result<String, AttachmentError> {
51    Ok(format!(
52        "{}/{}",
53        attachment_key(ra)?,
54        local_file_name(ra.filename.as_deref())
55    ))
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use crate::AttachmentFailureCause;
62
63    fn material() -> KeyMaterial {
64        KeyMaterial {
65            secret: std::array::from_fn(|index| index as u8 + 32),
66            salt: std::array::from_fn(|index| index as u8 + 64),
67            nonce: std::array::from_fn(|index| index as u8 + 96),
68        }
69    }
70
71    fn remote() -> RemoteAttachment {
72        remote_attachment(
73            "https://attachments.example.test/objects",
74            &hex::encode((0u8..32).collect::<Vec<_>>()),
75            &material(),
76            1234,
77            Some("report.pdf"),
78        )
79    }
80
81    // verifies: ATCH-010
82    #[xmtp_common::test(unwrap_try = true)]
83    async fn download_url_form() {
84        assert_eq!(
85            download_url("https://example.test/objects", "abc123"),
86            "https://example.test/objects/abc123"
87        );
88    }
89
90    #[xmtp_common::test(unwrap_try = true)]
91    async fn remote_attachment_fields() {
92        let ra = remote();
93        assert_eq!(
94            ra.url,
95            format!(
96                "https://attachments.example.test/objects/{}",
97                ra.content_digest
98            )
99        );
100        assert_eq!(ra.scheme, "https://");
101        assert_eq!(ra.content_length, Some(1234));
102        assert_eq!(ra.filename.as_deref(), Some("report.pdf"));
103        assert_eq!(ra.secret, material().secret);
104        assert_eq!(ra.salt, material().salt);
105        assert_eq!(ra.nonce, material().nonce);
106        assert_eq!(KeyMaterial::from_remote(&ra)?, material());
107    }
108
109    // This digest was computed separately with Python hashlib from bytes 0..108.
110    // verifies: ATCH-041
111    #[xmtp_common::test(unwrap_try = true)]
112    async fn attachment_key_vector() {
113        let ra = remote();
114        let expected = "44d21db70716bd7644cb0d819fa6791805ebc526ea32996a60e41dc753fcfafc";
115        assert_eq!(attachment_key(&ra)?, expected);
116        assert_eq!(plaintext_rel_path(&ra)?, format!("{expected}/report.pdf"));
117    }
118
119    #[xmtp_common::test(unwrap_try = true)]
120    async fn malformed_key_material() {
121        let valid = remote();
122        let mut bad_cases = Vec::new();
123        let mut bad = valid.clone();
124        bad.content_digest.pop();
125        bad_cases.push(bad);
126        let mut bad = valid.clone();
127        bad.content_digest.replace_range(..1, "A");
128        bad_cases.push(bad);
129        let mut bad = valid.clone();
130        bad.secret.pop();
131        bad_cases.push(bad);
132        let mut bad = valid.clone();
133        bad.salt.pop();
134        bad_cases.push(bad);
135        let mut bad = valid;
136        bad.nonce.pop();
137        bad_cases.push(bad);
138        for ra in bad_cases {
139            assert_eq!(
140                KeyMaterial::from_remote(&ra).unwrap_err().cause,
141                AttachmentFailureCause::Malformed
142            );
143            assert_eq!(
144                attachment_key(&ra).unwrap_err().cause,
145                AttachmentFailureCause::Malformed
146            );
147            assert_eq!(
148                plaintext_rel_path(&ra).unwrap_err().cause,
149                AttachmentFailureCause::Malformed
150            );
151        }
152    }
153}