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