boatramp_server/
envelope.rs1use 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
22const MAGIC: &[u8; 4] = b"BRK1";
25const KEK_LEN: usize = 32;
27
28pub struct LocalKek {
30 kek: [u8; KEK_LEN],
31}
32
33impl LocalKek {
34 pub fn from_bytes(kek: [u8; KEK_LEN]) -> Self {
36 Self { kek }
37 }
38
39 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
113pub struct VaultEnvelope {
119 client: reqwest::Client,
120 encrypt_url: String,
121 decrypt_url: String,
122 token: String,
123}
124
125impl VaultEnvelope {
126 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 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
195pub enum EnvelopeSpec {
198 None,
200 Local { kek_file: PathBuf },
202 Vault {
204 addr: String,
205 key: String,
206 token: String,
207 },
208}
209
210pub 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
221fn 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 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 let other = LocalKek::from_bytes([9u8; KEK_LEN]);
278 assert!(other.unwrap(&wrapped).await.is_err());
279
280 let mut tampered = wrapped.clone();
282 *tampered.last_mut().unwrap() ^= 0x01;
283 assert!(e.unwrap(&tampered).await.is_err());
284
285 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(); 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}