cratefield_kms/lib.rs
1//! `cratefield-kms` โ the KMS port (issue #40, ADR 0102,
2//! `docs/SECRETS-DESIGN.md`).
3//!
4//! Wrapping and unwrapping a data key is the **only** thing the KMS does
5//! for us, so it is the only thing this trait can ask. Everything else โ
6//! which cipher seals a secret, what binds a ciphertext to its row, where
7//! the wrapped key is stored โ belongs to the envelope layer (#39) and to
8//! the database, never to the vendor.
9//!
10//! The provider name and key reference travel with every wrapped key, so
11//! changing vendor is a re-wrap job rather than a schema change.
12//!
13//! ```no_run
14//! # use cratefield_kms::{Kms, LocalFileKms, Dek};
15//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
16//! let kms = LocalFileKms::open("/run/secrets/fz-kek", "development")?;
17//! let dek = Dek::generate()?;
18//! let wrapped = kms.wrap(&dek).await?;
19//! let same = kms.unwrap(&wrapped).await?;
20//! assert_eq!(dek.expose(), same.expose());
21//! # Ok(())
22//! # }
23//! ```
24
25#![forbid(unsafe_code)]
26
27mod local;
28mod worker_secret;
29
30use async_trait::async_trait;
31use thiserror::Error;
32use zeroize::{Zeroize, Zeroizing};
33
34pub use local::LocalFileKms;
35pub use worker_secret::WorkerSecretKms;
36
37/// A 256-bit data key. Zeroised on drop, never `Debug`-printed, never
38/// `Clone`d: a key that can be copied is a key that can be left behind.
39pub struct Dek(Zeroizing<Vec<u8>>);
40
41/// The only size this port deals in (ADR 0102).
42pub const DEK_LEN: usize = 32;
43
44impl Dek {
45 /// A fresh key from the OS RNG.
46 ///
47 /// # Errors
48 ///
49 /// [`KmsError::Unavailable`] when the OS RNG will not answer, which
50 /// is not a condition worth continuing through.
51 pub fn generate() -> Result<Self, KmsError> {
52 let mut bytes = vec![0_u8; DEK_LEN];
53 getrandom::fill(&mut bytes).map_err(|err| {
54 bytes.zeroize();
55 KmsError::Unavailable(format!("the OS random source failed: {err}"))
56 })?;
57 Ok(Self(Zeroizing::new(bytes)))
58 }
59
60 /// Adopts key material the caller already holds (an unwrap result, a
61 /// key read from a file).
62 ///
63 /// # Errors
64 ///
65 /// [`KmsError::Invalid`] unless the material is exactly [`DEK_LEN`].
66 pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, KmsError> {
67 if bytes.len() != DEK_LEN {
68 let mut bytes = bytes;
69 bytes.zeroize();
70 return Err(KmsError::Invalid(format!("a data key is {DEK_LEN} bytes")));
71 }
72 Ok(Self(Zeroizing::new(bytes)))
73 }
74
75 /// The key material. Named `expose` so a reader has to notice.
76 #[must_use]
77 pub fn expose(&self) -> &[u8] {
78 &self.0
79 }
80}
81
82impl std::fmt::Debug for Dek {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 f.write_str("Dek([redacted])")
85 }
86}
87
88/// Why a wrap or unwrap did not happen. The split that matters
89/// operationally is **unavailable** (retry, alarm on duration) against
90/// **denied** (do not retry, alarm immediately): they want different
91/// alarms and different retry behaviour, and a single opaque error makes
92/// a permissions mistake look like an outage for as long as it takes
93/// someone to read the logs.
94#[derive(Debug, Error)]
95pub enum KmsError {
96 /// The KMS could not be reached, or answered that it is unwell.
97 /// Retryable.
98 #[error("the KMS is unavailable: {0}")]
99 Unavailable(String),
100 /// The KMS was reached and refused: no permission on the key, or the
101 /// key is disabled. Not retryable; a human has to change something.
102 #[error("the KMS denied the request: {0}")]
103 Denied(String),
104 /// The wrapped key did not authenticate: it was truncated, altered,
105 /// or wrapped under a different master key. Never retryable, and
106 /// never a reason to fall back to anything.
107 #[error("the wrapped key failed authentication: {0}")]
108 Tampered(String),
109 /// The caller passed something this port cannot accept.
110 #[error("invalid input: {0}")]
111 Invalid(String),
112 /// The provider refuses to run in this configuration.
113 #[error("{0}")]
114 Refused(String),
115}
116
117impl KmsError {
118 /// Whether a retry could plausibly succeed without anyone
119 /// intervening. Retry policy lives with the caller; this is the fact
120 /// it needs.
121 #[must_use]
122 pub fn is_retryable(&self) -> bool {
123 matches!(self, KmsError::Unavailable(_))
124 }
125}
126
127/// Wrap and unwrap a data key under a master key that never leaves the
128/// KMS.
129///
130/// Implementations must not log key material, must not fall back to any
131/// other key on failure, and must map vendor errors onto
132/// [`KmsError::Unavailable`] and [`KmsError::Denied`] rather than
133/// collapsing them.
134#[async_trait]
135pub trait Kms: Send + Sync {
136 /// Stored with every wrapped key: `"local-file"`, `"worker-secret"`,
137 /// `"aws-kms"`, `"gcp-kms"`.
138 fn provider(&self) -> &'static str;
139
140 /// The master key this instance wraps under, as the vendor names it.
141 /// Stored with every wrapped key so a re-wrap knows what it is
142 /// moving from.
143 fn key_ref(&self) -> &str;
144
145 /// Wraps a data key. The result is opaque to the caller and is what
146 /// goes in the database (`docs/SECRETS-DESIGN.md` ยง3).
147 ///
148 /// # Errors
149 ///
150 /// [`KmsError`], with `Unavailable` and `Denied` distinguished.
151 async fn wrap(&self, dek: &Dek) -> Result<Vec<u8>, KmsError>;
152
153 /// Unwraps what [`Kms::wrap`] produced.
154 ///
155 /// # Errors
156 ///
157 /// [`KmsError::Tampered`] when the wrapped key does not
158 /// authenticate, and otherwise as [`Kms::wrap`].
159 async fn unwrap(&self, wrapped: &[u8]) -> Result<Dek, KmsError>;
160}
161
162/// The conformance every provider passes (issue #40). Kept in the crate
163/// rather than in one provider's tests so a new vendor is one call away
164/// from being held to the same behaviour.
165///
166/// Checks the round trip, that two wraps of one key differ (a fresh
167/// nonce per wrap, so a wrapped blob is not a stable identifier), that
168/// every single-bit change to the wrapped blob is refused as
169/// [`KmsError::Tampered`], and that a truncated or empty blob is refused
170/// rather than panicking.
171///
172/// # Panics
173///
174/// With a message naming the property that failed.
175pub async fn conformance(kms: &dyn Kms) {
176 assert!(
177 !kms.provider().is_empty(),
178 "a provider name is stored with every wrapped key; it cannot be empty"
179 );
180 assert!(!kms.key_ref().is_empty(), "a key reference cannot be empty");
181
182 let dek = Dek::generate().expect("the OS RNG answers");
183 let wrapped = kms.wrap(&dek).await.expect("wrap succeeds");
184 let recovered = kms.unwrap(&wrapped).await.expect("unwrap succeeds");
185 assert_eq!(
186 dek.expose(),
187 recovered.expose(),
188 "unwrap must return the key that was wrapped"
189 );
190
191 let again = kms.wrap(&dek).await.expect("wrap succeeds twice");
192 assert_ne!(
193 wrapped, again,
194 "each wrap uses a fresh nonce, so a wrapped key is not a stable identifier"
195 );
196 let recovered = kms.unwrap(&again).await.expect("the second wrap unwraps");
197 assert_eq!(dek.expose(), recovered.expose());
198
199 // Every byte is covered by the tag, including whatever framing the
200 // provider adds. A provider that authenticates only part of its blob
201 // fails here rather than in production.
202 for index in 0..wrapped.len() {
203 let mut altered = wrapped.clone();
204 altered[index] ^= 0b0000_0001;
205 match kms.unwrap(&altered).await {
206 Err(KmsError::Tampered(_)) => {}
207 Err(other) => panic!("a flipped bit at {index} must be Tampered, got {other}"),
208 Ok(_) => panic!("a flipped bit at {index} was accepted"),
209 }
210 }
211
212 for (what, blob) in [
213 ("empty", Vec::new()),
214 ("truncated", wrapped[..wrapped.len() / 2].to_vec()),
215 ("one byte", vec![0]),
216 ] {
217 match kms.unwrap(&blob).await {
218 Err(KmsError::Tampered(_) | KmsError::Invalid(_)) => {}
219 Err(other) => panic!("a {what} blob must be refused cleanly, got {other}"),
220 Ok(_) => panic!("a {what} blob was accepted"),
221 }
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn a_dek_does_not_print_its_material() {
231 let dek = Dek::from_bytes(vec![7; DEK_LEN]).expect("32 bytes");
232 let shown = format!("{dek:?}");
233 assert_eq!(shown, "Dek([redacted])");
234 assert!(!shown.contains('7'), "{shown}");
235 }
236
237 #[test]
238 fn a_dek_is_exactly_32_bytes() {
239 assert!(Dek::from_bytes(vec![0; 31]).is_err());
240 assert!(Dek::from_bytes(vec![0; 33]).is_err());
241 assert!(Dek::from_bytes(vec![0; DEK_LEN]).is_ok());
242 assert_eq!(Dek::generate().expect("rng").expose().len(), DEK_LEN);
243 }
244
245 #[test]
246 fn only_unavailable_is_retryable() {
247 assert!(KmsError::Unavailable("x".into()).is_retryable());
248 for error in [
249 KmsError::Denied("x".into()),
250 KmsError::Tampered("x".into()),
251 KmsError::Invalid("x".into()),
252 KmsError::Refused("x".into()),
253 ] {
254 assert!(!error.is_retryable(), "{error} must not be retried");
255 }
256 }
257}