Skip to main content

s4_server/
sse.rs

1//! Server-side encryption (SSE-S4) — AES-256-GCM (v0.4 #21).
2//!
3//! Wraps the post-compression S3 object body with authenticated
4//! encryption. Compress-then-encrypt is the right order: encryption
5//! produces high-entropy bytes that don't compress, so encrypting last
6//! preserves the codec's ratio.
7//!
8//! ## Wire format (S4E1)
9//!
10//! ```text
11//! [magic: "S4E1" 4B]
12//! [algo:  u8]            # 1 = AES-256-GCM (v0.4 only supports this)
13//! [reserved: 3B]         # 0x00 0x00 0x00
14//! [nonce: 12B]           # random per-object
15//! [tag:   16B]           # AES-GCM authentication tag
16//! [ciphertext: variable] # encrypted-then-authenticated body
17//! ```
18//!
19//! Total overhead: 36 bytes per object.
20//!
21//! Since the body S4 wraps is already S4F2-framed, the on-the-wire
22//! object stored in S3 looks like:
23//!
24//! ```text
25//! [S4E1 header 36B][AES-GCM(S4F2 body)]
26//! ```
27//!
28//! ## v0.4 scope cuts
29//!
30//! - **Server-managed key only**: a single 32-byte key loaded from a
31//!   local file via `--sse-s4-key <path>`. KMS / vault integration is a
32//!   follow-up issue.
33//! - **No SSE-C** (customer-provided keys via `x-amz-server-side-
34//!   encryption-customer-key` header) yet — same follow-up issue.
35//! - **One key, no rotation** — no key-id field in the header. v0.5 will
36//!   bump the wire format to S4E2 with a key-id slot.
37//!
38//! Operators who need any of those today should layer S4 behind an
39//! IAM-aware proxy that handles encryption at its end.
40
41use std::path::Path;
42use std::sync::Arc;
43
44use aes_gcm::aead::{Aead, KeyInit, Payload};
45use aes_gcm::{Aes256Gcm, Key, Nonce};
46use bytes::Bytes;
47use rand::RngCore;
48use thiserror::Error;
49
50pub const SSE_MAGIC: &[u8; 4] = b"S4E1";
51pub const SSE_HEADER_BYTES: usize = 4 + 1 + 3 + 12 + 16; // magic + algo + reserved + nonce + tag = 36
52pub const ALGO_AES_256_GCM: u8 = 1;
53const NONCE_LEN: usize = 12;
54const TAG_LEN: usize = 16;
55const KEY_LEN: usize = 32;
56
57#[derive(Debug, Error)]
58pub enum SseError {
59    #[error("SSE key file {path:?}: {source}")]
60    KeyFileIo {
61        path: std::path::PathBuf,
62        source: std::io::Error,
63    },
64    #[error(
65        "SSE key file must be exactly 32 raw bytes (or 64-char hex / 44-char base64); got {got} bytes after parse"
66    )]
67    BadKeyLength { got: usize },
68    #[error("SSE-encrypted body too short ({got} bytes; need at least {SSE_HEADER_BYTES})")]
69    TooShort { got: usize },
70    #[error("SSE bad magic: expected S4E1, got {got:?}")]
71    BadMagic { got: [u8; 4] },
72    #[error("SSE unsupported algo tag: {tag} (this build only knows AES-256-GCM = 1)")]
73    UnsupportedAlgo { tag: u8 },
74    #[error("SSE decryption / authentication failed (key mismatch or ciphertext tampered with)")]
75    DecryptFailed,
76}
77
78/// 32-byte symmetric key. Held inside an `Arc` so cloning the key-ring
79/// across handler tasks is cheap.
80#[derive(Clone)]
81pub struct SseKey(Arc<[u8; KEY_LEN]>);
82
83impl SseKey {
84    /// Load a 32-byte key from disk. Accepts three on-disk encodings:
85    /// raw 32 bytes, 64-char ASCII hex, or 44-char ASCII base64 (with or
86    /// without padding). Whitespace is trimmed.
87    pub fn from_path(path: &Path) -> Result<Self, SseError> {
88        let raw = std::fs::read(path).map_err(|source| SseError::KeyFileIo {
89            path: path.to_path_buf(),
90            source,
91        })?;
92        Self::from_bytes(&raw)
93    }
94
95    pub fn from_bytes(bytes: &[u8]) -> Result<Self, SseError> {
96        // Try raw first.
97        if bytes.len() == KEY_LEN {
98            let mut k = [0u8; KEY_LEN];
99            k.copy_from_slice(bytes);
100            return Ok(Self(Arc::new(k)));
101        }
102        // Trim whitespace and try hex / base64.
103        let s = std::str::from_utf8(bytes).unwrap_or("").trim();
104        if s.len() == KEY_LEN * 2 && s.chars().all(|c| c.is_ascii_hexdigit()) {
105            let mut k = [0u8; KEY_LEN];
106            for (i, k_byte) in k.iter_mut().enumerate() {
107                *k_byte = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16)
108                    .map_err(|_| SseError::BadKeyLength { got: bytes.len() })?;
109            }
110            return Ok(Self(Arc::new(k)));
111        }
112        if let Ok(decoded) =
113            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, s.as_bytes())
114            && decoded.len() == KEY_LEN
115        {
116            let mut k = [0u8; KEY_LEN];
117            k.copy_from_slice(&decoded);
118            return Ok(Self(Arc::new(k)));
119        }
120        Err(SseError::BadKeyLength { got: bytes.len() })
121    }
122
123    fn as_aes_key(&self) -> &Key<Aes256Gcm> {
124        Key::<Aes256Gcm>::from_slice(self.0.as_ref())
125    }
126}
127
128impl std::fmt::Debug for SseKey {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        f.debug_struct("SseKey")
131            .field("len", &KEY_LEN)
132            .field("key", &"<redacted>")
133            .finish()
134    }
135}
136
137/// Encrypt `plaintext` with the given key, producing the on-the-wire
138/// S4E1-framed output: `[magic 4][algo 1][reserved 3][nonce 12][tag 16][ciphertext]`.
139pub fn encrypt(key: &SseKey, plaintext: &[u8]) -> Bytes {
140    let cipher = Aes256Gcm::new(key.as_aes_key());
141    let mut nonce_bytes = [0u8; NONCE_LEN];
142    rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
143    let nonce = Nonce::from_slice(&nonce_bytes);
144    // Use the magic + algo bytes as additional authenticated data so
145    // tampering with the header bumps the algo or magic still fails the
146    // tag check.
147    let mut aad = [0u8; 8];
148    aad[..4].copy_from_slice(SSE_MAGIC);
149    aad[4] = ALGO_AES_256_GCM;
150    let ct_with_tag = cipher
151        .encrypt(
152            nonce,
153            Payload {
154                msg: plaintext,
155                aad: &aad,
156            },
157        )
158        .expect("aes-gcm encrypt cannot fail with a 32-byte key");
159    // ct_with_tag = ciphertext || tag (16 last bytes)
160    debug_assert!(ct_with_tag.len() >= TAG_LEN);
161    let split = ct_with_tag.len() - TAG_LEN;
162    let (ct, tag) = ct_with_tag.split_at(split);
163
164    let mut out = Vec::with_capacity(SSE_HEADER_BYTES + ct.len());
165    out.extend_from_slice(SSE_MAGIC);
166    out.push(ALGO_AES_256_GCM);
167    out.extend_from_slice(&[0u8; 3]); // reserved
168    out.extend_from_slice(&nonce_bytes);
169    out.extend_from_slice(tag);
170    out.extend_from_slice(ct);
171    Bytes::from(out)
172}
173
174/// Decrypt an S4E1-framed body. Returns the plaintext (= S4F2-framed
175/// codec body). Fails if the magic, algo tag, or AES-GCM auth tag don't
176/// validate — meaning either the wrong key was supplied or the
177/// ciphertext was tampered with.
178pub fn decrypt(key: &SseKey, body: &[u8]) -> Result<Bytes, SseError> {
179    if body.len() < SSE_HEADER_BYTES {
180        return Err(SseError::TooShort { got: body.len() });
181    }
182    let mut magic = [0u8; 4];
183    magic.copy_from_slice(&body[..4]);
184    if &magic != SSE_MAGIC {
185        return Err(SseError::BadMagic { got: magic });
186    }
187    let algo = body[4];
188    if algo != ALGO_AES_256_GCM {
189        return Err(SseError::UnsupportedAlgo { tag: algo });
190    }
191    // body[5..8] reserved
192    let mut nonce_bytes = [0u8; NONCE_LEN];
193    nonce_bytes.copy_from_slice(&body[8..8 + NONCE_LEN]);
194    let mut tag_bytes = [0u8; TAG_LEN];
195    tag_bytes.copy_from_slice(&body[8 + NONCE_LEN..SSE_HEADER_BYTES]);
196    let ct = &body[SSE_HEADER_BYTES..];
197
198    let cipher = Aes256Gcm::new(key.as_aes_key());
199    let nonce = Nonce::from_slice(&nonce_bytes);
200    let mut aad = [0u8; 8];
201    aad[..4].copy_from_slice(SSE_MAGIC);
202    aad[4] = ALGO_AES_256_GCM;
203    let mut ct_with_tag = Vec::with_capacity(ct.len() + TAG_LEN);
204    ct_with_tag.extend_from_slice(ct);
205    ct_with_tag.extend_from_slice(&tag_bytes);
206    let plain = cipher
207        .decrypt(
208            nonce,
209            Payload {
210                msg: &ct_with_tag,
211                aad: &aad,
212            },
213        )
214        .map_err(|_| SseError::DecryptFailed)?;
215    Ok(Bytes::from(plain))
216}
217
218/// Detect whether `body` is S4E1-encrypted by sniffing the magic bytes.
219/// Used by the GET path to decide whether to run decryption before
220/// frame parsing.
221pub fn looks_encrypted(body: &[u8]) -> bool {
222    body.len() >= SSE_HEADER_BYTES && &body[..4] == SSE_MAGIC
223}
224
225pub type SharedSseKey = Arc<SseKey>;
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    fn key32() -> SseKey {
232        SseKey::from_bytes(&[7u8; 32]).unwrap()
233    }
234
235    #[test]
236    fn roundtrip_basic() {
237        let k = key32();
238        let pt = b"the quick brown fox jumps over the lazy dog";
239        let ct = encrypt(&k, pt);
240        assert!(looks_encrypted(&ct));
241        assert_eq!(&ct[..4], SSE_MAGIC);
242        assert_eq!(ct[4], ALGO_AES_256_GCM);
243        assert_eq!(ct.len(), SSE_HEADER_BYTES + pt.len());
244        let pt2 = decrypt(&k, &ct).unwrap();
245        assert_eq!(pt2.as_ref(), pt);
246    }
247
248    #[test]
249    fn wrong_key_fails() {
250        let k1 = SseKey::from_bytes(&[1u8; 32]).unwrap();
251        let k2 = SseKey::from_bytes(&[2u8; 32]).unwrap();
252        let ct = encrypt(&k1, b"secret");
253        let err = decrypt(&k2, &ct).unwrap_err();
254        assert!(matches!(err, SseError::DecryptFailed));
255    }
256
257    #[test]
258    fn tampered_ciphertext_fails() {
259        let k = key32();
260        let mut ct = encrypt(&k, b"secret message").to_vec();
261        // Flip a bit deep in the ciphertext (past the header)
262        let last = ct.len() - 1;
263        ct[last] ^= 0x01;
264        let err = decrypt(&k, &ct).unwrap_err();
265        assert!(matches!(err, SseError::DecryptFailed));
266    }
267
268    #[test]
269    fn tampered_algo_byte_fails() {
270        let k = key32();
271        let mut ct = encrypt(&k, b"secret").to_vec();
272        ct[4] = 99; // unsupported algo
273        let err = decrypt(&k, &ct).unwrap_err();
274        assert!(matches!(err, SseError::UnsupportedAlgo { tag: 99 }));
275    }
276
277    #[test]
278    fn rejects_short_body() {
279        let k = key32();
280        let err = decrypt(&k, b"short").unwrap_err();
281        assert!(matches!(err, SseError::TooShort { got: 5 }));
282    }
283
284    #[test]
285    fn looks_encrypted_passthrough_returns_false() {
286        // S4F2 frame magic, NOT S4E1
287        assert!(!looks_encrypted(b"S4F2\x01\x00\x00\x00........"));
288        assert!(!looks_encrypted(b""));
289    }
290
291    #[test]
292    fn key_from_hex_string() {
293        let k =
294            SseKey::from_bytes(b"0102030405060708090a0b0c0d0e0f10111213141516171819202122232425")
295                .unwrap_err();
296        // Wrong hex length (62 hex chars, not 64)
297        assert!(matches!(k, SseError::BadKeyLength { .. }));
298        let good = b"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
299        let _ = SseKey::from_bytes(good).expect("64-char hex should parse");
300    }
301
302    #[test]
303    fn encrypt_uses_random_nonce() {
304        // Two encrypts of the same plaintext with the same key produce
305        // different ciphertexts because the nonce is freshly random.
306        let k = key32();
307        let pt = b"deterministic input";
308        let a = encrypt(&k, pt);
309        let b = encrypt(&k, pt);
310        assert_ne!(a, b, "nonce must be random per-call");
311    }
312}