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/// Keeps this Plugin's static factory registration linked into a Host binary.
25#[inline(never)]
26pub fn link() -> &'static str {
27    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    // A platform-specific no-follow open is required before this Provider can
245    // safely support another target. Never fall back to a check-then-open flow.
246    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}