lenso_secrets_encrypted_file_plugin/
lib.rs1use std::{
4 collections::BTreeMap,
5 fmt,
6 fs::File,
7 io::Read,
8 iter,
9 path::{Path, PathBuf},
10};
11
12use age::secrecy::SecretString;
13use lenso::prelude::*;
14use lenso_capability_secrets::{self as secrets, ResolveError, ResolveRequest, ResolveResponse};
15use lenso_kernel::RuntimeFailure;
16use zeroize::{Zeroize, Zeroizing};
17
18const MAX_REFERENCE_LENGTH: usize = 256;
19const MAX_SOURCE_LENGTH: usize = 512;
20const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024;
21const MAX_PLAINTEXT_BYTES: usize = 16 * 1024 * 1024;
22const MAX_RECORDS: usize = 100_000;
23
24#[inline(never)]
26pub fn link() {
27 std::hint::black_box(PLUGIN_DESCRIPTOR_JSON);
28}
29
30#[derive(Clone, Debug, serde::Deserialize)]
31#[serde(deny_unknown_fields)]
32struct EncryptedFileConfig {
33 path: PathBuf,
34 key_environment_variable: String,
35 #[serde(deserialize_with = "deserialize_unique_references")]
36 references: BTreeMap<String, String>,
37 max_file_bytes: u64,
38 max_plaintext_bytes: usize,
39 max_records: usize,
40}
41
42#[derive(serde::Deserialize)]
43#[serde(deny_unknown_fields)]
44struct FileDocument {
45 version: u32,
46 secrets: BTreeMap<String, String>,
47}
48
49impl Drop for FileDocument {
50 fn drop(&mut self) {
51 for value in self.secrets.values_mut() {
52 value.zeroize();
53 }
54 }
55}
56
57fn validate_config(config: &EncryptedFileConfig) -> Result<(), RuntimeFailure> {
58 if config.path.as_os_str().is_empty() {
59 return Err(invalid_plan("encrypted secret file path is empty"));
60 }
61 if !valid_environment_variable(&config.key_environment_variable) {
62 return Err(invalid_plan(
63 "encrypted secret file key environment variable is invalid",
64 ));
65 }
66 if config.references.is_empty() {
67 return Err(invalid_plan(
68 "encrypted secret file references must contain at least one mapping",
69 ));
70 }
71 for (reference, source) in &config.references {
72 if !valid_reference(reference) {
73 return Err(invalid_plan(
74 "encrypted secret file logical reference is invalid",
75 ));
76 }
77 if !valid_source_name(source) {
78 return Err(invalid_plan("encrypted secret file source name is invalid"));
79 }
80 }
81 if !(1..=MAX_FILE_BYTES).contains(&config.max_file_bytes) {
82 return Err(invalid_plan(
83 "max_file_bytes must be between 1 and 67108864",
84 ));
85 }
86 if !(1..=MAX_PLAINTEXT_BYTES).contains(&config.max_plaintext_bytes) {
87 return Err(invalid_plan(
88 "max_plaintext_bytes must be between 1 and 16777216",
89 ));
90 }
91 if !(1..=MAX_RECORDS).contains(&config.max_records) {
92 return Err(invalid_plan("max_records must be between 1 and 100000"));
93 }
94 Ok(())
95}
96
97#[lenso::plugin(
98 lifecycle,
99 configuration_schema = "config.schema.json",
100 validate = validate_config
101)]
102#[derive(Clone, Debug)]
103struct EncryptedFileSecretsPlugin {
104 #[config]
105 config: EncryptedFileConfig,
106}
107
108impl Lifecycle for EncryptedFileSecretsPlugin {
109 fn prepare(
110 &self,
111 _context: PrepareContext,
112 ) -> impl std::future::Future<Output = Result<(), RuntimeFailure>> {
113 std::future::ready(verify_sources(&self.config, &EnvironmentKeySource))
114 }
115}
116
117#[lenso::provides(secrets::Secrets)]
118impl EncryptedFileSecretsPlugin {
119 fn resolve(
120 &self,
121 _context: Ctx,
122 request: ResolveRequest,
123 ) -> impl std::future::Future<Output = PluginResult<ResolveResponse, ResolveError>> {
124 let ResolveRequest { reference } = request;
125 futures::future::ready(resolve(&self.config, &EnvironmentKeySource, &reference))
126 }
127}
128
129fn resolve(
130 config: &EncryptedFileConfig,
131 key_source: &dyn KeySource,
132 reference: &str,
133) -> PluginResult<ResolveResponse, ResolveError> {
134 if !valid_reference(reference) {
135 return Err(PluginError::domain(ResolveError::InvalidReference));
136 }
137 let source = config
138 .references
139 .get(reference)
140 .ok_or_else(|| PluginError::domain(ResolveError::UnknownReference))?;
141 let mut document = load_document(config, key_source).map_err(|()| {
142 PluginError::runtime(RuntimeFailure::PluginFailure {
143 detail: format!(
144 "configured encrypted-file secret reference `{reference}` is unavailable"
145 ),
146 })
147 })?;
148 let value = document.secrets.remove(source).ok_or_else(|| {
149 PluginError::runtime(RuntimeFailure::PluginFailure {
150 detail: format!(
151 "configured encrypted-file secret reference `{reference}` is unavailable"
152 ),
153 })
154 })?;
155 let value = Zeroizing::new(value);
156 Ok(ResolveResponse {
157 value: value.as_str().to_owned(),
158 })
159}
160
161fn verify_sources(
162 config: &EncryptedFileConfig,
163 key_source: &dyn KeySource,
164) -> Result<(), RuntimeFailure> {
165 let document =
166 load_document(config, key_source).map_err(|()| RuntimeFailure::PluginFailure {
167 detail: "configured encrypted secret file is unavailable".to_owned(),
168 })?;
169 for (reference, source) in &config.references {
170 if !document.secrets.contains_key(source) {
171 return Err(RuntimeFailure::PluginFailure {
172 detail: format!(
173 "configured encrypted-file secret reference `{reference}` is unavailable"
174 ),
175 });
176 }
177 }
178 Ok(())
179}
180
181fn load_document(
182 config: &EncryptedFileConfig,
183 key_source: &dyn KeySource,
184) -> Result<FileDocument, ()> {
185 let file = open_encrypted_file(&config.path)?;
186 load_document_from_file(config, key_source, file)
187}
188
189fn load_document_from_file(
190 config: &EncryptedFileConfig,
191 key_source: &dyn KeySource,
192 mut file: File,
193) -> Result<FileDocument, ()> {
194 let metadata = file.metadata().map_err(|_| ())?;
195 if !metadata.file_type().is_file() {
196 return Err(());
197 }
198 if metadata.len() == 0 || metadata.len() > config.max_file_bytes {
199 return Err(());
200 }
201 let ciphertext = read_bounded(&mut file, config.max_file_bytes)?;
202 let passphrase = key_source.read(&config.key_environment_variable)?;
203 let decryptor = age::Decryptor::new(ciphertext.as_slice()).map_err(|_| ())?;
204 let identity = age::scrypt::Identity::new(passphrase);
205 let mut reader = decryptor
206 .decrypt(iter::once(&identity as &dyn age::Identity))
207 .map_err(|_| ())?;
208 let mut plaintext = Zeroizing::new(Vec::new());
209 reader
210 .by_ref()
211 .take(config.max_plaintext_bytes as u64 + 1)
212 .read_to_end(&mut plaintext)
213 .map_err(|_| ())?;
214 if plaintext.len() > config.max_plaintext_bytes {
215 return Err(());
216 }
217 let document = serde_json::from_slice::<FileDocument>(&plaintext).map_err(|_| ())?;
218 if document.version != 1
219 || document.secrets.is_empty()
220 || document.secrets.len() > config.max_records
221 || document
222 .secrets
223 .iter()
224 .any(|(name, value)| !valid_source_name(name) || value.is_empty())
225 {
226 return Err(());
227 }
228 Ok(document)
229}
230
231#[cfg(unix)]
232fn open_encrypted_file(path: &Path) -> Result<File, ()> {
233 use std::os::unix::fs::OpenOptionsExt as _;
234
235 std::fs::OpenOptions::new()
236 .read(true)
237 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK)
238 .open(path)
239 .map_err(|_| ())
240}
241
242#[cfg(not(unix))]
243fn open_encrypted_file(_path: &Path) -> Result<File, ()> {
244 Err(())
247}
248
249fn read_bounded(reader: &mut impl Read, max_bytes: u64) -> Result<Vec<u8>, ()> {
250 let mut bytes = Vec::new();
251 reader
252 .take(max_bytes.checked_add(1).ok_or(())?)
253 .read_to_end(&mut bytes)
254 .map_err(|_| ())?;
255 if bytes.is_empty() || u64::try_from(bytes.len()).map_err(|_| ())? > max_bytes {
256 return Err(());
257 }
258 Ok(bytes)
259}
260
261trait KeySource: fmt::Debug {
262 fn read(&self, name: &str) -> Result<SecretString, ()>;
263}
264
265#[derive(Debug)]
266struct EnvironmentKeySource;
267
268impl KeySource for EnvironmentKeySource {
269 fn read(&self, name: &str) -> Result<SecretString, ()> {
270 std::env::var(name).map(SecretString::from).map_err(|_| ())
271 }
272}
273
274fn valid_reference(reference: &str) -> bool {
275 !reference.is_empty()
276 && reference.len() <= MAX_REFERENCE_LENGTH
277 && !reference.starts_with('/')
278 && !reference.ends_with('/')
279 && !reference.contains("//")
280 && !reference.contains('\0')
281 && reference
282 .split('/')
283 .all(|segment| segment != "." && segment != "..")
284}
285
286fn valid_source_name(value: &str) -> bool {
287 !value.trim().is_empty() && value.len() <= MAX_SOURCE_LENGTH && !value.contains('\0')
288}
289
290fn valid_environment_variable(value: &str) -> bool {
291 let mut bytes = value.bytes();
292 matches!(bytes.next(), Some(b'A'..=b'Z' | b'_'))
293 && bytes.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
294}
295
296fn deserialize_unique_references<'de, D>(
297 deserializer: D,
298) -> Result<BTreeMap<String, String>, D::Error>
299where
300 D: serde::Deserializer<'de>,
301{
302 struct UniqueReferences;
303
304 impl<'de> serde::de::Visitor<'de> for UniqueReferences {
305 type Value = BTreeMap<String, String>;
306
307 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
308 formatter.write_str("a logical-reference to encrypted-file key map")
309 }
310
311 fn visit_map<A>(self, mut access: A) -> Result<Self::Value, A::Error>
312 where
313 A: serde::de::MapAccess<'de>,
314 {
315 let mut references = BTreeMap::new();
316 while let Some((reference, source)) = access.next_entry::<String, String>()? {
317 if references.insert(reference.clone(), source).is_some() {
318 return Err(serde::de::Error::custom(format!(
319 "duplicate logical secret reference `{reference}`"
320 )));
321 }
322 }
323 Ok(references)
324 }
325 }
326
327 deserializer.deserialize_map(UniqueReferences)
328}
329
330fn invalid_plan(detail: impl Into<String>) -> RuntimeFailure {
331 RuntimeFailure::InvalidResolvedPlan {
332 detail: detail.into(),
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use std::{
339 fs,
340 io::{Cursor, Write},
341 };
342
343 use super::*;
344
345 #[derive(Debug)]
346 struct FixedKeySource(&'static str);
347
348 impl KeySource for FixedKeySource {
349 fn read(&self, _name: &str) -> Result<SecretString, ()> {
350 Ok(SecretString::from(self.0.to_owned()))
351 }
352 }
353
354 fn write_document(path: &std::path::Path, passphrase: &str, value: &str) {
355 let plaintext = serde_json::json!({
356 "version": 1,
357 "secrets": { "openai": value }
358 })
359 .to_string();
360 let encryptor =
361 age::Encryptor::with_user_passphrase(SecretString::from(passphrase.to_owned()));
362 let mut ciphertext = Vec::new();
363 let mut writer = encryptor.wrap_output(&mut ciphertext).unwrap();
364 writer.write_all(plaintext.as_bytes()).unwrap();
365 writer.finish().unwrap();
366 fs::write(path, ciphertext).unwrap();
367 }
368
369 fn config(path: PathBuf) -> EncryptedFileConfig {
370 EncryptedFileConfig {
371 path,
372 key_environment_variable: "LENSO_SECRETS_FILE_PASSPHRASE".to_owned(),
373 references: BTreeMap::from([("model/openai-api-key".to_owned(), "openai".to_owned())]),
374 max_file_bytes: 1024 * 1024,
375 max_plaintext_bytes: 1024 * 1024,
376 max_records: 100,
377 }
378 }
379
380 #[test]
381 fn descriptor_exposes_one_secrets_provider() {
382 let descriptor: serde_json::Value = serde_json::from_str(PLUGIN_DESCRIPTOR_JSON).unwrap();
383 assert_eq!(descriptor["plugin_id"], "lenso.secrets.encrypted-file");
384 assert_eq!(
385 descriptor["provided_capabilities"][0]["capability_id"],
386 "lenso.secrets@1"
387 );
388 }
389
390 #[test]
391 fn resolves_rotation_from_a_standard_age_container_without_debug_leakage() {
392 let directory = tempfile::tempdir().unwrap();
393 let path = directory.path().join("secrets.age");
394 write_document(&path, "correct horse battery staple", "first-secret");
395 let config = config(path.clone());
396 let source = FixedKeySource("correct horse battery staple");
397 verify_sources(&config, &source).unwrap();
398 let first = resolve(&config, &source, "model/openai-api-key").unwrap();
399 assert_eq!(first.value, "first-secret");
400 assert!(!format!("{first:?}").contains("first-secret"));
401
402 write_document(&path, "correct horse battery staple", "rotated-secret");
403 let rotated = resolve(&config, &source, "model/openai-api-key").unwrap();
404 assert_eq!(rotated.value, "rotated-secret");
405 }
406
407 #[test]
408 fn wrong_key_tampering_and_missing_record_fail_without_secret_details() {
409 let directory = tempfile::tempdir().unwrap();
410 let path = directory.path().join("secrets.age");
411 write_document(&path, "correct passphrase", "never-log-this");
412 let config = config(path.clone());
413 let wrong = verify_sources(&config, &FixedKeySource("wrong passphrase")).unwrap_err();
414 assert!(!format!("{wrong:?}").contains("never-log-this"));
415
416 fs::write(&path, b"tampered").unwrap();
417 let tampered = verify_sources(&config, &FixedKeySource("correct passphrase")).unwrap_err();
418 assert!(!format!("{tampered:?}").contains("correct passphrase"));
419 }
420
421 #[test]
422 fn rejects_symlinks_unknown_references_and_invalid_limits() {
423 let directory = tempfile::tempdir().unwrap();
424 let target = directory.path().join("target.age");
425 write_document(&target, "passphrase", "secret");
426 let mut config = config(target.clone());
427 assert!(matches!(
428 resolve(&config, &FixedKeySource("passphrase"), "unknown/reference"),
429 Err(PluginError::Domain(ResolveError::UnknownReference))
430 ));
431 config.max_records = 0;
432 assert!(validate_config(&config).is_err());
433
434 #[cfg(unix)]
435 {
436 use std::os::unix::fs::symlink;
437 let link = directory.path().join("link.age");
438 symlink(&target, &link).unwrap();
439 config.max_records = 100;
440 config.path = link;
441 assert!(verify_sources(&config, &FixedKeySource("passphrase")).is_err());
442 }
443 }
444
445 #[test]
446 fn rejects_an_oversized_encrypted_file() {
447 let directory = tempfile::tempdir().unwrap();
448 let path = directory.path().join("oversized.age");
449 fs::write(&path, [0_u8; 33]).unwrap();
450 let mut config = config(path);
451 config.max_file_bytes = 32;
452
453 assert!(verify_sources(&config, &FixedKeySource("passphrase")).is_err());
454 }
455
456 #[cfg(unix)]
457 #[test]
458 fn rejects_a_fifo_immediately_with_a_sanitized_failure() {
459 let directory = tempfile::tempdir().unwrap();
460 let path = directory.path().join("secrets.pipe");
461 let status = std::process::Command::new("mkfifo")
462 .arg(&path)
463 .status()
464 .unwrap();
465 assert!(status.success());
466
467 let failure = verify_sources(&config(path), &FixedKeySource("passphrase")).unwrap_err();
468 assert!(matches!(
469 failure,
470 RuntimeFailure::PluginFailure { ref detail }
471 if detail == "configured encrypted secret file is unavailable"
472 ));
473 }
474
475 #[test]
476 fn bounded_reader_consumes_at_most_the_limit_plus_one() {
477 let mut reader = Cursor::new(vec![0_u8; 4096]);
478
479 assert!(read_bounded(&mut reader, 32).is_err());
480 assert_eq!(reader.position(), 33);
481 }
482
483 #[cfg(unix)]
484 #[test]
485 fn an_open_handle_is_not_redirected_by_path_replacement() {
486 let directory = tempfile::tempdir().unwrap();
487 let path = directory.path().join("secrets.age");
488 let replacement = directory.path().join("replacement.age");
489 write_document(&path, "passphrase", "first-value");
490 write_document(&replacement, "passphrase", "replacement-value");
491 let config = config(path.clone());
492
493 let handle = open_encrypted_file(&path).unwrap();
494 fs::rename(&replacement, &path).unwrap();
495 let document =
496 load_document_from_file(&config, &FixedKeySource("passphrase"), handle).unwrap();
497
498 assert_eq!(document.secrets.get("openai").unwrap(), "first-value");
499 }
500}