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