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}