Skip to main content

cellos_broker_file/
lib.rs

1//! [`SecretBroker`] that reads secret values from filesystem paths.
2//!
3//! # Motivation
4//!
5//! Many production runtimes deliver secrets as files rather than environment variables:
6//! - **Kubernetes** mounted secrets (`/run/secrets/<name>` or a volume mount path)
7//! - **Docker secrets** (`/run/secrets/<name>`)
8//! - **systemd credentials** (`/run/credentials/<unit>/name`)
9//! - **HashiCorp Vault Agent** writing rendered templates to tmpfs
10//! - **CI systems** injecting secrets into a tmpfs path before the job starts
11//!
12//! This broker maps each logical secret key to a file path via an environment variable,
13//! reads the file at resolve time, and returns its contents as a [`SecretView`].
14//!
15//! # Configuration
16//!
17//! For each secret key, set:
18//! ```text
19//! CELLOS_SECRET_FILE_<UPPER_KEY>=<path>
20//! ```
21//!
22//! The key is uppercased and hyphens are replaced with underscores to form the env var suffix.
23//!
24//! **Examples:**
25//! ```text
26//! CELLOS_SECRET_FILE_DB_PASSWORD=/run/secrets/db-password
27//! CELLOS_SECRET_FILE_API_TOKEN=/run/credentials/myapp.service/api-token
28//! ```
29//!
30//! These would resolve for `secretRefs: ["DB_PASSWORD", "API_TOKEN"]` in the cell spec.
31//!
32//! # Security properties
33//!
34//! - File path is operator-controlled via env var — the broker reads only the configured path.
35//! - No persistent in-process state. Credentials are not cached between resolve calls.
36//! - Contents are returned as [`SecretView`] which is `ZeroizeOnDrop` — bytes are overwritten
37//!   when the supervisor is done with them, before export or destroy phases.
38//! - Trailing newlines are stripped (standard file-based secret convention).
39//! - **`O_NOFOLLOW` on the final path component (Unix)** — if an attacker with write
40//!   access to the parent directory swaps the configured secret file for a symlink
41//!   to `/etc/shadow`, the open errors out instead of exfiltrating the target.
42//!   Mirrors `cellos-core::trust_keys::load_trust_verify_keys_file` (SEC-15b).
43//!   Windows lacks an `O_NOFOLLOW` analogue in `std`, so it falls back to
44//!   `tokio::fs::read_to_string`.
45//!
46//! # Revocation
47//!
48//! `revoke_for_cell` is a documented no-op: this broker holds no persistent state between
49//! calls. Isolation relies on the cell model's teardown semantics (cleared subprocess env,
50//! short TTLs) and the file's own lifecycle on the host.
51//!
52//! # Correlation propagation (Tranche-1 seam-freeze G1)
53//!
54//! Filesystem secrets are stamped before the supervisor starts (Kubernetes /
55//! systemd / Vault Agent / CI) so this broker has no upstream session of its
56//! own and returns `None` from [`SecretBroker::broker_correlation_id`]. The
57//! supervisor falls back to the operator-supplied
58//! `spec.correlation.correlationId` for cross-tool correlation in that case.
59
60use async_trait::async_trait;
61use cellos_core::ports::SecretBroker;
62use cellos_core::{CellosError, SecretView};
63use tracing::instrument;
64
65/// Resolves secrets from filesystem files using `CELLOS_SECRET_FILE_<UPPER_KEY>=<path>`.
66///
67/// The file at the configured path is read at each `resolve` call — not cached.
68pub struct FileSecretBroker;
69
70impl FileSecretBroker {
71    pub fn new() -> Self {
72        Self
73    }
74
75    /// Returns the env var name that configures the file path for a given key.
76    ///
77    /// `"DB_PASSWORD"` → `"CELLOS_SECRET_FILE_DB_PASSWORD"`
78    pub fn path_env_var(key: &str) -> String {
79        format!(
80            "CELLOS_SECRET_FILE_{}",
81            key.to_uppercase().replace('-', "_")
82        )
83    }
84}
85
86impl Default for FileSecretBroker {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92/// Read the secret file with `O_NOFOLLOW` on the final path component (Unix).
93///
94/// Rationale (BFILE-VAL): an attacker with write access to the directory
95/// containing the operator-configured secret file can swap the file for a
96/// symlink pointing at `/etc/shadow` or any other readable file the
97/// supervisor process has access to. `tokio::fs::read_to_string` follows
98/// symlinks transparently, which would silently exfiltrate the target.
99/// Opening with `O_NOFOLLOW` makes that swap fail with `ELOOP` instead.
100///
101/// Mirrors the pattern already used in
102/// `cellos-core::trust_keys::load_trust_verify_keys_file` and
103/// `cellos-supervisor::spec_input` (SEC-15b). We deliberately avoid a
104/// `libc` dependency and hard-code the kernel ABI constant — this is the
105/// same trade-off cellos-core makes; adding a new Unix variant is a
106/// one-line change here, not a libc-crate refactor.
107///
108/// On non-Unix targets (Windows) `std` has no `O_NOFOLLOW` analogue, so we
109/// fall back to a plain async read. This matches every other trust-path
110/// loader in the workspace.
111async fn read_secret_file(path: &str) -> std::io::Result<String> {
112    #[cfg(unix)]
113    {
114        use std::io::Read;
115        use std::os::unix::fs::OpenOptionsExt;
116
117        // O_NOFOLLOW value is platform-specific.
118        //   - Linux:                   0x20000  (asm-generic/fcntl.h)
119        //   - macOS / *BSD:            0x100    (sys/fcntl.h)
120        // Using the wrong constant would silently map to a different flag
121        // (on Linux 0x100 is `O_NOCTTY`, which would not refuse a symlink),
122        // so this MUST stay accurate per platform.
123        #[cfg(target_os = "linux")]
124        const O_NOFOLLOW: i32 = 0x20000;
125        #[cfg(any(
126            target_os = "macos",
127            target_os = "ios",
128            target_os = "freebsd",
129            target_os = "netbsd",
130            target_os = "openbsd",
131            target_os = "dragonfly",
132        ))]
133        const O_NOFOLLOW: i32 = 0x100;
134        #[cfg(not(any(
135            target_os = "linux",
136            target_os = "macos",
137            target_os = "ios",
138            target_os = "freebsd",
139            target_os = "netbsd",
140            target_os = "openbsd",
141            target_os = "dragonfly",
142        )))]
143        compile_error!(
144            "cellos-broker-file: O_NOFOLLOW value not yet defined for this Unix target — \
145             add the platform-specific value (see <fcntl.h>) before building."
146        );
147
148        // Synchronous open + read inside `spawn_blocking` keeps the
149        // O_NOFOLLOW semantics (tokio::fs::OpenOptions has no
150        // `custom_flags` shim) without leaking blocking I/O onto the
151        // async runtime. Secret files are tiny (single-line tokens or
152        // small PEMs), so this is comparable in cost to the previous
153        // `tokio::fs::read_to_string` call.
154        let path_owned = path.to_string();
155        tokio::task::spawn_blocking(move || {
156            let mut opts = std::fs::OpenOptions::new();
157            opts.read(true);
158            opts.custom_flags(O_NOFOLLOW);
159            let mut file = opts.open(&path_owned)?;
160            let mut buf = String::new();
161            file.read_to_string(&mut buf)?;
162            Ok::<String, std::io::Error>(buf)
163        })
164        .await
165        .map_err(|join_err| std::io::Error::other(format!("spawn_blocking join: {join_err}")))?
166    }
167    #[cfg(not(unix))]
168    {
169        tokio::fs::read_to_string(path).await
170    }
171}
172
173#[async_trait]
174impl SecretBroker for FileSecretBroker {
175    #[instrument(skip(self), fields(key = %key, cell_id = %cell_id))]
176    async fn resolve(
177        &self,
178        key: &str,
179        cell_id: &str,
180        _ttl_seconds: u64,
181    ) -> Result<SecretView, CellosError> {
182        // BFILE-VAL: reject keys that cannot form a legal env var lookup. These
183        // pre-checks are defence in depth — `std::env::var` will already error
184        // on most of these via `NulError` / "not present", but pinning the
185        // failure mode here keeps the contract observable and the error message
186        // attributable to the broker layer.
187        if key.is_empty() {
188            return Err(CellosError::SecretBroker(
189                "secret key must not be empty".into(),
190            ));
191        }
192        if key.contains('\0') {
193            return Err(CellosError::SecretBroker(
194                "secret key must not contain NUL bytes".into(),
195            ));
196        }
197
198        let env_var = Self::path_env_var(key);
199        let path = std::env::var(&env_var).map_err(|_| {
200            CellosError::SecretBroker(format!(
201                "env var {env_var} not set (no file path configured for secret key {key:?})"
202            ))
203        })?;
204
205        let raw = read_secret_file(&path).await.map_err(|e| {
206            CellosError::SecretBroker(format!("read secret file for key {key:?} at {path:?}: {e}"))
207        })?;
208
209        // Strip exactly one trailing newline, consistent with how most secret writers work.
210        let value = raw.strip_suffix('\n').unwrap_or(&raw).to_string();
211
212        if value.is_empty() {
213            tracing::warn!(
214                key = %key,
215                path = %path,
216                "secret file is empty after trim"
217            );
218        }
219
220        Ok(SecretView {
221            key: key.to_string(),
222            value: zeroize::Zeroizing::new(value),
223        })
224    }
225
226    /// No-op — this broker holds no persistent state between calls.
227    /// The file on disk is managed by the operator's secret delivery mechanism.
228    async fn revoke_for_cell(&self, _cell_id: &str) -> Result<(), CellosError> {
229        Ok(())
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use std::io::Write;
237    use tempfile::NamedTempFile;
238
239    #[tokio::test]
240    async fn resolves_secret_from_file() {
241        let mut f = NamedTempFile::new().unwrap();
242        write!(f, "super-secret-value").unwrap();
243
244        let env_var = FileSecretBroker::path_env_var("DB_PASSWORD");
245        std::env::set_var(&env_var, f.path().to_str().unwrap());
246
247        let broker = FileSecretBroker::new();
248        let view = broker.resolve("DB_PASSWORD", "cell-1", 60).await.unwrap();
249
250        std::env::remove_var(&env_var);
251        assert_eq!(view.key, "DB_PASSWORD");
252        assert_eq!(view.value.as_str(), "super-secret-value");
253    }
254
255    #[tokio::test]
256    async fn strips_trailing_newline() {
257        let mut f = NamedTempFile::new().unwrap();
258        writeln!(f, "token-value").unwrap(); // writeln adds \n
259
260        let env_var = FileSecretBroker::path_env_var("API_TOKEN");
261        std::env::set_var(&env_var, f.path().to_str().unwrap());
262
263        let broker = FileSecretBroker::new();
264        let view = broker.resolve("API_TOKEN", "cell-1", 60).await.unwrap();
265
266        std::env::remove_var(&env_var);
267        assert_eq!(view.value.as_str(), "token-value"); // no trailing newline
268    }
269
270    #[tokio::test]
271    async fn normalizes_hyphenated_key() {
272        let mut f = NamedTempFile::new().unwrap();
273        write!(f, "my-secret").unwrap();
274
275        // hyphen in key → underscore in env var suffix
276        let env_var = FileSecretBroker::path_env_var("my-api-key");
277        std::env::set_var(&env_var, f.path().to_str().unwrap());
278
279        let broker = FileSecretBroker::new();
280        let view = broker.resolve("my-api-key", "cell-1", 60).await.unwrap();
281
282        std::env::remove_var(&env_var);
283        assert_eq!(view.value.as_str(), "my-secret");
284    }
285
286    #[tokio::test]
287    async fn errors_when_env_var_not_set() {
288        let env_var = FileSecretBroker::path_env_var("MISSING_KEY_XYZ");
289        std::env::remove_var(&env_var);
290
291        let broker = FileSecretBroker::new();
292        let err = broker
293            .resolve("MISSING_KEY_XYZ", "cell-1", 60)
294            .await
295            .unwrap_err();
296        let msg = err.to_string();
297        assert!(
298            msg.contains("CELLOS_SECRET_FILE_MISSING_KEY_XYZ"),
299            "error should mention the env var: {msg}"
300        );
301    }
302
303    #[tokio::test]
304    async fn errors_when_file_does_not_exist() {
305        let env_var = FileSecretBroker::path_env_var("GHOST_KEY");
306        std::env::set_var(&env_var, "/tmp/cellos-nonexistent-secret-file-xyzzy");
307
308        let broker = FileSecretBroker::new();
309        let err = broker.resolve("GHOST_KEY", "cell-1", 60).await.unwrap_err();
310
311        std::env::remove_var(&env_var);
312        let msg = err.to_string();
313        assert!(
314            msg.contains("GHOST_KEY"),
315            "error should mention the key: {msg}"
316        );
317    }
318
319    #[tokio::test]
320    async fn revoke_is_noop() {
321        let broker = FileSecretBroker::new();
322        broker.revoke_for_cell("any-cell").await.unwrap();
323    }
324
325    /// Red-team wave-2 T5: `read_secret_file` opens the configured path with
326    /// `O_NOFOLLOW`. Pin the symlink-rejection property so a future revert to
327    /// `tokio::fs::read_to_string` cannot silently weaken the secret-broker.
328    #[cfg(unix)]
329    #[tokio::test]
330    async fn rejects_symlink_at_final_component() {
331        let dir = tempfile::tempdir().expect("tmpdir");
332        let real_path = dir.path().join("real-secret");
333        let symlink_path = dir.path().join("symlinked-secret");
334        std::fs::write(&real_path, b"real-value").expect("write real secret");
335        std::os::unix::fs::symlink(&real_path, &symlink_path).expect("symlink");
336
337        let env_var_real = FileSecretBroker::path_env_var("REAL_KEY_WAVE2");
338        std::env::set_var(&env_var_real, real_path.to_str().unwrap());
339        let broker = FileSecretBroker::new();
340        let view = broker
341            .resolve("REAL_KEY_WAVE2", "cell-1", 60)
342            .await
343            .expect("real path opens");
344        assert_eq!(view.value.as_str(), "real-value");
345        std::env::remove_var(&env_var_real);
346
347        let env_var_sym = FileSecretBroker::path_env_var("SYMLINK_KEY_WAVE2");
348        std::env::set_var(&env_var_sym, symlink_path.to_str().unwrap());
349        let err = broker
350            .resolve("SYMLINK_KEY_WAVE2", "cell-1", 60)
351            .await
352            .expect_err("symlink final component must be rejected");
353        std::env::remove_var(&env_var_sym);
354        let msg = err.to_string();
355        assert!(msg.contains("SYMLINK_KEY_WAVE2"), "got: {msg}");
356        assert!(msg.contains("read secret file"), "got: {msg}");
357    }
358
359    #[tokio::test]
360    async fn preserves_multiline_content_minus_final_newline() {
361        // Multi-line files (e.g. PEM certificates) should have only the final \n stripped.
362        let mut f = NamedTempFile::new().unwrap();
363        write!(f, "line1\nline2\nline3\n").unwrap();
364
365        let env_var = FileSecretBroker::path_env_var("CERT_PEM");
366        std::env::set_var(&env_var, f.path().to_str().unwrap());
367
368        let broker = FileSecretBroker::new();
369        let view = broker.resolve("CERT_PEM", "cell-1", 60).await.unwrap();
370
371        std::env::remove_var(&env_var);
372        assert_eq!(view.value.as_str(), "line1\nline2\nline3");
373    }
374}