Skip to main content

s4_server/
kms.rs

1//! KMS backend abstraction for SSE-KMS envelope encryption (v0.5 #28).
2//!
3//! Per-object DEK (Data Encryption Key, 256-bit AES) is wrapped by a
4//! KEK (Key Encryption Key) held in a pluggable KMS backend. The
5//! plaintext DEK is used in-memory only — only the wrapped form is
6//! persisted alongside the ciphertext (in the S4E4 frame written by
7//! [`crate::sse::encrypt_with_source`]).
8//!
9//! ## Why envelope encryption?
10//!
11//! - **Per-object key** = blast radius of a key compromise is one
12//!   object, not the whole tenant.
13//! - **KEK never leaves the KMS** = the plaintext bytes of the master
14//!   key are not memory-resident in the gateway. Only DEKs are.
15//! - **Server-side rotation cheap** = rotate the KEK in KMS, re-wrap
16//!   DEKs lazily on next PUT/GET. The ciphertext bodies don't move.
17//!
18//! ## Backends
19//!
20//! - [`LocalKms`] — file-backed KEK store for dev / on-prem / air-gap.
21//!   Default-features. AES-256-GCM wrap with a fresh 12-byte nonce per
22//!   call; the wrapped form is `nonce || ciphertext || tag`.
23//! - [`aws::AwsKms`] — AWS KMS via `aws-sdk-kms`. Behind the
24//!   `aws-kms` cargo feature (off by default to keep the default build
25//!   from pulling the entire aws-sdk-kms tree). Calls `GenerateDataKey`
26//!   for fresh DEKs and `Decrypt` for unwrap.
27//!
28//! ## Async-ness
29//!
30//! Both methods on [`KmsBackend`] are `async` — even the file-backed
31//! `LocalKms` returns a future, because real KMS backends do
32//! network I/O and we want the trait shape to stay compatible. The
33//! `LocalKms` futures resolve immediately.
34
35use std::collections::HashMap;
36use std::path::PathBuf;
37
38use aes_gcm::aead::{Aead, KeyInit, Payload};
39use aes_gcm::{Aes256Gcm, Key, Nonce};
40use async_trait::async_trait;
41use rand::RngCore;
42use zeroize::Zeroizing;
43
44const KEK_LEN: usize = 32;
45const DEK_LEN: usize = 32;
46const WRAP_NONCE_LEN: usize = 12;
47const WRAP_TAG_LEN: usize = 16;
48/// Minimum size of a `WrappedDek::ciphertext` produced by [`LocalKms`]:
49/// 12-byte nonce + at least the 16-byte AES-GCM tag (DEK is 32 bytes,
50/// so the actual minimum is 12 + 32 + 16 = 60, but we check the floor
51/// at 12 + 16 = 28 to give a clearer error than a panic on slice
52/// overflow).
53const LOCAL_WRAP_MIN_LEN: usize = WRAP_NONCE_LEN + WRAP_TAG_LEN;
54
55#[derive(Debug, thiserror::Error)]
56pub enum KmsError {
57    #[error("KMS key id {key_id:?} not found in backend")]
58    KeyNotFound { key_id: String },
59    #[error("KMS KEK file {path:?}: {source}")]
60    KekFileIo {
61        path: PathBuf,
62        source: std::io::Error,
63    },
64    #[error("KMS KEK file {path:?} must be exactly {expected} raw bytes; got {got}")]
65    KekBadLength {
66        path: PathBuf,
67        expected: usize,
68        got: usize,
69    },
70    #[error("KMS KEK directory {path:?}: {source}")]
71    KekDirIo {
72        path: PathBuf,
73        source: std::io::Error,
74    },
75    /// `LocalKms` saw a wrapped-DEK ciphertext shorter than the
76    /// minimum (nonce + tag). Surface as a distinct error so audit
77    /// logs can tell "metadata corruption / truncation" apart from
78    /// "wrong key" / "tampered with".
79    #[error("KMS wrapped DEK too short ({got} bytes; need at least {min})")]
80    WrappedDekTooShort { got: usize, min: usize },
81    /// AES-GCM authentication failure on unwrap. Either the wrapped
82    /// DEK was tampered with, or it was wrapped under a different
83    /// KEK than the one we're holding for `key_id`.
84    #[error("KMS unwrap failed (wrapped DEK auth tag mismatch for key_id {key_id:?})")]
85    UnwrapFailed { key_id: String },
86    /// Backend-specific transport error (network, AWS SDK, etc).
87    /// `source` is type-erased so the trait stays object-safe.
88    #[error("KMS backend unavailable: {message}")]
89    BackendUnavailable { message: String },
90}
91
92/// Wrapped DEK as stored in the S4E4 frame.
93///
94/// `key_id` identifies which KEK in the backend was used to wrap
95/// `ciphertext`. Both fields are AAD-authenticated by the outer
96/// AES-GCM tag in the S4E4 frame, so an attacker can't substitute a
97/// different `key_id` to make the gateway try a different KEK.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct WrappedDek {
100    /// KEK identifier, caller-meaningful. For [`LocalKms`] this is
101    /// the basename of the `.kek` file (without extension); for
102    /// [`aws::AwsKms`] it is the KMS key ARN or alias.
103    pub key_id: String,
104    /// Encrypted DEK bytes. Format is backend-defined — for
105    /// `LocalKms` it is `nonce(12) || ciphertext(32) || tag(16)`;
106    /// for AWS KMS it is the opaque blob returned by `GenerateDataKey`.
107    pub ciphertext: Vec<u8>,
108}
109
110#[async_trait]
111pub trait KmsBackend: Send + Sync + std::fmt::Debug {
112    /// Generate a fresh 32-byte DEK and return both the plaintext
113    /// (used immediately for AES-GCM encryption of the object body)
114    /// and the wrapped form (persisted in the S4E4 frame).
115    ///
116    /// `key_id` selects which KEK to wrap under. For `LocalKms` an
117    /// unknown id is [`KmsError::KeyNotFound`]; for AWS KMS an unknown
118    /// ARN surfaces as [`KmsError::BackendUnavailable`] (the AWS SDK
119    /// returns NotFound but we don't want callers leaking ARN existence
120    /// to clients).
121    ///
122    /// v0.8.1 #58: returns the plaintext DEK as `Zeroizing<Vec<u8>>` so
123    /// the backing bytes are wiped on `Drop` (defense in depth against
124    /// memory dumps / swap-out / core dumps). Callers can keep using
125    /// `&dek`, `dek.len()`, etc. unchanged via `Deref<Target=Vec<u8>>`.
126    /// `WrappedDek::ciphertext` is intentionally NOT zeroized — it's
127    /// already encrypted under the KEK and persisted at rest.
128    async fn generate_dek(
129        &self,
130        key_id: &str,
131    ) -> Result<(Zeroizing<Vec<u8>>, WrappedDek), KmsError>;
132
133    /// Unwrap a stored DEK ciphertext back to plaintext for the
134    /// decrypt path. v0.8.1 #58: returns `Zeroizing<Vec<u8>>` so the
135    /// plaintext is wiped on `Drop`; callers in this crate also copy
136    /// it into a stack `[u8; 32]` (also `Zeroizing`-wrapped at the
137    /// `service.rs` call sites) for the duration of one GET.
138    async fn decrypt_dek(&self, wrapped: &WrappedDek) -> Result<Zeroizing<Vec<u8>>, KmsError>;
139}
140
141/// File-based KEK store for dev / on-prem deployments.
142///
143/// ## Layout
144///
145/// ```text
146/// <dir>/
147///   alpha.kek         # 32 raw bytes — KEK for key_id "alpha"
148///   beta.kek          # 32 raw bytes — KEK for key_id "beta"
149/// ```
150///
151/// Files are loaded eagerly at [`LocalKms::open`] time; subsequent
152/// adds/removals require a restart. KEK files MUST be exactly 32
153/// bytes (other formats — hex / base64 — are intentionally not
154/// accepted here, unlike [`crate::sse::SseKey`], because operators
155/// generating KEKs for KMS use should produce raw randomness from
156/// `/dev/urandom` rather than human-edited files).
157///
158/// ## Wrap algorithm
159///
160/// `LocalKms` wraps DEKs with AES-256-GCM using the KEK as the cipher
161/// key. The wrapped form is `nonce(12) || ciphertext(32) || tag(16)`
162/// = 60 bytes for a 32-byte DEK. The nonce is fresh per wrap, drawn
163/// from `OsRng`; the AAD is the UTF-8 `key_id` so a wrap under one id
164/// can't be replayed under another.
165pub struct LocalKms {
166    dir: PathBuf,
167    keks: HashMap<String, [u8; KEK_LEN]>,
168}
169
170impl std::fmt::Debug for LocalKms {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        f.debug_struct("LocalKms")
173            .field("dir", &self.dir)
174            .field("key_count", &self.keks.len())
175            .field("key_ids", &self.keks.keys().collect::<Vec<_>>())
176            .finish()
177    }
178}
179
180impl LocalKms {
181    /// Open a KEK directory. Reads every `*.kek` file; each must be
182    /// exactly 32 raw bytes. The basename (sans `.kek`) becomes the
183    /// `key_id` used in [`KmsBackend::generate_dek`] / [`WrappedDek`].
184    ///
185    /// An empty directory is a valid (but useless) state — callers
186    /// that haven't loaded any KEKs will still see all `generate_dek`
187    /// calls return [`KmsError::KeyNotFound`].
188    pub fn open(dir: PathBuf) -> Result<Self, KmsError> {
189        let read_dir = std::fs::read_dir(&dir).map_err(|source| KmsError::KekDirIo {
190            path: dir.clone(),
191            source,
192        })?;
193        let mut keks = HashMap::new();
194        for entry in read_dir {
195            let entry = entry.map_err(|source| KmsError::KekDirIo {
196                path: dir.clone(),
197                source,
198            })?;
199            let path = entry.path();
200            if path.extension().and_then(|s| s.to_str()) != Some("kek") {
201                continue;
202            }
203            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
204                continue;
205            };
206            let key_id = stem.to_string();
207            let bytes = std::fs::read(&path).map_err(|source| KmsError::KekFileIo {
208                path: path.clone(),
209                source,
210            })?;
211            if bytes.len() != KEK_LEN {
212                return Err(KmsError::KekBadLength {
213                    path: path.clone(),
214                    expected: KEK_LEN,
215                    got: bytes.len(),
216                });
217            }
218            let mut k = [0u8; KEK_LEN];
219            k.copy_from_slice(&bytes);
220            keks.insert(key_id, k);
221        }
222        Ok(Self { dir, keks })
223    }
224
225    /// Construct a `LocalKms` directly from in-memory KEKs. Useful
226    /// for tests and for callers that load KEKs out of band (e.g.
227    /// from a sealed config blob). Production deployments should
228    /// prefer [`LocalKms::open`].
229    pub fn from_keks(dir: PathBuf, keks: HashMap<String, [u8; KEK_LEN]>) -> Self {
230        Self { dir, keks }
231    }
232
233    /// Sorted list of key ids present in this backend. Used by the
234    /// CLI `--list-kms-keys` flag (orchestrator wires that) and by
235    /// readiness probes that want to assert a specific key is loaded.
236    pub fn key_ids(&self) -> Vec<String> {
237        let mut ids: Vec<String> = self.keks.keys().cloned().collect();
238        ids.sort();
239        ids
240    }
241
242    fn kek(&self, key_id: &str) -> Result<&[u8; KEK_LEN], KmsError> {
243        self.keks.get(key_id).ok_or_else(|| KmsError::KeyNotFound {
244            key_id: key_id.to_string(),
245        })
246    }
247}
248
249#[async_trait]
250impl KmsBackend for LocalKms {
251    async fn generate_dek(
252        &self,
253        key_id: &str,
254    ) -> Result<(Zeroizing<Vec<u8>>, WrappedDek), KmsError> {
255        let kek = self.kek(key_id)?;
256        // v0.8.1 #58: wrap the DEK plaintext in `Zeroizing` so the
257        // underlying `Vec<u8>` heap allocation is wiped on `Drop`.
258        // The returned `Zeroizing<Vec<u8>>` derefs to `Vec<u8>` so
259        // callers' `&dek` / `dek.len()` keep working unchanged.
260        let mut dek: Zeroizing<Vec<u8>> = Zeroizing::new(vec![0u8; DEK_LEN]);
261        rand::rngs::OsRng.fill_bytes(&mut dek);
262
263        let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(kek));
264        let mut nonce_bytes = [0u8; WRAP_NONCE_LEN];
265        rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
266        let nonce = Nonce::from_slice(&nonce_bytes);
267        let aad = key_id.as_bytes();
268        let ct_with_tag = cipher
269            .encrypt(nonce, Payload { msg: &dek, aad })
270            .expect("aes-gcm encrypt cannot fail with a 32-byte key");
271
272        // Layout: nonce || ct_with_tag (the latter already contains
273        // the 16-byte trailing tag from the aes-gcm crate). The wrapped
274        // ciphertext is intentionally NOT `Zeroizing` — it's an
275        // encrypted blob that lives at rest in the S4E4 frame, so
276        // wiping it on drop would just be busywork.
277        let mut wrapped = Vec::with_capacity(WRAP_NONCE_LEN + ct_with_tag.len());
278        wrapped.extend_from_slice(&nonce_bytes);
279        wrapped.extend_from_slice(&ct_with_tag);
280
281        Ok((
282            dek,
283            WrappedDek {
284                key_id: key_id.to_string(),
285                ciphertext: wrapped,
286            },
287        ))
288    }
289
290    async fn decrypt_dek(&self, wrapped: &WrappedDek) -> Result<Zeroizing<Vec<u8>>, KmsError> {
291        let kek = self.kek(&wrapped.key_id)?;
292        if wrapped.ciphertext.len() < LOCAL_WRAP_MIN_LEN {
293            return Err(KmsError::WrappedDekTooShort {
294                got: wrapped.ciphertext.len(),
295                min: LOCAL_WRAP_MIN_LEN,
296            });
297        }
298        let (nonce_bytes, ct_with_tag) = wrapped.ciphertext.split_at(WRAP_NONCE_LEN);
299        let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(kek));
300        let nonce = Nonce::from_slice(nonce_bytes);
301        let aad = wrapped.key_id.as_bytes();
302        let dek = cipher
303            .decrypt(
304                nonce,
305                Payload {
306                    msg: ct_with_tag,
307                    aad,
308                },
309            )
310            .map_err(|_| KmsError::UnwrapFailed {
311                key_id: wrapped.key_id.clone(),
312            })?;
313        // v0.8.1 #58: rewrap the freshly-decrypted plaintext into
314        // `Zeroizing` immediately so any panic between here and the
315        // caller's stack `[u8; 32]` copy still wipes the heap bytes.
316        Ok(Zeroizing::new(dek))
317    }
318}
319
320// ----------------------------------------------------------------------------
321// AWS KMS backend (feature-gated)
322// ----------------------------------------------------------------------------
323
324#[cfg(feature = "aws-kms")]
325pub mod aws {
326    //! AWS KMS-backed [`KmsBackend`]. Off by default — enable with
327    //! `--features aws-kms`. The backend forwards `generate_dek` to
328    //! `GenerateDataKey` (with `KeySpec=AES_256`) and `decrypt_dek`
329    //! to `Decrypt`; the wrapped DEK ciphertext is exactly the opaque
330    //! blob AWS returns, so we don't double-wrap.
331    use super::{KmsBackend, KmsError, WrappedDek};
332    use async_trait::async_trait;
333    use zeroize::Zeroizing;
334
335    /// AWS KMS-backed KEK store. The `key_id` passed to
336    /// [`KmsBackend::generate_dek`] is forwarded as `KeyId` to AWS —
337    /// callers can use a key ARN, alias ARN, or alias name. For
338    /// [`KmsBackend::decrypt_dek`] AWS re-derives the KEK from
339    /// `CiphertextBlob` so the `key_id` field on `WrappedDek` is
340    /// effectively a label / audit signal.
341    pub struct AwsKms {
342        client: aws_sdk_kms::Client,
343    }
344
345    impl std::fmt::Debug for AwsKms {
346        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
347            f.debug_struct("AwsKms").finish()
348        }
349    }
350
351    impl AwsKms {
352        /// Construct an [`AwsKms`] from a pre-built SDK client. Allows
353        /// callers to share an SDK config (region, retry, endpoint
354        /// override for LocalStack) with the rest of the gateway.
355        pub fn new(client: aws_sdk_kms::Client) -> Self {
356            Self { client }
357        }
358
359        /// Convenience: build a client from the ambient
360        /// `aws_config::load_defaults` (env, profile, IMDS, etc).
361        pub async fn from_default_env() -> Self {
362            let cfg = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
363            let client = aws_sdk_kms::Client::new(&cfg);
364            Self { client }
365        }
366    }
367
368    #[async_trait]
369    impl KmsBackend for AwsKms {
370        async fn generate_dek(
371            &self,
372            key_id: &str,
373        ) -> Result<(Zeroizing<Vec<u8>>, WrappedDek), KmsError> {
374            let resp = self
375                .client
376                .generate_data_key()
377                .key_id(key_id)
378                .key_spec(aws_sdk_kms::types::DataKeySpec::Aes256)
379                .send()
380                .await
381                .map_err(|e| KmsError::BackendUnavailable {
382                    message: format!("GenerateDataKey({key_id}): {e}"),
383                })?;
384            let dek_vec = resp
385                .plaintext
386                .ok_or_else(|| KmsError::BackendUnavailable {
387                    message: format!("GenerateDataKey({key_id}): missing Plaintext in response"),
388                })?
389                .into_inner();
390            // v0.8.1 #58: wrap immediately on receipt from AWS so any
391            // early-return (or panic) between here and the caller's
392            // copy_from_slice still wipes the DEK on drop.
393            let dek = Zeroizing::new(dek_vec);
394            let ciphertext = resp
395                .ciphertext_blob
396                .ok_or_else(|| KmsError::BackendUnavailable {
397                    message: format!(
398                        "GenerateDataKey({key_id}): missing CiphertextBlob in response"
399                    ),
400                })?
401                .into_inner();
402            // Use the response's KeyId (canonical ARN) when present so
403            // we record the resolved key, not the alias the caller
404            // passed. Falls back to the original on the unlikely
405            // chance AWS doesn't echo it.
406            let stored_id = resp.key_id.unwrap_or_else(|| key_id.to_string());
407            Ok((
408                dek,
409                WrappedDek {
410                    key_id: stored_id,
411                    ciphertext,
412                },
413            ))
414        }
415
416        async fn decrypt_dek(&self, wrapped: &WrappedDek) -> Result<Zeroizing<Vec<u8>>, KmsError> {
417            let resp = self
418                .client
419                .decrypt()
420                .ciphertext_blob(aws_sdk_kms::primitives::Blob::new(
421                    wrapped.ciphertext.clone(),
422                ))
423                .key_id(&wrapped.key_id)
424                .send()
425                .await
426                .map_err(|e| KmsError::BackendUnavailable {
427                    message: format!("Decrypt({}): {e}", wrapped.key_id),
428                })?;
429            let dek_vec = resp
430                .plaintext
431                .ok_or_else(|| KmsError::BackendUnavailable {
432                    message: format!("Decrypt({}): missing Plaintext in response", wrapped.key_id),
433                })?
434                .into_inner();
435            // v0.8.1 #58: same Zeroizing-on-receipt pattern as generate_dek.
436            Ok(Zeroizing::new(dek_vec))
437        }
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use std::collections::HashMap;
445    use std::path::Path;
446    use tempfile::TempDir;
447
448    fn write_kek(dir: &Path, name: &str, bytes: &[u8]) {
449        std::fs::write(dir.join(format!("{name}.kek")), bytes).unwrap();
450    }
451
452    #[tokio::test]
453    async fn open_empty_dir_is_ok() {
454        let tmp = TempDir::new().unwrap();
455        let kms = LocalKms::open(tmp.path().to_path_buf()).unwrap();
456        assert!(kms.key_ids().is_empty());
457        // generate_dek with no keys → KeyNotFound.
458        let err = kms.generate_dek("missing").await.unwrap_err();
459        assert!(
460            matches!(err, KmsError::KeyNotFound { ref key_id } if key_id == "missing"),
461            "got {err:?}"
462        );
463    }
464
465    #[tokio::test]
466    async fn open_loads_kek_files_and_skips_others() {
467        let tmp = TempDir::new().unwrap();
468        write_kek(tmp.path(), "alpha", &[1u8; KEK_LEN]);
469        write_kek(tmp.path(), "beta", &[2u8; KEK_LEN]);
470        // Non-`.kek` files must be ignored (sidecar metadata, README,
471        // editor swap files, etc).
472        std::fs::write(tmp.path().join("README"), b"hello").unwrap();
473        std::fs::write(tmp.path().join("alpha.kek.bak"), [9u8; 99]).unwrap();
474        let kms = LocalKms::open(tmp.path().to_path_buf()).unwrap();
475        let ids = kms.key_ids();
476        assert_eq!(ids, vec!["alpha".to_string(), "beta".to_string()]);
477    }
478
479    #[tokio::test]
480    async fn open_rejects_truncated_kek_file() {
481        let tmp = TempDir::new().unwrap();
482        // 31 bytes — one short of a valid KEK.
483        write_kek(tmp.path(), "short", &[7u8; KEK_LEN - 1]);
484        let err = LocalKms::open(tmp.path().to_path_buf()).unwrap_err();
485        assert!(
486            matches!(
487                err,
488                KmsError::KekBadLength { expected, got, .. } if expected == KEK_LEN && got == KEK_LEN - 1
489            ),
490            "got {err:?}"
491        );
492    }
493
494    #[tokio::test]
495    async fn generate_then_decrypt_roundtrip() {
496        let tmp = TempDir::new().unwrap();
497        write_kek(tmp.path(), "main", &[42u8; KEK_LEN]);
498        let kms = LocalKms::open(tmp.path().to_path_buf()).unwrap();
499        let (dek, wrapped) = kms.generate_dek("main").await.unwrap();
500        assert_eq!(dek.len(), DEK_LEN);
501        assert_eq!(wrapped.key_id, "main");
502        // Wrapped form: 12-byte nonce + 32-byte ciphertext + 16-byte
503        // tag = 60 bytes.
504        assert_eq!(
505            wrapped.ciphertext.len(),
506            WRAP_NONCE_LEN + DEK_LEN + WRAP_TAG_LEN
507        );
508
509        let unwrapped = kms.decrypt_dek(&wrapped).await.unwrap();
510        assert_eq!(unwrapped, dek);
511    }
512
513    #[tokio::test]
514    async fn generate_uses_random_dek_and_nonce() {
515        let tmp = TempDir::new().unwrap();
516        write_kek(tmp.path(), "k", &[5u8; KEK_LEN]);
517        let kms = LocalKms::open(tmp.path().to_path_buf()).unwrap();
518        let (dek1, w1) = kms.generate_dek("k").await.unwrap();
519        let (dek2, w2) = kms.generate_dek("k").await.unwrap();
520        assert_ne!(dek1, dek2, "DEK must be random per call");
521        assert_ne!(
522            w1.ciphertext, w2.ciphertext,
523            "wrap nonce must be random per call"
524        );
525    }
526
527    #[tokio::test]
528    async fn decrypt_unknown_key_id_errors() {
529        let tmp = TempDir::new().unwrap();
530        write_kek(tmp.path(), "real", &[1u8; KEK_LEN]);
531        let kms = LocalKms::open(tmp.path().to_path_buf()).unwrap();
532        let bogus = WrappedDek {
533            key_id: "phantom".to_string(),
534            ciphertext: vec![0u8; LOCAL_WRAP_MIN_LEN + DEK_LEN],
535        };
536        let err = kms.decrypt_dek(&bogus).await.unwrap_err();
537        assert!(
538            matches!(err, KmsError::KeyNotFound { ref key_id } if key_id == "phantom"),
539            "got {err:?}"
540        );
541    }
542
543    #[tokio::test]
544    async fn decrypt_tampered_ciphertext_fails_unwrap() {
545        let tmp = TempDir::new().unwrap();
546        write_kek(tmp.path(), "k", &[3u8; KEK_LEN]);
547        let kms = LocalKms::open(tmp.path().to_path_buf()).unwrap();
548        let (_dek, mut wrapped) = kms.generate_dek("k").await.unwrap();
549        // Flip a byte in the encrypted DEK area (not the nonce, not
550        // the tag — but AES-GCM auths the whole thing, so any flip
551        // anywhere fails).
552        let mid = wrapped.ciphertext.len() / 2;
553        wrapped.ciphertext[mid] ^= 0xFF;
554        let err = kms.decrypt_dek(&wrapped).await.unwrap_err();
555        assert!(
556            matches!(err, KmsError::UnwrapFailed { ref key_id } if key_id == "k"),
557            "got {err:?}"
558        );
559    }
560
561    #[tokio::test]
562    async fn decrypt_short_ciphertext_errors() {
563        let tmp = TempDir::new().unwrap();
564        write_kek(tmp.path(), "k", &[8u8; KEK_LEN]);
565        let kms = LocalKms::open(tmp.path().to_path_buf()).unwrap();
566        let bogus = WrappedDek {
567            key_id: "k".to_string(),
568            ciphertext: vec![0u8; 5], // too small for nonce + tag
569        };
570        let err = kms.decrypt_dek(&bogus).await.unwrap_err();
571        assert!(
572            matches!(err, KmsError::WrappedDekTooShort { got: 5, .. }),
573            "got {err:?}"
574        );
575    }
576
577    #[tokio::test]
578    async fn decrypt_wrong_key_id_aad_fails_unwrap() {
579        // Wrap under "alpha", then forge a WrappedDek that claims
580        // "beta" with the same ciphertext bytes. AAD includes key_id
581        // so AES-GCM auth must fail under "beta"'s KEK + "beta" AAD,
582        // even if the bytes are the wrap of a real DEK.
583        let tmp = TempDir::new().unwrap();
584        write_kek(tmp.path(), "alpha", &[1u8; KEK_LEN]);
585        write_kek(tmp.path(), "beta", &[2u8; KEK_LEN]);
586        let kms = LocalKms::open(tmp.path().to_path_buf()).unwrap();
587        let (_dek, wrapped) = kms.generate_dek("alpha").await.unwrap();
588        let forged = WrappedDek {
589            key_id: "beta".to_string(),
590            ciphertext: wrapped.ciphertext.clone(),
591        };
592        let err = kms.decrypt_dek(&forged).await.unwrap_err();
593        assert!(
594            matches!(err, KmsError::UnwrapFailed { ref key_id } if key_id == "beta"),
595            "got {err:?}"
596        );
597    }
598
599    #[tokio::test]
600    async fn from_keks_constructor_works() {
601        let mut keks = HashMap::new();
602        keks.insert("inline".to_string(), [9u8; KEK_LEN]);
603        let kms = LocalKms::from_keks(PathBuf::from("/tmp/none"), keks);
604        let (_dek, wrapped) = kms.generate_dek("inline").await.unwrap();
605        assert_eq!(wrapped.key_id, "inline");
606        let _back = kms.decrypt_dek(&wrapped).await.unwrap();
607    }
608
609    // -----------------------------------------------------------------
610    // AwsKms tests — only compiled with --features aws-kms, and
611    // ignored by default since they require live AWS credentials +
612    // a real KMS key. Run locally with:
613    //   AWS_PROFILE=... S4_KMS_TEST_KEY_ID=arn:... \
614    //     cargo test --features aws-kms aws_kms_ -- --ignored
615    // CI runs them nightly via .github/workflows/aws-kms-e2e.yml when
616    // the AWS_KMS_* repo variables are configured (v0.8.1 #60).
617    // -----------------------------------------------------------------
618
619    /// v0.8.1 #60: Real AWS KMS round-trip — exercises GenerateDataKey
620    /// followed by Decrypt against an actual KMS key, asserting the
621    /// 32-byte DEK survives the wrap/unwrap byte-for-byte. Wrapped form
622    /// must NOT equal the plaintext (defends against an `AwsKms` impl
623    /// that accidentally stored plaintext in `WrappedDek::ciphertext`).
624    /// The canonical-key-id check guards against the AWS SDK silently
625    /// dropping `KeyId` from the response — we want the resolved ARN
626    /// stored, not whatever alias the caller passed.
627    #[cfg(feature = "aws-kms")]
628    #[tokio::test]
629    #[ignore = "requires AWS credentials and a real KMS key (set S4_KMS_TEST_KEY_ID)"]
630    async fn aws_kms_roundtrip() {
631        let key_id = std::env::var("S4_KMS_TEST_KEY_ID")
632            .expect("S4_KMS_TEST_KEY_ID env var required (real AWS KMS key ARN or alias)");
633        let kms = super::aws::AwsKms::from_default_env().await;
634
635        // GenerateDataKey
636        let (plaintext_dek, wrapped) = kms
637            .generate_dek(&key_id)
638            .await
639            .expect("generate_dek should succeed against real KMS");
640        assert_eq!(
641            plaintext_dek.len(),
642            DEK_LEN,
643            "DEK should be 32 bytes (AES-256)"
644        );
645
646        // Wrapped form must differ from plaintext — a wrapper that
647        // accidentally returned the plaintext as ciphertext would
648        // catastrophically leak the DEK at rest.
649        // v0.8.1 #58: `plaintext_dek` is now `Zeroizing<Vec<u8>>`;
650        // deref via `&*` to compare against the bare `Vec<u8>`
651        // ciphertext field.
652        assert_ne!(
653            wrapped.ciphertext, *plaintext_dek,
654            "wrapped DEK must NOT equal plaintext DEK"
655        );
656
657        // Decrypt round-trip — must byte-equal the original DEK.
658        // Both sides are `Zeroizing<Vec<u8>>`; deref both for the
659        // `PartialEq<Vec<u8>>` impl.
660        let unwrapped = kms
661            .decrypt_dek(&wrapped)
662            .await
663            .expect("decrypt_dek should succeed");
664        assert_eq!(*unwrapped, *plaintext_dek, "round-trip DEK must byte-equal");
665
666        // KMS returns the canonical ARN even when an alias was passed
667        // in. We accept either the canonical ARN form or — as a fallback
668        // — the original key id string the caller supplied (for the
669        // unlikely case AWS doesn't echo `KeyId`).
670        assert!(
671            wrapped.key_id.starts_with("arn:aws:kms:") || wrapped.key_id == key_id,
672            "wrapped key_id should be canonical ARN or original input: {}",
673            wrapped.key_id
674        );
675    }
676
677    /// v0.8.1 #60: Unwrap of a syntactically valid but bogus ciphertext
678    /// must surface a backend / unwrap error rather than silently
679    /// returning bytes. The point is to defend against future
680    /// refactors that might unwrap `Result::ok()` and zero-fill the DEK
681    /// — that would still pass `aws_kms_roundtrip` (because real
682    /// ciphertexts decrypt fine) but would let a corrupt DEK through.
683    #[cfg(feature = "aws-kms")]
684    #[tokio::test]
685    #[ignore = "requires AWS credentials (no specific key needed; uses a synthetic bogus ARN)"]
686    async fn aws_kms_unwrap_unknown_arn_fails() {
687        let kms = super::aws::AwsKms::from_default_env().await;
688        let bogus = WrappedDek {
689            // Syntactically valid ARN format, all-zero account + key —
690            // KMS will reject either NotFound or InvalidCiphertext.
691            key_id: "arn:aws:kms:us-east-1:000000000000:key/00000000-0000-0000-0000-000000000000"
692                .to_string(),
693            ciphertext: vec![0u8; 100],
694        };
695        let err = kms
696            .decrypt_dek(&bogus)
697            .await
698            .expect_err("decrypt with bogus ciphertext must fail");
699        assert!(
700            matches!(
701                err,
702                KmsError::BackendUnavailable { .. } | KmsError::UnwrapFailed { .. }
703            ),
704            "expected BackendUnavailable or UnwrapFailed, got {err:?}"
705        );
706    }
707
708    // -----------------------------------------------------------------
709    // v0.8.1 #58: DEK zeroize on drop tests.
710    //
711    // The first two tests are compile-time type assertions disguised
712    // as runtime checks — they confirm the trait method returns
713    // `Zeroizing<Vec<u8>>` rather than a bare `Vec<u8>`. If a future
714    // refactor accidentally widens the return type back to `Vec<u8>`,
715    // the explicit `let _: Zeroizing<Vec<u8>> = ...` binding fails to
716    // compile.
717    //
718    // The third test is a best-effort smoke check that drop wipes the
719    // backing memory. We intentionally rely on the `zeroize` crate's
720    // own test suite for the strong guarantee — modern allocators
721    // routinely re-use freed allocations, so reading the same heap
722    // pointer post-drop is undefined behaviour. This test only
723    // confirms `Zeroizing` wrap compiles and integrates with our DEK
724    // shape; the security claim is "we use the canonical zeroize
725    // primitive correctly", not "this test proves the bytes are
726    // gone".
727    // -----------------------------------------------------------------
728
729    #[tokio::test]
730    async fn local_kms_generate_dek_returns_zeroizing() {
731        let tmp = TempDir::new().unwrap();
732        write_kek(tmp.path(), "z", &[7u8; KEK_LEN]);
733        let kms = LocalKms::open(tmp.path().to_path_buf()).unwrap();
734        // Compile-time check: the explicit type binding fails if
735        // `generate_dek` regresses to returning a bare `Vec<u8>`.
736        let (dek, _wrapped): (Zeroizing<Vec<u8>>, WrappedDek) =
737            kms.generate_dek("z").await.unwrap();
738        // Functional sanity: `Deref<Target=Vec<u8>>` lets us call
739        // `.len()` and treat the value as a byte slice unchanged.
740        assert_eq!(dek.len(), DEK_LEN);
741        // `&*dek` derefs to `&Vec<u8>`, which auto-coerces to `&[u8]`.
742        let _slice: &[u8] = &dek;
743    }
744
745    #[tokio::test]
746    async fn local_kms_decrypt_dek_returns_zeroizing() {
747        let tmp = TempDir::new().unwrap();
748        write_kek(tmp.path(), "z", &[11u8; KEK_LEN]);
749        let kms = LocalKms::open(tmp.path().to_path_buf()).unwrap();
750        let (dek_in, wrapped) = kms.generate_dek("z").await.unwrap();
751        // Compile-time check on the decrypt path.
752        let dek_out: Zeroizing<Vec<u8>> = kms.decrypt_dek(&wrapped).await.unwrap();
753        assert_eq!(dek_out.len(), DEK_LEN);
754        // Round-trip: the unwrapped DEK matches the freshly generated one.
755        // `&*dek_in` and `&*dek_out` both deref to `&Vec<u8>` for `==`.
756        assert_eq!(&*dek_out, &*dek_in);
757    }
758
759    #[tokio::test]
760    async fn dek_zeroized_on_drop_smoke() {
761        // Best-effort: build a `Zeroizing<Vec<u8>>` populated with a
762        // sentinel pattern, hand its inner bytes through `&*` to
763        // confirm the deref chain works, then explicitly drop and
764        // verify the wrapper's `Drop` runs without panicking. The
765        // strong guarantee that the bytes are wiped is provided by
766        // the `zeroize` crate's own tests; we only assert that our
767        // chosen wrapping type integrates cleanly.
768        let mut z: Zeroizing<Vec<u8>> = Zeroizing::new(vec![0u8; DEK_LEN]);
769        for (i, b) in z.iter_mut().enumerate() {
770            *b = (i as u8).wrapping_add(1);
771        }
772        // Pre-drop: bytes should be the sentinel pattern.
773        assert_eq!(z[0], 1);
774        assert_eq!(z[DEK_LEN - 1], DEK_LEN as u8);
775        // Explicit drop runs `Zeroize::zeroize` on the inner Vec,
776        // which writes zeros to every byte and then frees the
777        // allocation. We can't safely re-read the freed memory
778        // (UB on a strict reading; flaky in practice because
779        // jemalloc / glibc reuse arenas), so the assertion is
780        // simply that drop completes without panic.
781        drop(z);
782        // If we got here, `Zeroizing<Vec<u8>>` ran its Drop impl.
783        // `zeroize` crate tests prove the bytes are zeroed; this
784        // test proves we're using the right wrapper.
785    }
786}