Skip to main content

decdn_common/config/
secret.rs

1//! [`SecretString`] — a `String` newtype with redacted [`Debug`].
2//!
3//! Used by [`super::types::S3Credentials::Static`] to hold AWS access
4//! keys, secret keys, and session tokens. The wrapper exists so that an
5//! incidental `tracing::debug!(?cfg)`, panic backtrace formatter, or
6//! stray `dbg!()` cannot leak the credential into logs. The codebase
7//! already has redaction discipline for HTTP-origin URL credentials
8//! (see `decdn_config_types::redact_for_log`) — this is the same pattern for
9//! TOML-borne secrets.
10//!
11//! - [`std::fmt::Debug`] always prints `"***"` regardless of the
12//!   wrapped value.
13//! - `std::fmt::Display` is **not** implemented — use
14//!   [`SecretString::expose`] when you need the raw value, so every
15//!   call site is greppable.
16//! - [`serde::Deserialize`] is implemented (so TOML still works).
17//! - [`serde::Serialize`] is implemented but emits a redacted,
18//!   hashed form (`"hash:{digest}"`) — never the cleartext secret.
19//!   The config section structs derive `Serialize` (for symmetry with
20//!   `Deserialize`), so `SecretString` must implement it for them to
21//!   compile; emitting a hash rather than the raw string is
22//!   defense-in-depth, the `Serialize` analogue of the `Debug`
23//!   redaction above — any incidental serialization of a config
24//!   section (diagnostics, tests, future call sites) cannot leak the
25//!   credential. The hash uses
26//!   [`std::collections::hash_map::DefaultHasher`], which `std`
27//!   documents as producing the same output for all `DefaultHasher`
28//!   instances in a given build of the standard library — i.e.
29//!   deterministic across processes built with the same toolchain, so
30//!   distinct secrets almost always serialize to distinct forms. It is
31//!   non-cryptographic (single-pair collision probability ≈ 2⁻⁶⁴ for a
32//!   64-bit hash); cryptographic strength is unnecessary because the
33//!   output only guards against accidental cleartext exposure, never
34//!   authenticates anything.
35
36use std::hash::{Hash, Hasher};
37
38use serde::{Deserialize, Serialize, Serializer};
39
40/// String newtype that redacts itself in [`Debug`] output. See module
41/// docs for the threat model.
42#[derive(Clone, Deserialize)]
43#[serde(transparent)]
44pub struct SecretString(String);
45
46impl SecretString {
47    /// Construct a [`SecretString`] from any [`String`]-shaped value.
48    /// Most call sites get one via serde; this constructor is for
49    /// tests and for the rare in-code construction.
50    pub fn new(value: impl Into<String>) -> Self {
51        Self(value.into())
52    }
53
54    /// Read out the wrapped string. Named "expose" rather than `as_str`
55    /// or `inner` so a code reviewer scanning for credential reads has
56    /// a single greppable token.
57    #[must_use]
58    pub fn expose(&self) -> &str {
59        &self.0
60    }
61
62    /// True if the wrapped string is empty. Useful in validators
63    /// without exposing the secret.
64    #[must_use]
65    pub const fn is_empty(&self) -> bool {
66        self.0.is_empty()
67    }
68}
69
70impl std::fmt::Debug for SecretString {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        // `"***"` rather than `"<redacted>"` to keep the placeholder
73        // shorter than any real credential — operators tailing a log
74        // can spot it visually without the placeholder running into
75        // surrounding fields.
76        f.write_str("\"***\"")
77    }
78}
79
80impl Serialize for SecretString {
81    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
82        // We cannot drop `Serialize` entirely: the config section
83        // structs (`S3Credentials`, and `FileConfig` transitively)
84        // derive it for symmetry with `Deserialize`, so `SecretString`
85        // must implement it for them to compile. Rather than emit the
86        // cleartext, we hash it: any incidental serialization of a
87        // config section — diagnostics, tests, a future call site —
88        // then cannot leak the credential (defense-in-depth, the
89        // `Serialize` analogue of the `Debug` redaction). Distinct
90        // secrets almost always map to distinct hashes, so the form
91        // stays useful for equality checks. `DefaultHasher` is
92        // non-cryptographic; std documents it as producing the same
93        // output across all `DefaultHasher` instances in a build, so
94        // the digest is stable within a process. Cryptographic strength
95        // is unnecessary — the output only guards against accidental
96        // cleartext exposure, and the single-pair collision probability
97        // (~2⁻⁶⁴ for a 64-bit output) is negligible.
98        let mut h = std::collections::hash_map::DefaultHasher::new();
99        self.0.hash(&mut h);
100        // 16 hex chars covers the full u64 hash output. Prefix is
101        // `hash:` (not `sha:`): the digest comes from `DefaultHasher`
102        // (SipHash-1-3 in current std), not any SHA family — naming
103        // it after the algorithm we don't use would be misleading.
104        serializer.serialize_str(&format!("hash:{:016x}", h.finish()))
105    }
106}
107
108// PartialEq is provided to keep tests ergonomic. Comparing two
109// SecretStrings for equality does not leak: the comparison runs in
110// constant-ish time at the byte level and produces only a `bool`.
111// This is intentionally not constant-time crypto: we're guarding
112// against incidental leakage via Debug/Serialize, not against side-
113// channel attackers.
114impl PartialEq for SecretString {
115    fn eq(&self, other: &Self) -> bool {
116        self.0 == other.0
117    }
118}
119
120impl Eq for SecretString {}
121
122#[cfg(test)]
123#[allow(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)]
124mod tests;