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;