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(
270                nonce,
271                Payload {
272                    msg: &dek,
273                    aad,
274                },
275            )
276            .expect("aes-gcm encrypt cannot fail with a 32-byte key");
277
278        // Layout: nonce || ct_with_tag (the latter already contains
279        // the 16-byte trailing tag from the aes-gcm crate). The wrapped
280        // ciphertext is intentionally NOT `Zeroizing` — it's an
281        // encrypted blob that lives at rest in the S4E4 frame, so
282        // wiping it on drop would just be busywork.
283        let mut wrapped = Vec::with_capacity(WRAP_NONCE_LEN + ct_with_tag.len());
284        wrapped.extend_from_slice(&nonce_bytes);
285        wrapped.extend_from_slice(&ct_with_tag);
286
287        Ok((
288            dek,
289            WrappedDek {
290                key_id: key_id.to_string(),
291                ciphertext: wrapped,
292            },
293        ))
294    }
295
296    async fn decrypt_dek(&self, wrapped: &WrappedDek) -> Result<Zeroizing<Vec<u8>>, KmsError> {
297        let kek = self.kek(&wrapped.key_id)?;
298        if wrapped.ciphertext.len() < LOCAL_WRAP_MIN_LEN {
299            return Err(KmsError::WrappedDekTooShort {
300                got: wrapped.ciphertext.len(),
301                min: LOCAL_WRAP_MIN_LEN,
302            });
303        }
304        let (nonce_bytes, ct_with_tag) = wrapped.ciphertext.split_at(WRAP_NONCE_LEN);
305        let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(kek));
306        let nonce = Nonce::from_slice(nonce_bytes);
307        let aad = wrapped.key_id.as_bytes();
308        let dek = cipher
309            .decrypt(
310                nonce,
311                Payload {
312                    msg: ct_with_tag,
313                    aad,
314                },
315            )
316            .map_err(|_| KmsError::UnwrapFailed {
317                key_id: wrapped.key_id.clone(),
318            })?;
319        // v0.8.1 #58: rewrap the freshly-decrypted plaintext into
320        // `Zeroizing` immediately so any panic between here and the
321        // caller's stack `[u8; 32]` copy still wipes the heap bytes.
322        Ok(Zeroizing::new(dek))
323    }
324}
325
326// ----------------------------------------------------------------------------
327// AWS KMS backend (feature-gated)
328// ----------------------------------------------------------------------------
329
330#[cfg(feature = "aws-kms")]
331pub mod aws {
332    //! AWS KMS-backed [`KmsBackend`]. Off by default — enable with
333    //! `--features aws-kms`. The backend forwards `generate_dek` to
334    //! `GenerateDataKey` (with `KeySpec=AES_256`) and `decrypt_dek`
335    //! to `Decrypt`; the wrapped DEK ciphertext is exactly the opaque
336    //! blob AWS returns, so we don't double-wrap.
337    use super::{KmsBackend, KmsError, WrappedDek};
338    use async_trait::async_trait;
339    use zeroize::Zeroizing;
340
341    /// AWS KMS-backed KEK store. The `key_id` passed to
342    /// [`KmsBackend::generate_dek`] is forwarded as `KeyId` to AWS —
343    /// callers can use a key ARN, alias ARN, or alias name. For
344    /// [`KmsBackend::decrypt_dek`] AWS re-derives the KEK from
345    /// `CiphertextBlob` so the `key_id` field on `WrappedDek` is
346    /// effectively a label / audit signal.
347    pub struct AwsKms {
348        client: aws_sdk_kms::Client,
349    }
350
351    impl std::fmt::Debug for AwsKms {
352        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353            f.debug_struct("AwsKms").finish()
354        }
355    }
356
357    impl AwsKms {
358        /// Construct an [`AwsKms`] from a pre-built SDK client. Allows
359        /// callers to share an SDK config (region, retry, endpoint
360        /// override for LocalStack) with the rest of the gateway.
361        pub fn new(client: aws_sdk_kms::Client) -> Self {
362            Self { client }
363        }
364
365        /// Convenience: build a client from the ambient
366        /// `aws_config::load_defaults` (env, profile, IMDS, etc).
367        pub async fn from_default_env() -> Self {
368            let cfg = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
369            let client = aws_sdk_kms::Client::new(&cfg);
370            Self { client }
371        }
372    }
373
374    #[async_trait]
375    impl KmsBackend for AwsKms {
376        async fn generate_dek(
377            &self,
378            key_id: &str,
379        ) -> Result<(Zeroizing<Vec<u8>>, WrappedDek), KmsError> {
380            let resp = self
381                .client
382                .generate_data_key()
383                .key_id(key_id)
384                .key_spec(aws_sdk_kms::types::DataKeySpec::Aes256)
385                .send()
386                .await
387                .map_err(|e| KmsError::BackendUnavailable {
388                    message: format!("GenerateDataKey({key_id}): {e}"),
389                })?;
390            let dek_vec = resp
391                .plaintext
392                .ok_or_else(|| KmsError::BackendUnavailable {
393                    message: format!("GenerateDataKey({key_id}): missing Plaintext in response"),
394                })?
395                .into_inner();
396            // v0.8.1 #58: wrap immediately on receipt from AWS so any
397            // early-return (or panic) between here and the caller's
398            // copy_from_slice still wipes the DEK on drop.
399            let dek = Zeroizing::new(dek_vec);
400            let ciphertext = resp
401                .ciphertext_blob
402                .ok_or_else(|| KmsError::BackendUnavailable {
403                    message: format!("GenerateDataKey({key_id}): missing CiphertextBlob in response"),
404                })?
405                .into_inner();
406            // Use the response's KeyId (canonical ARN) when present so
407            // we record the resolved key, not the alias the caller
408            // passed. Falls back to the original on the unlikely
409            // chance AWS doesn't echo it.
410            let stored_id = resp.key_id.unwrap_or_else(|| key_id.to_string());
411            Ok((
412                dek,
413                WrappedDek {
414                    key_id: stored_id,
415                    ciphertext,
416                },
417            ))
418        }
419
420        async fn decrypt_dek(&self, wrapped: &WrappedDek) -> Result<Zeroizing<Vec<u8>>, KmsError> {
421            let resp = self
422                .client
423                .decrypt()
424                .ciphertext_blob(aws_sdk_kms::primitives::Blob::new(
425                    wrapped.ciphertext.clone(),
426                ))
427                .key_id(&wrapped.key_id)
428                .send()
429                .await
430                .map_err(|e| KmsError::BackendUnavailable {
431                    message: format!("Decrypt({}): {e}", wrapped.key_id),
432                })?;
433            let dek_vec = resp
434                .plaintext
435                .ok_or_else(|| KmsError::BackendUnavailable {
436                    message: format!("Decrypt({}): missing Plaintext in response", wrapped.key_id),
437                })?
438                .into_inner();
439            // v0.8.1 #58: same Zeroizing-on-receipt pattern as generate_dek.
440            Ok(Zeroizing::new(dek_vec))
441        }
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use std::collections::HashMap;
449    use std::path::Path;
450    use tempfile::TempDir;
451
452    fn write_kek(dir: &Path, name: &str, bytes: &[u8]) {
453        std::fs::write(dir.join(format!("{name}.kek")), bytes).unwrap();
454    }
455
456    #[tokio::test]
457    async fn open_empty_dir_is_ok() {
458        let tmp = TempDir::new().unwrap();
459        let kms = LocalKms::open(tmp.path().to_path_buf()).unwrap();
460        assert!(kms.key_ids().is_empty());
461        // generate_dek with no keys → KeyNotFound.
462        let err = kms.generate_dek("missing").await.unwrap_err();
463        assert!(
464            matches!(err, KmsError::KeyNotFound { ref key_id } if key_id == "missing"),
465            "got {err:?}"
466        );
467    }
468
469    #[tokio::test]
470    async fn open_loads_kek_files_and_skips_others() {
471        let tmp = TempDir::new().unwrap();
472        write_kek(tmp.path(), "alpha", &[1u8; KEK_LEN]);
473        write_kek(tmp.path(), "beta", &[2u8; KEK_LEN]);
474        // Non-`.kek` files must be ignored (sidecar metadata, README,
475        // editor swap files, etc).
476        std::fs::write(tmp.path().join("README"), b"hello").unwrap();
477        std::fs::write(tmp.path().join("alpha.kek.bak"), [9u8; 99]).unwrap();
478        let kms = LocalKms::open(tmp.path().to_path_buf()).unwrap();
479        let ids = kms.key_ids();
480        assert_eq!(ids, vec!["alpha".to_string(), "beta".to_string()]);
481    }
482
483    #[tokio::test]
484    async fn open_rejects_truncated_kek_file() {
485        let tmp = TempDir::new().unwrap();
486        // 31 bytes — one short of a valid KEK.
487        write_kek(tmp.path(), "short", &[7u8; KEK_LEN - 1]);
488        let err = LocalKms::open(tmp.path().to_path_buf()).unwrap_err();
489        assert!(
490            matches!(
491                err,
492                KmsError::KekBadLength { expected, got, .. } if expected == KEK_LEN && got == KEK_LEN - 1
493            ),
494            "got {err:?}"
495        );
496    }
497
498    #[tokio::test]
499    async fn generate_then_decrypt_roundtrip() {
500        let tmp = TempDir::new().unwrap();
501        write_kek(tmp.path(), "main", &[42u8; KEK_LEN]);
502        let kms = LocalKms::open(tmp.path().to_path_buf()).unwrap();
503        let (dek, wrapped) = kms.generate_dek("main").await.unwrap();
504        assert_eq!(dek.len(), DEK_LEN);
505        assert_eq!(wrapped.key_id, "main");
506        // Wrapped form: 12-byte nonce + 32-byte ciphertext + 16-byte
507        // tag = 60 bytes.
508        assert_eq!(wrapped.ciphertext.len(), WRAP_NONCE_LEN + DEK_LEN + WRAP_TAG_LEN);
509
510        let unwrapped = kms.decrypt_dek(&wrapped).await.unwrap();
511        assert_eq!(unwrapped, dek);
512    }
513
514    #[tokio::test]
515    async fn generate_uses_random_dek_and_nonce() {
516        let tmp = TempDir::new().unwrap();
517        write_kek(tmp.path(), "k", &[5u8; KEK_LEN]);
518        let kms = LocalKms::open(tmp.path().to_path_buf()).unwrap();
519        let (dek1, w1) = kms.generate_dek("k").await.unwrap();
520        let (dek2, w2) = kms.generate_dek("k").await.unwrap();
521        assert_ne!(dek1, dek2, "DEK must be random per call");
522        assert_ne!(w1.ciphertext, w2.ciphertext, "wrap nonce must be random per call");
523    }
524
525    #[tokio::test]
526    async fn decrypt_unknown_key_id_errors() {
527        let tmp = TempDir::new().unwrap();
528        write_kek(tmp.path(), "real", &[1u8; KEK_LEN]);
529        let kms = LocalKms::open(tmp.path().to_path_buf()).unwrap();
530        let bogus = WrappedDek {
531            key_id: "phantom".to_string(),
532            ciphertext: vec![0u8; LOCAL_WRAP_MIN_LEN + DEK_LEN],
533        };
534        let err = kms.decrypt_dek(&bogus).await.unwrap_err();
535        assert!(
536            matches!(err, KmsError::KeyNotFound { ref key_id } if key_id == "phantom"),
537            "got {err:?}"
538        );
539    }
540
541    #[tokio::test]
542    async fn decrypt_tampered_ciphertext_fails_unwrap() {
543        let tmp = TempDir::new().unwrap();
544        write_kek(tmp.path(), "k", &[3u8; KEK_LEN]);
545        let kms = LocalKms::open(tmp.path().to_path_buf()).unwrap();
546        let (_dek, mut wrapped) = kms.generate_dek("k").await.unwrap();
547        // Flip a byte in the encrypted DEK area (not the nonce, not
548        // the tag — but AES-GCM auths the whole thing, so any flip
549        // anywhere fails).
550        let mid = wrapped.ciphertext.len() / 2;
551        wrapped.ciphertext[mid] ^= 0xFF;
552        let err = kms.decrypt_dek(&wrapped).await.unwrap_err();
553        assert!(
554            matches!(err, KmsError::UnwrapFailed { ref key_id } if key_id == "k"),
555            "got {err:?}"
556        );
557    }
558
559    #[tokio::test]
560    async fn decrypt_short_ciphertext_errors() {
561        let tmp = TempDir::new().unwrap();
562        write_kek(tmp.path(), "k", &[8u8; KEK_LEN]);
563        let kms = LocalKms::open(tmp.path().to_path_buf()).unwrap();
564        let bogus = WrappedDek {
565            key_id: "k".to_string(),
566            ciphertext: vec![0u8; 5], // too small for nonce + tag
567        };
568        let err = kms.decrypt_dek(&bogus).await.unwrap_err();
569        assert!(
570            matches!(err, KmsError::WrappedDekTooShort { got: 5, .. }),
571            "got {err:?}"
572        );
573    }
574
575    #[tokio::test]
576    async fn decrypt_wrong_key_id_aad_fails_unwrap() {
577        // Wrap under "alpha", then forge a WrappedDek that claims
578        // "beta" with the same ciphertext bytes. AAD includes key_id
579        // so AES-GCM auth must fail under "beta"'s KEK + "beta" AAD,
580        // even if the bytes are the wrap of a real DEK.
581        let tmp = TempDir::new().unwrap();
582        write_kek(tmp.path(), "alpha", &[1u8; KEK_LEN]);
583        write_kek(tmp.path(), "beta", &[2u8; KEK_LEN]);
584        let kms = LocalKms::open(tmp.path().to_path_buf()).unwrap();
585        let (_dek, wrapped) = kms.generate_dek("alpha").await.unwrap();
586        let forged = WrappedDek {
587            key_id: "beta".to_string(),
588            ciphertext: wrapped.ciphertext.clone(),
589        };
590        let err = kms.decrypt_dek(&forged).await.unwrap_err();
591        assert!(
592            matches!(err, KmsError::UnwrapFailed { ref key_id } if key_id == "beta"),
593            "got {err:?}"
594        );
595    }
596
597    #[tokio::test]
598    async fn from_keks_constructor_works() {
599        let mut keks = HashMap::new();
600        keks.insert("inline".to_string(), [9u8; KEK_LEN]);
601        let kms = LocalKms::from_keks(PathBuf::from("/tmp/none"), keks);
602        let (_dek, wrapped) = kms.generate_dek("inline").await.unwrap();
603        assert_eq!(wrapped.key_id, "inline");
604        let _back = kms.decrypt_dek(&wrapped).await.unwrap();
605    }
606
607    // -----------------------------------------------------------------
608    // AwsKms tests — only compiled with --features aws-kms, and
609    // ignored by default since they require live AWS credentials +
610    // a real KMS key. Run locally with:
611    //   AWS_PROFILE=... S4_KMS_TEST_KEY_ID=arn:... \
612    //     cargo test --features aws-kms aws_kms_ -- --ignored
613    // CI runs them nightly via .github/workflows/aws-kms-e2e.yml when
614    // the AWS_KMS_* repo variables are configured (v0.8.1 #60).
615    // -----------------------------------------------------------------
616
617    /// v0.8.1 #60: Real AWS KMS round-trip — exercises GenerateDataKey
618    /// followed by Decrypt against an actual KMS key, asserting the
619    /// 32-byte DEK survives the wrap/unwrap byte-for-byte. Wrapped form
620    /// must NOT equal the plaintext (defends against an `AwsKms` impl
621    /// that accidentally stored plaintext in `WrappedDek::ciphertext`).
622    /// The canonical-key-id check guards against the AWS SDK silently
623    /// dropping `KeyId` from the response — we want the resolved ARN
624    /// stored, not whatever alias the caller passed.
625    #[cfg(feature = "aws-kms")]
626    #[tokio::test]
627    #[ignore = "requires AWS credentials and a real KMS key (set S4_KMS_TEST_KEY_ID)"]
628    async fn aws_kms_roundtrip() {
629        let key_id = std::env::var("S4_KMS_TEST_KEY_ID")
630            .expect("S4_KMS_TEST_KEY_ID env var required (real AWS KMS key ARN or alias)");
631        let kms = super::aws::AwsKms::from_default_env().await;
632
633        // GenerateDataKey
634        let (plaintext_dek, wrapped) = kms
635            .generate_dek(&key_id)
636            .await
637            .expect("generate_dek should succeed against real KMS");
638        assert_eq!(
639            plaintext_dek.len(),
640            DEK_LEN,
641            "DEK should be 32 bytes (AES-256)"
642        );
643
644        // Wrapped form must differ from plaintext — a wrapper that
645        // accidentally returned the plaintext as ciphertext would
646        // catastrophically leak the DEK at rest.
647        // v0.8.1 #58: `plaintext_dek` is now `Zeroizing<Vec<u8>>`;
648        // deref via `&*` to compare against the bare `Vec<u8>`
649        // ciphertext field.
650        assert_ne!(
651            wrapped.ciphertext, *plaintext_dek,
652            "wrapped DEK must NOT equal plaintext DEK"
653        );
654
655        // Decrypt round-trip — must byte-equal the original DEK.
656        // Both sides are `Zeroizing<Vec<u8>>`; deref both for the
657        // `PartialEq<Vec<u8>>` impl.
658        let unwrapped = kms
659            .decrypt_dek(&wrapped)
660            .await
661            .expect("decrypt_dek should succeed");
662        assert_eq!(*unwrapped, *plaintext_dek, "round-trip DEK must byte-equal");
663
664        // KMS returns the canonical ARN even when an alias was passed
665        // in. We accept either the canonical ARN form or — as a fallback
666        // — the original key id string the caller supplied (for the
667        // unlikely case AWS doesn't echo `KeyId`).
668        assert!(
669            wrapped.key_id.starts_with("arn:aws:kms:") || wrapped.key_id == key_id,
670            "wrapped key_id should be canonical ARN or original input: {}",
671            wrapped.key_id
672        );
673    }
674
675    /// v0.8.1 #60: Unwrap of a syntactically valid but bogus ciphertext
676    /// must surface a backend / unwrap error rather than silently
677    /// returning bytes. The point is to defend against future
678    /// refactors that might unwrap `Result::ok()` and zero-fill the DEK
679    /// — that would still pass `aws_kms_roundtrip` (because real
680    /// ciphertexts decrypt fine) but would let a corrupt DEK through.
681    #[cfg(feature = "aws-kms")]
682    #[tokio::test]
683    #[ignore = "requires AWS credentials (no specific key needed; uses a synthetic bogus ARN)"]
684    async fn aws_kms_unwrap_unknown_arn_fails() {
685        let kms = super::aws::AwsKms::from_default_env().await;
686        let bogus = WrappedDek {
687            // Syntactically valid ARN format, all-zero account + key —
688            // KMS will reject either NotFound or InvalidCiphertext.
689            key_id: "arn:aws:kms:us-east-1:000000000000:key/00000000-0000-0000-0000-000000000000"
690                .to_string(),
691            ciphertext: vec![0u8; 100],
692        };
693        let err = kms
694            .decrypt_dek(&bogus)
695            .await
696            .expect_err("decrypt with bogus ciphertext must fail");
697        assert!(
698            matches!(
699                err,
700                KmsError::BackendUnavailable { .. } | KmsError::UnwrapFailed { .. }
701            ),
702            "expected BackendUnavailable or UnwrapFailed, got {err:?}"
703        );
704    }
705
706    // -----------------------------------------------------------------
707    // v0.8.1 #58: DEK zeroize on drop tests.
708    //
709    // The first two tests are compile-time type assertions disguised
710    // as runtime checks — they confirm the trait method returns
711    // `Zeroizing<Vec<u8>>` rather than a bare `Vec<u8>`. If a future
712    // refactor accidentally widens the return type back to `Vec<u8>`,
713    // the explicit `let _: Zeroizing<Vec<u8>> = ...` binding fails to
714    // compile.
715    //
716    // The third test is a best-effort smoke check that drop wipes the
717    // backing memory. We intentionally rely on the `zeroize` crate's
718    // own test suite for the strong guarantee — modern allocators
719    // routinely re-use freed allocations, so reading the same heap
720    // pointer post-drop is undefined behaviour. This test only
721    // confirms `Zeroizing` wrap compiles and integrates with our DEK
722    // shape; the security claim is "we use the canonical zeroize
723    // primitive correctly", not "this test proves the bytes are
724    // gone".
725    // -----------------------------------------------------------------
726
727    #[tokio::test]
728    async fn local_kms_generate_dek_returns_zeroizing() {
729        let tmp = TempDir::new().unwrap();
730        write_kek(tmp.path(), "z", &[7u8; KEK_LEN]);
731        let kms = LocalKms::open(tmp.path().to_path_buf()).unwrap();
732        // Compile-time check: the explicit type binding fails if
733        // `generate_dek` regresses to returning a bare `Vec<u8>`.
734        let (dek, _wrapped): (Zeroizing<Vec<u8>>, WrappedDek) =
735            kms.generate_dek("z").await.unwrap();
736        // Functional sanity: `Deref<Target=Vec<u8>>` lets us call
737        // `.len()` and treat the value as a byte slice unchanged.
738        assert_eq!(dek.len(), DEK_LEN);
739        // `&*dek` derefs to `&Vec<u8>`, which auto-coerces to `&[u8]`.
740        let _slice: &[u8] = &dek;
741    }
742
743    #[tokio::test]
744    async fn local_kms_decrypt_dek_returns_zeroizing() {
745        let tmp = TempDir::new().unwrap();
746        write_kek(tmp.path(), "z", &[11u8; KEK_LEN]);
747        let kms = LocalKms::open(tmp.path().to_path_buf()).unwrap();
748        let (dek_in, wrapped) = kms.generate_dek("z").await.unwrap();
749        // Compile-time check on the decrypt path.
750        let dek_out: Zeroizing<Vec<u8>> = kms.decrypt_dek(&wrapped).await.unwrap();
751        assert_eq!(dek_out.len(), DEK_LEN);
752        // Round-trip: the unwrapped DEK matches the freshly generated one.
753        // `&*dek_in` and `&*dek_out` both deref to `&Vec<u8>` for `==`.
754        assert_eq!(&*dek_out, &*dek_in);
755    }
756
757    #[tokio::test]
758    async fn dek_zeroized_on_drop_smoke() {
759        // Best-effort: build a `Zeroizing<Vec<u8>>` populated with a
760        // sentinel pattern, hand its inner bytes through `&*` to
761        // confirm the deref chain works, then explicitly drop and
762        // verify the wrapper's `Drop` runs without panicking. The
763        // strong guarantee that the bytes are wiped is provided by
764        // the `zeroize` crate's own tests; we only assert that our
765        // chosen wrapping type integrates cleanly.
766        let mut z: Zeroizing<Vec<u8>> = Zeroizing::new(vec![0u8; DEK_LEN]);
767        for (i, b) in z.iter_mut().enumerate() {
768            *b = (i as u8).wrapping_add(1);
769        }
770        // Pre-drop: bytes should be the sentinel pattern.
771        assert_eq!(z[0], 1);
772        assert_eq!(z[DEK_LEN - 1], DEK_LEN as u8);
773        // Explicit drop runs `Zeroize::zeroize` on the inner Vec,
774        // which writes zeros to every byte and then frees the
775        // allocation. We can't safely re-read the freed memory
776        // (UB on a strict reading; flaky in practice because
777        // jemalloc / glibc reuse arenas), so the assertion is
778        // simply that drop completes without panic.
779        drop(z);
780        // If we got here, `Zeroizing<Vec<u8>>` ran its Drop impl.
781        // `zeroize` crate tests prove the bytes are zeroed; this
782        // test proves we're using the right wrapper.
783    }
784}