Skip to main content

lenso_secrets_encrypted_file_plugin/
lib.rs

1//! Age-encrypted local-file Secrets Provider Plugin.
2
3use 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    // A platform-specific no-follow open is required before this Provider can
239    // safely support another target. Never fall back to a check-then-open flow.
240    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}