Skip to main content

agentd/sec/
secret.rs

1// SPDX-License-Identifier: Apache-2.0
2//! File-based secret refs (RFC 0017 §6, riding RFC 0006 §6 / RFC 0012 §3.7).
3//!
4//! Secrets are env/file only — **never** in the config file, **never** logged
5//! (RFC 0012 §3.7). This module is the file-backed half of the secret front
6//! door: it reads a credential from a mounted file (a Kubernetes `Secret`
7//! volume), trims the trailing newline kubelet leaves on a projected file, and
8//! resolves the two interpolation tokens a declared header value may carry:
9//!
10//! - `{{secret:NAME}}` — the value of process env var `NAME`.
11//! - `{{secret-file:PATH}}` — the contents of the mounted file at `PATH`,
12//!   re-read at the moment of use so a rotation takes effect without a restart
13//!   (RFC 0017 §6.1/§6.2).
14//!
15//! Both are `read_local` only (RFC 0011 §3.1): a filesystem path, no URL
16//! scheme, no network. The **template** (`{{secret:…}}` / `{{secret-file:…}}`)
17//! is structural and may live in the config file or a flag; the **resolved
18//! value** is materialized only at the instant of use and is never retained,
19//! never logged. A reference is structural; the value is not in the file — so
20//! the RFC 0011/0012 "the file is secret-free" invariant holds exactly.
21
22/// Read a credential from a mounted file, trimming a single trailing newline
23/// (kubelet projects a Secret value verbatim; an editor/`echo` commonly appends
24/// a `\n`). Errors carry the path but NOT the contents (RFC 0012 §3.7 — a
25/// secret never reaches a log/error line).
26pub fn read_token_file(path: &str) -> Result<String, String> {
27    let raw = std::fs::read_to_string(path)
28        .map_err(|e| format!("cannot read secret file {path}: {e}"))?;
29    Ok(trim_token(&raw).to_string())
30}
31
32/// Trim a trailing `\n` (and a `\r\n`) from a file-read token. Only the final
33/// line-ending is stripped — interior whitespace is part of the credential.
34fn trim_token(s: &str) -> &str {
35    s.strip_suffix('\n')
36        .map(|t| t.strip_suffix('\r').unwrap_or(t))
37        .unwrap_or(s)
38}
39
40/// Resolve every `{{secret:NAME}}` / `{{secret-file:PATH}}` token in `template`
41/// against `env` (the process environment) and the local filesystem, returning
42/// the materialized string. Plain text passes through unchanged. A bad token
43/// (missing env var, unreadable file, or an unterminated `{{`) is an `Err` with
44/// a message that names the ref but NOT the resolved value (RFC 0012 §3.7).
45///
46/// This is the runtime resolver — it is called at the moment of use, so a
47/// rotated `{{secret-file:…}}` is picked up on the next call. `--validate-config`
48/// and startup call [`refs_resolvable`] for the side-effect-free pre-flight.
49pub fn resolve(template: &str, env: &dyn Fn(&str) -> Option<String>) -> Result<String, String> {
50    if !template.contains("{{") {
51        return Ok(template.to_string());
52    }
53    let mut out = String::with_capacity(template.len());
54    let mut rest = template;
55    while let Some(open) = rest.find("{{") {
56        out.push_str(&rest[..open]);
57        let after = &rest[open + 2..];
58        let close = after
59            .find("}}")
60            .ok_or_else(|| "unterminated secret ref '{{' (want '{{secret:NAME}}')".to_string())?;
61        let token = after[..close].trim();
62        out.push_str(&resolve_one(token, env)?);
63        rest = &after[close + 2..];
64    }
65    out.push_str(rest);
66    Ok(out)
67}
68
69/// Resolve a single `secret:NAME` / `secret-file:PATH` token body (without the
70/// surrounding braces). Any other token is an error — a literal `{{…}}` that is
71/// not a secret ref is rejected rather than silently passed through, so a typo
72/// can't smuggle braces onto the wire.
73fn resolve_one(token: &str, env: &dyn Fn(&str) -> Option<String>) -> Result<String, String> {
74    if let Some(path) = token.strip_prefix("secret-file:") {
75        let path = path.trim();
76        if path.is_empty() {
77            return Err("empty {{secret-file:}} path".to_string());
78        }
79        read_token_file(path)
80    } else if let Some(name) = token.strip_prefix("secret:") {
81        let name = name.trim();
82        if name.is_empty() {
83            return Err("empty {{secret:}} name".to_string());
84        }
85        env(name).ok_or_else(|| format!("{{{{secret:{name}}}}} is not set in the environment"))
86    } else {
87        Err(format!(
88            "unknown interpolation token '{{{{{token}}}}}' (want {{{{secret:NAME}}}} or {{{{secret-file:PATH}}}})"
89        ))
90    }
91}
92
93/// Does `value` contain at least one `{{secret:…}}` / `{{secret-file:…}}` ref?
94/// Used by the validator to distinguish a (legal) secret *reference* from an
95/// (illegal) inline secret-shaped scalar in a declared header (RFC 0017 §3.1).
96pub fn has_secret_ref(value: &str) -> bool {
97    value.contains("{{secret:") || value.contains("{{secret-file:")
98}
99
100/// Side-effect-free-as-possible pre-flight for `--validate-config` / startup:
101/// every ref in `template` must resolve (the env var is set; the file exists and
102/// is readable). Returns the same diagnostics `resolve` would, without retaining
103/// the resolved bytes. A `{{secret-file:…}}` IS read here (it must exist to be
104/// valid, RFC 0017 §6.2 — "missing/unreadable at startup → exit 2"), but the
105/// contents are dropped immediately.
106pub fn refs_resolvable(template: &str, env: &dyn Fn(&str) -> Option<String>) -> Result<(), String> {
107    resolve(template, env).map(|_| ())
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use std::io::Write;
114
115    fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
116        move |k: &str| {
117            pairs
118                .iter()
119                .find(|(n, _)| *n == k)
120                .map(|(_, v)| (*v).to_string())
121        }
122    }
123
124    #[test]
125    fn trims_one_trailing_newline_only() {
126        assert_eq!(trim_token("tok\n"), "tok");
127        assert_eq!(trim_token("tok\r\n"), "tok");
128        assert_eq!(trim_token("tok"), "tok");
129        // interior + a blank trailing line: only the final \n goes.
130        assert_eq!(trim_token("a b\n\n"), "a b\n");
131        // no over-trim of interior whitespace.
132        assert_eq!(trim_token("  tok  \n"), "  tok  ");
133    }
134
135    #[test]
136    fn read_token_file_reads_and_trims() {
137        let mut f = tempfile::NamedTempFile::new().unwrap();
138        writeln!(f, "super-secret").unwrap();
139        let v = read_token_file(f.path().to_str().unwrap()).unwrap();
140        assert_eq!(v, "super-secret");
141    }
142
143    #[test]
144    fn read_token_file_missing_is_error_without_contents() {
145        let e = read_token_file("/no/such/secret/file").unwrap_err();
146        assert!(e.contains("cannot read secret file"));
147    }
148
149    #[test]
150    fn resolve_passthrough_and_env_ref() {
151        let env = env_of(&[("ANTHROPIC_API_KEY", "k-123")]);
152        assert_eq!(resolve("plain text", &env).unwrap(), "plain text");
153        assert_eq!(
154            resolve("x-api-key: {{secret:ANTHROPIC_API_KEY}}", &env).unwrap(),
155            "x-api-key: k-123"
156        );
157        // a bare token with surrounding text on both sides.
158        assert_eq!(
159            resolve("Bearer {{secret:ANTHROPIC_API_KEY}}!", &env).unwrap(),
160            "Bearer k-123!"
161        );
162    }
163
164    #[test]
165    fn resolve_file_ref_reads_fresh_and_trims() {
166        let mut f = tempfile::NamedTempFile::new().unwrap();
167        writeln!(f, "file-tok").unwrap();
168        let path = f.path().to_str().unwrap();
169        let env = env_of(&[]);
170        let tmpl = format!("Bearer {{{{secret-file:{path}}}}}");
171        assert_eq!(resolve(&tmpl, &env).unwrap(), "Bearer file-tok");
172    }
173
174    #[test]
175    fn resolve_missing_env_is_error_and_does_not_leak_value() {
176        let env = env_of(&[]);
177        let e = resolve("{{secret:NOPE}}", &env).unwrap_err();
178        assert!(e.contains("NOPE"));
179        assert!(e.contains("not set"));
180    }
181
182    #[test]
183    fn resolve_unknown_token_and_unterminated_are_errors() {
184        let env = env_of(&[]);
185        assert!(resolve("{{bogus:x}}", &env).is_err());
186        assert!(resolve("{{secret:", &env).is_err());
187        assert!(resolve("{{secret:}}", &env).is_err());
188        assert!(resolve("{{secret-file:}}", &env).is_err());
189    }
190
191    #[test]
192    fn has_secret_ref_detects_both_kinds() {
193        assert!(has_secret_ref("{{secret:X}}"));
194        assert!(has_secret_ref("Bearer {{secret-file:/p}}"));
195        assert!(!has_secret_ref("plain value"));
196        assert!(!has_secret_ref("2023-06-01"));
197    }
198}