paygress/
blossom_crypto.rs1use chacha20poly1305::aead::{Aead, KeyInit};
23use chacha20poly1305::{XChaCha20Poly1305, XNonce};
24
25pub type EncryptionKey = [u8; 32];
28
29#[derive(Debug, thiserror::Error)]
33pub enum CryptoError {
34 #[error("ciphertext too short to contain a nonce")]
35 Truncated,
36 #[error("AEAD authentication failed (wrong key or tampered ciphertext)")]
37 AuthenticationFailed,
38 #[error("encryption failed: {0}")]
39 EncryptionFailed(String),
40}
41
42const NONCE_LEN: usize = 24;
43
44pub fn encrypt_for_upload(plaintext: &[u8], key: &EncryptionKey) -> Result<Vec<u8>, CryptoError> {
51 use rand::RngCore;
52 let cipher = XChaCha20Poly1305::new(key.into());
53
54 let mut nonce_bytes = [0u8; NONCE_LEN];
55 rand::thread_rng().fill_bytes(&mut nonce_bytes);
56 let nonce = XNonce::from_slice(&nonce_bytes);
57
58 let ciphertext = cipher
59 .encrypt(nonce, plaintext)
60 .map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
61
62 let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len());
63 out.extend_from_slice(&nonce_bytes);
64 out.extend_from_slice(&ciphertext);
65 Ok(out)
66}
67
68pub fn decrypt_after_download(wire: &[u8], key: &EncryptionKey) -> Result<Vec<u8>, CryptoError> {
73 if wire.len() < NONCE_LEN {
74 return Err(CryptoError::Truncated);
75 }
76 let (nonce_bytes, ciphertext) = wire.split_at(NONCE_LEN);
77 let nonce = XNonce::from_slice(nonce_bytes);
78
79 let cipher = XChaCha20Poly1305::new(key.into());
80 cipher
81 .decrypt(nonce, ciphertext)
82 .map_err(|_| CryptoError::AuthenticationFailed)
83}
84
85pub fn sha256_hex(bytes: &[u8]) -> String {
90 use sha2::{Digest, Sha256};
91 let digest = Sha256::digest(bytes);
92 hex::encode(digest)
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98
99 fn key() -> EncryptionKey {
100 [0x42; 32]
101 }
102
103 #[test]
104 fn round_trip_recovers_plaintext() {
105 let pt = b"hello world".to_vec();
106 let ct = encrypt_for_upload(&pt, &key()).unwrap();
107 let recovered = decrypt_after_download(&ct, &key()).unwrap();
108 assert_eq!(recovered, pt);
109 }
110
111 #[test]
112 fn empty_blob_round_trips() {
113 let pt: Vec<u8> = vec![];
114 let ct = encrypt_for_upload(&pt, &key()).unwrap();
115 let recovered = decrypt_after_download(&ct, &key()).unwrap();
116 assert_eq!(recovered, pt);
117 }
118
119 #[test]
120 fn wrong_key_fails_authentication() {
121 let pt = b"secret".to_vec();
122 let ct = encrypt_for_upload(&pt, &key()).unwrap();
123 let mut wrong = key();
124 wrong[0] ^= 0xff;
125 let err = decrypt_after_download(&ct, &wrong).unwrap_err();
126 assert!(matches!(err, CryptoError::AuthenticationFailed));
127 }
128
129 #[test]
130 fn tampered_ciphertext_fails_authentication() {
131 let pt = b"secret".to_vec();
132 let mut ct = encrypt_for_upload(&pt, &key()).unwrap();
133 let last = ct.len() - 1;
135 ct[last] ^= 0x01;
136 let err = decrypt_after_download(&ct, &key()).unwrap_err();
137 assert!(matches!(err, CryptoError::AuthenticationFailed));
138 }
139
140 #[test]
141 fn truncated_wire_format_is_rejected_distinctly() {
142 let too_short = vec![0u8; NONCE_LEN - 1];
143 let err = decrypt_after_download(&too_short, &key()).unwrap_err();
144 assert!(matches!(err, CryptoError::Truncated));
145 }
146
147 #[test]
148 fn encryption_is_non_deterministic() {
149 let pt = b"reproducibility-leak".to_vec();
150 let a = encrypt_for_upload(&pt, &key()).unwrap();
151 let b = encrypt_for_upload(&pt, &key()).unwrap();
152 assert_ne!(a, b, "two encryptions of the same plaintext must differ");
153 }
154
155 #[test]
156 fn sha256_hex_is_64_chars() {
157 let h = sha256_hex(b"abc");
158 assert_eq!(h.len(), 64);
159 assert_eq!(
160 h,
161 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
162 );
163 }
164}