Skip to main content

paygress/
blossom_crypto.rs

1// Client-side encryption for Blossom-stored blobs (Unit 6 of the
2// 12-month plan,
3// docs/plans/2026-04-26-001-feat-paygress-12mo-vision-plan.md).
4//
5// Blossom servers are content-addressed by SHA-256 of the *upload*
6// bytes. When a workload's checkpoint sits on a third-party Blossom
7// server, we don't trust the operator. We encrypt the blob
8// **before** computing the hash so the server (and anyone who
9// downloads by hash) sees only ciphertext.
10//
11// Algorithm: XChaCha20-Poly1305. 32-byte key, 24-byte nonce.
12// Nonces are randomly generated per-encryption (non-deterministic
13// — the proptest in tests/blossom.rs pins this) and prepended to
14// the ciphertext on the wire so `decrypt` can recover them
15// without out-of-band coordination.
16//
17// Wire format on the Blossom server: `nonce || aead-ciphertext`.
18// AEAD authentication tag is appended by the chacha20poly1305 crate
19// itself, so a wrong key fails AEAD verification rather than
20// silently returning garbage.
21
22use chacha20poly1305::aead::{Aead, KeyInit};
23use chacha20poly1305::{XChaCha20Poly1305, XNonce};
24
25/// 32-byte symmetric key for XChaCha20-Poly1305. Per-blob (or
26/// per-lease for checkpoint chains).
27pub type EncryptionKey = [u8; 32];
28
29/// Errors from the encryption layer. Distinct from anyhow so
30/// callers can map AEAD failures (wrong key, tampered ciphertext)
31/// to specific user-facing messages.
32#[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
44/// Encrypt `plaintext` with `key`. The output is `nonce || ciphertext`,
45/// where `ciphertext` includes the AEAD authentication tag.
46///
47/// Each call generates a fresh random nonce, so encrypting the same
48/// plaintext twice produces different bytes — observers cannot
49/// detect that two checkpoints carry identical state.
50pub 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
68/// Decrypt the wire format produced by [`encrypt_for_upload`]. AEAD
69/// failures (wrong key, tampered bytes, truncated input) surface as
70/// `AuthenticationFailed` so callers don't have to reason about
71/// chacha20poly1305 internals.
72pub 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
85/// Compute the SHA-256 hash of the wire-format ciphertext. Blossom
86/// servers index by this value, so callers must hash the
87/// post-encryption bytes (not the plaintext) when constructing
88/// auth events or `/<hash>` URLs.
89pub 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        // Flip a bit in the AEAD payload (after the nonce).
134        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}