Skip to main content

cratefield_kms/
local.rs

1//! `LocalFileKms` — the provider for development and tests (issue #40).
2//!
3//! It wraps data keys under a master key read from a file, with the same
4//! AEAD the envelope layer uses (XChaCha20-Poly1305, ADR 0102). That is
5//! a real cipher, but a master key on a local disk is not a managed KMS:
6//! it can be read, copied and backed up by anything that can read the
7//! file, which is the property a KMS exists to remove. So it **refuses
8//! to construct in production**.
9
10use std::path::Path;
11
12use base64::Engine as _;
13
14use async_trait::async_trait;
15use chacha20poly1305::aead::{Aead, KeyInit, Payload};
16use zeroize::Zeroize;
17
18use crate::{DEK_LEN, Dek, Kms, KmsError};
19
20/// Domain separation, so a blob produced here can never be mistaken for
21/// a secret ciphertext from the envelope layer even under the same key.
22const WRAP_AAD: &[u8] = b"FZ-KMS-LOCAL-WRAP-v1";
23const NONCE_LEN: usize = 24;
24
25/// A KEK read from a file. See the module docs for why it refuses
26/// production.
27pub struct LocalFileKms {
28    kek: Dek,
29    key_ref: String,
30}
31
32impl std::fmt::Debug for LocalFileKms {
33    /// Names the key file, never its contents. Written by hand rather
34    /// than derived so it cannot start printing key material because a
35    /// field was added.
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        f.debug_struct("LocalFileKms")
38            .field("provider", &"local-file")
39            .field("key_ref", &self.key_ref)
40            .finish_non_exhaustive()
41    }
42}
43
44impl LocalFileKms {
45    /// Reads the master key from `path`.
46    ///
47    /// `env` is the deployment environment, as
48    /// `cratefield_core::VentureEnv` spells it: anything equal to
49    /// `"production"` (case-insensitively) is refused. The environment
50    /// is a parameter rather than read from the process here, so the
51    /// refusal cannot be sidestepped by unsetting a variable, and so the
52    /// caller passes the same value it validated everything else with.
53    ///
54    /// The file holds 32 raw bytes, or their hex or base64 encoding on
55    /// one line; a trailing newline is ignored, because the difference
56    /// between `printf` and `echo` should not be a cryptographic event.
57    ///
58    /// # Errors
59    ///
60    /// [`KmsError::Refused`] in production, [`KmsError::Unavailable`]
61    /// when the file cannot be read, [`KmsError::Invalid`] when its
62    /// contents are not a 32-byte key.
63    pub fn open(path: impl AsRef<Path>, env: &str) -> Result<Self, KmsError> {
64        if env.eq_ignore_ascii_case("production") {
65            return Err(KmsError::Refused(
66                "LocalFileKms holds its master key on local disk, where anything that can read \
67                 the file can read the key. It is for development and tests. Configure a managed \
68                 KMS provider for production"
69                    .to_owned(),
70            ));
71        }
72        let path = path.as_ref();
73        let raw = std::fs::read(path).map_err(|err| {
74            KmsError::Unavailable(format!(
75                "cannot read the master key at {}: {err}",
76                path.display()
77            ))
78        })?;
79        let kek = parse_key(raw).map_err(|err| {
80            KmsError::Invalid(format!("the master key at {}: {err}", path.display()))
81        })?;
82        Ok(Self {
83            kek,
84            key_ref: path.display().to_string(),
85        })
86    }
87
88    /// A KEK the caller already holds, for tests that do not want a
89    /// file. Refuses production for the same reason.
90    ///
91    /// # Errors
92    ///
93    /// [`KmsError::Refused`] in production.
94    pub fn from_key(kek: Dek, key_ref: impl Into<String>, env: &str) -> Result<Self, KmsError> {
95        if env.eq_ignore_ascii_case("production") {
96            return Err(KmsError::Refused(
97                "LocalFileKms is for development and tests".to_owned(),
98            ));
99        }
100        Ok(Self {
101            kek,
102            key_ref: key_ref.into(),
103        })
104    }
105
106    /// Writes a fresh master key to `path` for a development
107    /// environment. Refuses to overwrite: losing a KEK means losing
108    /// every secret wrapped under it, so the destructive version of this
109    /// is a human deleting the file on purpose.
110    ///
111    /// # Errors
112    ///
113    /// [`KmsError::Refused`] when the file exists,
114    /// [`KmsError::Unavailable`] when it cannot be written.
115    pub fn create(path: impl AsRef<Path>) -> Result<(), KmsError> {
116        let path = path.as_ref();
117        if path.exists() {
118            return Err(KmsError::Refused(format!(
119                "{} exists; every secret wrapped under it would become unreadable. Delete it \
120                 deliberately if that is what you mean",
121                path.display()
122            )));
123        }
124        let dek = Dek::generate()?;
125        let mut hex = String::with_capacity(DEK_LEN * 2);
126        for byte in dek.expose() {
127            use std::fmt::Write as _;
128            let _ = write!(hex, "{byte:02x}");
129        }
130        let result = std::fs::write(path, format!("{hex}\n")).map_err(|err| {
131            KmsError::Unavailable(format!("cannot write {}: {err}", path.display()))
132        });
133        hex.zeroize();
134        result
135    }
136}
137
138/// 32 raw bytes, or hex, or base64, with surrounding whitespace ignored.
139fn parse_key(raw: Vec<u8>) -> Result<Dek, String> {
140    if raw.len() == DEK_LEN {
141        return Dek::from_bytes(raw).map_err(|err| err.to_string());
142    }
143    let text = String::from_utf8(raw)
144        .map_err(|_| format!("is not {DEK_LEN} raw bytes and not valid text"))?;
145    let text = text.trim();
146    if text.len() == DEK_LEN * 2
147        && let Ok(bytes) = decode_hex(text)
148    {
149        return Dek::from_bytes(bytes).map_err(|err| err.to_string());
150    }
151    if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(text.as_bytes())
152        && bytes.len() == DEK_LEN
153    {
154        return Dek::from_bytes(bytes).map_err(|err| err.to_string());
155    }
156    Err(format!(
157        "is not a {DEK_LEN}-byte key: expected {DEK_LEN} raw bytes, {} hex characters, or base64",
158        DEK_LEN * 2
159    ))
160}
161
162fn decode_hex(text: &str) -> Result<Vec<u8>, ()> {
163    let (pairs, rest) = text.as_bytes().as_chunks::<2>();
164    if !rest.is_empty() {
165        return Err(());
166    }
167    let mut out = Vec::with_capacity(pairs.len());
168    for pair in pairs {
169        let hi = (pair[0] as char).to_digit(16).ok_or(())?;
170        let lo = (pair[1] as char).to_digit(16).ok_or(())?;
171        out.push(u8::try_from(hi * 16 + lo).map_err(|_| ())?);
172    }
173    Ok(out)
174}
175
176#[async_trait]
177impl Kms for LocalFileKms {
178    fn provider(&self) -> &'static str {
179        "local-file"
180    }
181
182    fn key_ref(&self) -> &str {
183        &self.key_ref
184    }
185
186    async fn wrap(&self, dek: &Dek) -> Result<Vec<u8>, KmsError> {
187        let cipher = self.cipher()?;
188        let mut nonce = [0_u8; NONCE_LEN];
189        getrandom::fill(&mut nonce)
190            .map_err(|err| KmsError::Unavailable(format!("the OS random source failed: {err}")))?;
191        let sealed = cipher
192            .encrypt(
193                (&nonce).into(),
194                Payload {
195                    msg: dek.expose(),
196                    aad: WRAP_AAD,
197                },
198            )
199            .map_err(|_| KmsError::Invalid("the data key could not be wrapped".to_owned()))?;
200        let mut out = Vec::with_capacity(NONCE_LEN + sealed.len());
201        out.extend_from_slice(&nonce);
202        out.extend_from_slice(&sealed);
203        Ok(out)
204    }
205
206    async fn unwrap(&self, wrapped: &[u8]) -> Result<Dek, KmsError> {
207        if wrapped.len() <= NONCE_LEN {
208            return Err(KmsError::Tampered(
209                "the wrapped key is too short to contain a nonce and a tag".to_owned(),
210            ));
211        }
212        let (nonce, sealed) = wrapped.split_at(NONCE_LEN);
213        let nonce: &[u8; NONCE_LEN] = nonce.try_into().expect("split at NONCE_LEN");
214        let opened = self
215            .cipher()?
216            .decrypt(
217                nonce.into(),
218                Payload {
219                    msg: sealed,
220                    aad: WRAP_AAD,
221                },
222            )
223            .map_err(|_| {
224                KmsError::Tampered(
225                    "the wrapped key did not authenticate under this master key".to_owned(),
226                )
227            })?;
228        Dek::from_bytes(opened)
229    }
230}
231
232impl LocalFileKms {
233    fn cipher(&self) -> Result<chacha20poly1305::XChaCha20Poly1305, KmsError> {
234        chacha20poly1305::XChaCha20Poly1305::new_from_slice(self.kek.expose())
235            .map_err(|_| KmsError::Invalid("the master key is not 32 bytes".to_owned()))
236    }
237}