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}