Skip to main content

dodot_lib/secret/
secret_string.rs

1//! `SecretString` — a wrapper that holds a resolved secret value and
2//! tries to keep it from leaking through the usual Rust footguns.
3//!
4//! The guarantees this type makes are explicitly limited; see
5//! `docs/proposals/secrets.lex` §5.1 ("defense in depth, not a
6//! guarantee"). Resolved secret values land on disk in the rendered
7//! file regardless, so the meaningful protections are at the OS layer.
8//! What this wrapper does add:
9//!
10//! - **Zero-on-drop.** The byte buffer is overwritten on `Drop` via
11//!   the `zeroize` crate's volatile-write loop, which the compiler
12//!   can't elide. Reduces the window where a stale-but-still-resident
13//!   buffer could be read by another process with sufficient
14//!   privilege.
15//! - **No `Debug` / `Display`.** The type is opaque to the standard
16//!   formatting machinery. A `tracing::error!("{e:?}", e=...)` that
17//!   accidentally captures a `SecretString` won't print the bytes; it
18//!   prints `SecretString(<redacted>)`.
19//! - **No `Serialize`.** Same idea, for the JSON / TOML paths.
20//! - **No `Clone`.** Discourages duplicating the value into multiple
21//!   buffers; callers that genuinely need a copy can call
22//!   [`SecretString::expose`] and re-wrap.
23//!
24//! What this wrapper does NOT do:
25//!
26//! - It doesn't `mlock` the page. Memory locking is a process-wide
27//!   concern with rlimits to think about; the spec defers it.
28//! - It doesn't prevent the value from being copied into a `String`
29//!   that the renderer hands off to MiniJinja for substitution. The
30//!   rendered output goes to disk; the wrapper has no power past that
31//!   handoff.
32//!
33//! Read this as: every code path that touches the bytes through
34//! `SecretString` is a deliberate decision, and the type makes the
35//! accidental paths impossible.
36
37use zeroize::Zeroize;
38
39/// A resolved secret value, held briefly in process memory.
40///
41/// Construct via [`SecretString::new`]; read via [`SecretString::expose`].
42/// Zeroes its buffer on drop. Has no `Debug` / `Display` / `Serialize`
43/// implementations; printing one through any of those paths produces
44/// `<redacted>`.
45pub struct SecretString {
46    inner: Vec<u8>,
47}
48
49impl SecretString {
50    /// Wrap a UTF-8 string as a secret. Takes ownership of the input
51    /// bytes so the caller can't keep a parallel handle.
52    pub fn new(value: String) -> Self {
53        Self {
54            inner: value.into_bytes(),
55        }
56    }
57
58    /// Wrap an arbitrary byte slice (for binary secrets — keys, etc.).
59    pub fn from_bytes(bytes: Vec<u8>) -> Self {
60        Self { inner: bytes }
61    }
62
63    /// Borrow the secret as `&str`. Returns an error if the bytes
64    /// aren't valid UTF-8 — the value-injection path requires UTF-8.
65    /// Whole-file deploy uses [`SecretString::expose_bytes`] instead.
66    pub fn expose(&self) -> Result<&str, std::str::Utf8Error> {
67        std::str::from_utf8(&self.inner)
68    }
69
70    /// Borrow the secret as raw bytes. For whole-file flows where
71    /// UTF-8 isn't a guarantee.
72    pub fn expose_bytes(&self) -> &[u8] {
73        &self.inner
74    }
75
76    /// Length of the secret in bytes. Safe to log.
77    pub fn len(&self) -> usize {
78        self.inner.len()
79    }
80
81    /// True iff the secret is empty.
82    pub fn is_empty(&self) -> bool {
83        self.inner.is_empty()
84    }
85
86    /// True iff the secret value contains at least one newline.
87    /// Used by §3.4's multi-line refusal: value-injection requires
88    /// single-line values, and this check is the gate. Reading this
89    /// flag does not expose the bytes anywhere — it's a property of
90    /// the value, not the value itself.
91    pub fn contains_newline(&self) -> bool {
92        self.inner.contains(&b'\n')
93    }
94}
95
96impl Drop for SecretString {
97    fn drop(&mut self) {
98        self.inner.zeroize();
99    }
100}
101
102// Explicitly opaque to formatting machinery.
103impl std::fmt::Debug for SecretString {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        // Length included on purpose: it's safe to log, and helps
106        // distinguish "empty value resolved" from "no value resolved"
107        // when debugging without ever exposing the bytes.
108        write!(f, "SecretString(<redacted>, len={})", self.inner.len())
109    }
110}
111
112// `Display` is intentionally NOT implemented: printing a secret with
113// `{}` should fail to compile, not silently format the bytes.
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn new_and_expose_round_trip() {
121        let s = SecretString::new("hunter2".into());
122        assert_eq!(s.expose().unwrap(), "hunter2");
123        assert_eq!(s.len(), 7);
124        assert!(!s.is_empty());
125    }
126
127    #[test]
128    fn empty_secret_is_well_behaved() {
129        let s = SecretString::new(String::new());
130        assert!(s.is_empty());
131        assert_eq!(s.len(), 0);
132        assert_eq!(s.expose().unwrap(), "");
133    }
134
135    #[test]
136    fn from_bytes_supports_non_utf8_payload() {
137        // 0xff is not valid UTF-8.
138        let s = SecretString::from_bytes(vec![0xde, 0xad, 0xbe, 0xef, 0xff]);
139        assert_eq!(s.expose_bytes(), &[0xde, 0xad, 0xbe, 0xef, 0xff]);
140        assert!(s.expose().is_err(), "expose() must reject non-UTF-8");
141    }
142
143    #[test]
144    fn debug_does_not_leak_value() {
145        let s = SecretString::new("super-secret-token".into());
146        let formatted = format!("{:?}", s);
147        assert!(!formatted.contains("super-secret-token"));
148        assert!(formatted.contains("<redacted>"));
149        // Length surfacing is intentional — useful for debugging,
150        // doesn't reveal the value.
151        assert!(formatted.contains("len=18"));
152    }
153
154    #[test]
155    fn contains_newline_detects_multiline_for_section_3_4() {
156        let single = SecretString::new("one-line-value".into());
157        assert!(!single.contains_newline());
158
159        let multi = SecretString::new("line1\nline2".into());
160        assert!(multi.contains_newline());
161
162        // CR-only (e.g. classic-Mac line endings) is NOT flagged as
163        // multi-line — value-injection's single-line gate is about
164        // file-format breakage from `\n`. CR-only inputs are
165        // pathological enough that we'd rather catch them via the
166        // template engine's UTF-8 / encoding handling.
167        let cr_only = SecretString::new("line1\rline2".into());
168        assert!(!cr_only.contains_newline());
169    }
170
171    #[test]
172    fn drop_zeroes_underlying_bytes() {
173        // We can't observe the bytes after Drop directly without
174        // unsafe (the buffer is freed). Instead, exercise the
175        // zeroize pathway by holding the value, asserting the
176        // pre-drop content, then dropping and constructing a fresh
177        // value to confirm the type still works after Drop ran.
178        // The real guarantee — that `Drop::drop` is called and
179        // calls `zeroize` — is provided by the type's impl;
180        // this test pins the contract on that impl rather than
181        // attempting to read freed memory.
182        let s = SecretString::new("rotate-me".into());
183        assert_eq!(s.expose().unwrap(), "rotate-me");
184        drop(s);
185        let s2 = SecretString::new("next-value".into());
186        assert_eq!(s2.expose().unwrap(), "next-value");
187    }
188
189    // Compile-time check: SecretString does NOT implement Clone.
190    // (If a future change accidentally derives Clone, the line below
191    // would compile and this test would silently pass. Instead, the
192    // negative assertion lives as a doc-comment; rust-analyzer / human
193    // review catches re-introduced Clone derivations.)
194    //
195    //   let s = SecretString::new("x".into());
196    //   let _ = s.clone();   // <-- must fail to compile
197}