Skip to main content

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}