Skip to main content

boatramp_server/
envelope.rs

1//! Secrets-at-rest envelope backends.
2//!
3//! Concrete [`KeyEnvelope`](boatramp_core::envelope::KeyEnvelope) implementations
4//! — kept out of the wasm-clean core because they pull crypto/HTTP deps.
5//! [`LocalKek`] wraps with AES-256-GCM under a machine-local key-encryption key.
6//!
7//! **Cluster note:** cert private keys are wrapped in the *replicated* KV and
8//! read by every node, so a local KEK must be the **same file on every node**
9//! (the operator distributes it). A central KMS (Vault) avoids that by having
10//! every node unwrap through the same service.
11
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15use async_trait::async_trait;
16use aws_lc_rs::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM, NONCE_LEN};
17use aws_lc_rs::rand::{SecureRandom, SystemRandom};
18use base64::Engine as _;
19use boatramp_core::envelope::{EnvelopeError, KeyEnvelope};
20use serde::Deserialize;
21
22/// Wire-format tag: `b"BRK1"`. A wrapped blob is `MAGIC || nonce(12) ||
23/// ciphertext+tag`, so `unwrap` fail-closes on a foreign or truncated blob.
24const MAGIC: &[u8; 4] = b"BRK1";
25/// AES-256 key length.
26const KEK_LEN: usize = 32;
27
28/// AES-256-GCM envelope under a machine-local key-encryption key (KEK).
29pub struct LocalKek {
30    kek: [u8; KEK_LEN],
31}
32
33impl LocalKek {
34    /// Build from a raw 32-byte KEK.
35    pub fn from_bytes(kek: [u8; KEK_LEN]) -> Self {
36        Self { kek }
37    }
38
39    /// Load the KEK from `path` (raw 32 bytes), generating + persisting one
40    /// (`0600`) if the file does not exist. In a cluster the **same** file must
41    /// be present on every node (wrapped certs replicate).
42    pub fn load_or_generate(path: &Path) -> Result<Self, EnvelopeError> {
43        match std::fs::read(path) {
44            Ok(bytes) => {
45                let kek: [u8; KEK_LEN] = bytes
46                    .as_slice()
47                    .try_into()
48                    .map_err(|_| EnvelopeError::new("KEK file must be exactly 32 bytes"))?;
49                Ok(Self::from_bytes(kek))
50            }
51            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
52                let mut kek = [0u8; KEK_LEN];
53                SystemRandom::new()
54                    .fill(&mut kek)
55                    .map_err(|_| EnvelopeError::new("generating KEK"))?;
56                write_private_file(path, &kek).map_err(|e| {
57                    EnvelopeError::new(format!("writing KEK {}: {e}", path.display()))
58                })?;
59                Ok(Self::from_bytes(kek))
60            }
61            Err(e) => Err(EnvelopeError::new(format!(
62                "reading KEK {}: {e}",
63                path.display()
64            ))),
65        }
66    }
67
68    fn key(&self) -> Result<LessSafeKey, EnvelopeError> {
69        let unbound = UnboundKey::new(&AES_256_GCM, &self.kek)
70            .map_err(|_| EnvelopeError::new("invalid KEK"))?;
71        Ok(LessSafeKey::new(unbound))
72    }
73}
74
75#[async_trait]
76impl KeyEnvelope for LocalKek {
77    async fn wrap(&self, plaintext: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
78        let key = self.key()?;
79        let mut nonce_bytes = [0u8; NONCE_LEN];
80        SystemRandom::new()
81            .fill(&mut nonce_bytes)
82            .map_err(|_| EnvelopeError::new("generating nonce"))?;
83        let nonce = Nonce::assume_unique_for_key(nonce_bytes);
84        let mut in_out = plaintext.to_vec();
85        key.seal_in_place_append_tag(nonce, Aad::empty(), &mut in_out)
86            .map_err(|_| EnvelopeError::new("seal failed"))?;
87        let mut out = Vec::with_capacity(MAGIC.len() + NONCE_LEN + in_out.len());
88        out.extend_from_slice(MAGIC);
89        out.extend_from_slice(&nonce_bytes);
90        out.extend_from_slice(&in_out);
91        Ok(out)
92    }
93
94    async fn unwrap(&self, wrapped: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
95        let rest = wrapped
96            .strip_prefix(MAGIC.as_slice())
97            .ok_or_else(|| EnvelopeError::new("not a local-KEK blob (bad magic)"))?;
98        if rest.len() < NONCE_LEN {
99            return Err(EnvelopeError::new("truncated blob"));
100        }
101        let (nonce_bytes, ciphertext) = rest.split_at(NONCE_LEN);
102        let nonce = Nonce::try_assume_unique_for_key(nonce_bytes)
103            .map_err(|_| EnvelopeError::new("bad nonce"))?;
104        let key = self.key()?;
105        let mut in_out = ciphertext.to_vec();
106        let plaintext = key
107            .open_in_place(nonce, Aad::empty(), &mut in_out)
108            .map_err(|_| EnvelopeError::new("unwrap failed (wrong key or tampered)"))?;
109        Ok(plaintext.to_vec())
110    }
111}
112
113/// A [`KeyEnvelope`] backed by **Vault's Transit** engine: `wrap`/
114/// `unwrap` are `transit/encrypt|decrypt/<key>` round-trips, so the KEK never
115/// leaves Vault and every cluster node unwraps through the same service (no
116/// shared local key file). Any KMS exposing a Vault-compatible Transit API works.
117/// The token is passed in from the environment (never stored in config files).
118pub struct VaultEnvelope {
119    client: reqwest::Client,
120    encrypt_url: String,
121    decrypt_url: String,
122    token: String,
123}
124
125impl VaultEnvelope {
126    /// Configure against `addr` (e.g. `https://vault:8200`), Transit key `key`,
127    /// and a Vault `token`.
128    pub fn new(addr: &str, key: &str, token: String) -> Self {
129        let base = addr.trim_end_matches('/');
130        Self {
131            client: reqwest::Client::new(),
132            encrypt_url: format!("{base}/v1/transit/encrypt/{key}"),
133            decrypt_url: format!("{base}/v1/transit/decrypt/{key}"),
134            token,
135        }
136    }
137}
138
139#[derive(Deserialize)]
140struct VaultData<T> {
141    data: T,
142}
143#[derive(Deserialize)]
144struct VaultCiphertext {
145    ciphertext: String,
146}
147#[derive(Deserialize)]
148struct VaultPlaintext {
149    plaintext: String,
150}
151
152#[async_trait]
153impl KeyEnvelope for VaultEnvelope {
154    async fn wrap(&self, plaintext: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
155        let b64 = base64::engine::general_purpose::STANDARD.encode(plaintext);
156        let resp: VaultData<VaultCiphertext> = self
157            .client
158            .post(&self.encrypt_url)
159            .header("X-Vault-Token", &self.token)
160            .json(&serde_json::json!({ "plaintext": b64 }))
161            .send()
162            .await
163            .map_err(|e| EnvelopeError::new(format!("vault encrypt: {e}")))?
164            .error_for_status()
165            .map_err(|e| EnvelopeError::new(format!("vault encrypt: {e}")))?
166            .json()
167            .await
168            .map_err(|e| EnvelopeError::new(format!("vault encrypt decode: {e}")))?;
169        // Vault's `vault:vN:...` ciphertext string is the opaque wrapped blob.
170        Ok(resp.data.ciphertext.into_bytes())
171    }
172
173    async fn unwrap(&self, wrapped: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
174        let ciphertext =
175            std::str::from_utf8(wrapped).map_err(|e| EnvelopeError::new(e.to_string()))?;
176        let resp: VaultData<VaultPlaintext> = self
177            .client
178            .post(&self.decrypt_url)
179            .header("X-Vault-Token", &self.token)
180            .json(&serde_json::json!({ "ciphertext": ciphertext }))
181            .send()
182            .await
183            .map_err(|e| EnvelopeError::new(format!("vault decrypt: {e}")))?
184            .error_for_status()
185            .map_err(|e| EnvelopeError::new(format!("vault decrypt: {e}")))?
186            .json()
187            .await
188            .map_err(|e| EnvelopeError::new(format!("vault decrypt decode: {e}")))?;
189        base64::engine::general_purpose::STANDARD
190            .decode(resp.data.plaintext.trim())
191            .map_err(|e| EnvelopeError::new(format!("vault plaintext base64: {e}")))
192    }
193}
194
195/// A resolved secrets-at-rest envelope choice (the caller maps its config to
196/// this, resolving any Vault token from the environment — never from a file).
197pub enum EnvelopeSpec {
198    /// No wrapping — secrets stored cleartext (single-node dev / opt-out).
199    None,
200    /// Machine-local AES-256-GCM KEK at `kek_file`.
201    Local { kek_file: PathBuf },
202    /// Vault Transit `key` at `addr`, authenticated by `token`.
203    Vault {
204        addr: String,
205        key: String,
206        token: String,
207    },
208}
209
210/// Build the configured [`KeyEnvelope`], or `None` for cleartext.
211pub fn build_envelope(spec: EnvelopeSpec) -> Result<Option<Arc<dyn KeyEnvelope>>, EnvelopeError> {
212    Ok(match spec {
213        EnvelopeSpec::None => None,
214        EnvelopeSpec::Local { kek_file } => Some(Arc::new(LocalKek::load_or_generate(&kek_file)?)),
215        EnvelopeSpec::Vault { addr, key, token } => {
216            Some(Arc::new(VaultEnvelope::new(&addr, &key, token)))
217        }
218    })
219}
220
221/// Write `bytes` to `path` with `0600` permissions on unix (owner-only).
222fn write_private_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
223    if let Some(parent) = path.parent() {
224        std::fs::create_dir_all(parent)?;
225    }
226    #[cfg(unix)]
227    {
228        use std::io::Write;
229        use std::os::unix::fs::OpenOptionsExt;
230        let mut f = std::fs::OpenOptions::new()
231            .write(true)
232            .create(true)
233            .truncate(true)
234            .mode(0o600)
235            .open(path)?;
236        f.write_all(bytes)
237    }
238    #[cfg(not(unix))]
239    {
240        std::fs::write(path, bytes)
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    fn kek() -> LocalKek {
249        LocalKek::from_bytes([7u8; KEK_LEN])
250    }
251
252    #[tokio::test]
253    async fn wrap_unwrap_round_trips() {
254        let e = kek();
255        let secret = b"-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----";
256        let wrapped = e.wrap(secret).await.unwrap();
257        // The blob is tagged and doesn't contain the plaintext.
258        assert!(wrapped.starts_with(MAGIC));
259        assert!(!wrapped.windows(6).any(|w| w == b"secret"));
260        assert_eq!(e.unwrap(&wrapped).await.unwrap(), secret);
261    }
262
263    #[tokio::test]
264    async fn nonce_is_random_so_ciphertext_differs() {
265        let e = kek();
266        let a = e.wrap(b"same").await.unwrap();
267        let b = e.wrap(b"same").await.unwrap();
268        assert_ne!(a, b, "each wrap must use a fresh nonce");
269    }
270
271    #[tokio::test]
272    async fn wrong_key_and_tamper_are_rejected() {
273        let e = kek();
274        let wrapped = e.wrap(b"data").await.unwrap();
275
276        // A different KEK cannot unwrap.
277        let other = LocalKek::from_bytes([9u8; KEK_LEN]);
278        assert!(other.unwrap(&wrapped).await.is_err());
279
280        // Flipping a ciphertext byte breaks the GCM tag.
281        let mut tampered = wrapped.clone();
282        *tampered.last_mut().unwrap() ^= 0x01;
283        assert!(e.unwrap(&tampered).await.is_err());
284
285        // A foreign blob (bad magic) is refused.
286        assert!(e.unwrap(b"XXXXnonsense").await.is_err());
287    }
288
289    #[test]
290    fn vault_builds_transit_urls_trimming_trailing_slash() {
291        let v = VaultEnvelope::new("https://vault:8200/", "boatramp-certs", "tok".into());
292        assert_eq!(
293            v.encrypt_url,
294            "https://vault:8200/v1/transit/encrypt/boatramp-certs"
295        );
296        assert_eq!(
297            v.decrypt_url,
298            "https://vault:8200/v1/transit/decrypt/boatramp-certs"
299        );
300    }
301
302    #[tokio::test]
303    async fn key_file_round_trips_and_is_stable() {
304        let dir = std::env::temp_dir().join(format!("brk-test-{}", std::process::id()));
305        let path = dir.join("kek");
306        let _ = std::fs::remove_file(&path);
307        let a = LocalKek::load_or_generate(&path).unwrap();
308        let b = LocalKek::load_or_generate(&path).unwrap(); // loads the same key
309        let wrapped = a.wrap(b"x").await.unwrap();
310        assert_eq!(b.unwrap(&wrapped).await.unwrap(), b"x");
311        #[cfg(unix)]
312        {
313            use std::os::unix::fs::PermissionsExt;
314            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
315            assert_eq!(mode & 0o777, 0o600, "KEK file must be 0600");
316        }
317        let _ = std::fs::remove_dir_all(&dir);
318    }
319}