Skip to main content

awaken_contract/
secret.rs

1//! Secret value newtype that redacts itself in `Debug`/`Display` while still
2//! round-tripping through `serde`.
3//!
4//! Use [`RedactedString`] for any field that holds a credential. The inner
5//! buffer is held inside [`secrecy::SecretBox`], so it is zeroized when the
6//! value is dropped. The plaintext is reachable only via
7//! [`RedactedString::expose_secret`] — this single accessor is the grep-able
8//! "trust boundary" for the codebase.
9//!
10//! Wire format is a plain JSON string. JSON Schema reports `string`. Storage
11//! backends that persist this value see the secret in cleartext, so storage
12//! choices remain a separate concern.
13
14use std::borrow::Cow;
15use std::fmt;
16
17use schemars::{JsonSchema, Schema, SchemaGenerator};
18use secrecy::{ExposeSecret, SecretBox};
19use serde::{Deserialize, Deserializer, Serialize, Serializer};
20
21/// String wrapper whose `Debug`/`Display` implementations never reveal the
22/// underlying value, and whose buffer is zeroized on drop.
23pub struct RedactedString(SecretBox<String>);
24
25impl RedactedString {
26    /// Construct from any `Into<String>`.
27    pub fn new(value: impl Into<String>) -> Self {
28        Self(SecretBox::new(Box::new(value.into())))
29    }
30
31    /// Reach through the redaction to obtain the plaintext value.
32    ///
33    /// Calls to this function are the trust boundary: every caller is
34    /// responsible for not propagating the returned `&str` into any path that
35    /// might log it.
36    pub fn expose_secret(&self) -> &str {
37        self.0.expose_secret().as_str()
38    }
39
40    /// Returns `true` if the inner string is empty.
41    pub fn is_empty(&self) -> bool {
42        self.expose_secret().is_empty()
43    }
44
45    /// Redacted preview suitable for operator-facing logs: first four and
46    /// last four characters with the middle masked, e.g. `"sk-a***wxyz"`.
47    ///
48    /// Values shorter than twelve characters render as `"***"` so that the
49    /// preview never reveals more than half of any single secret.
50    /// Char-aware so multi-byte UTF-8 cannot be split in the middle of a code
51    /// point. Not a cryptographic identifier — do not use for authentication
52    /// or de-duplication.
53    pub fn preview(&self) -> String {
54        let chars: Vec<char> = self.expose_secret().chars().collect();
55        if chars.len() < 12 {
56            return "***".into();
57        }
58        let head: String = chars.iter().take(4).collect();
59        let tail: String = chars.iter().skip(chars.len() - 4).collect();
60        format!("{head}***{tail}")
61    }
62}
63
64impl Clone for RedactedString {
65    fn clone(&self) -> Self {
66        Self::new(self.expose_secret().to_owned())
67    }
68}
69
70impl PartialEq for RedactedString {
71    fn eq(&self, other: &Self) -> bool {
72        self.expose_secret() == other.expose_secret()
73    }
74}
75
76impl Eq for RedactedString {}
77
78impl fmt::Debug for RedactedString {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        f.write_str("RedactedString(***)")
81    }
82}
83
84impl fmt::Display for RedactedString {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        f.write_str("***")
87    }
88}
89
90impl From<String> for RedactedString {
91    fn from(value: String) -> Self {
92        Self::new(value)
93    }
94}
95
96impl From<&str> for RedactedString {
97    fn from(value: &str) -> Self {
98        Self::new(value)
99    }
100}
101
102impl Serialize for RedactedString {
103    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
104        serializer.serialize_str(self.expose_secret())
105    }
106}
107
108impl<'de> Deserialize<'de> for RedactedString {
109    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
110        let value = String::deserialize(deserializer)?;
111        Ok(Self::new(value))
112    }
113}
114
115impl JsonSchema for RedactedString {
116    fn schema_name() -> Cow<'static, str> {
117        Cow::Borrowed("String")
118    }
119
120    fn json_schema(generator: &mut SchemaGenerator) -> Schema {
121        String::json_schema(generator)
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn debug_redacts() {
131        let s = RedactedString::new("sk-secret");
132        assert_eq!(format!("{s:?}"), "RedactedString(***)");
133    }
134
135    #[test]
136    fn display_redacts() {
137        let s = RedactedString::new("sk-secret");
138        assert_eq!(format!("{s}"), "***");
139    }
140
141    #[test]
142    fn debug_in_option_redacts() {
143        let s = Some(RedactedString::new("sk-secret"));
144        let formatted = format!("{s:?}");
145        assert!(!formatted.contains("sk-secret"), "leaked: {formatted}");
146    }
147
148    #[test]
149    fn clone_preserves_value() {
150        let original = RedactedString::new("sk-secret");
151        let copied = original.clone();
152        assert_eq!(copied.expose_secret(), "sk-secret");
153        assert_eq!(original.expose_secret(), "sk-secret");
154    }
155
156    #[test]
157    fn serde_roundtrip_preserves_value() {
158        let s = RedactedString::new("sk-secret");
159        let encoded = serde_json::to_string(&s).unwrap();
160        assert_eq!(encoded, "\"sk-secret\"");
161        let decoded: RedactedString = serde_json::from_str(&encoded).unwrap();
162        assert_eq!(decoded.expose_secret(), "sk-secret");
163    }
164
165    #[test]
166    fn json_schema_is_plain_string() {
167        let schema = schemars::schema_for!(RedactedString);
168        let value = serde_json::to_value(&schema).unwrap();
169        assert_eq!(value.get("type").and_then(|v| v.as_str()), Some("string"));
170    }
171
172    #[test]
173    fn preview_keeps_first_and_last_four_chars() {
174        let s = RedactedString::new("sk-abcd1234567890wxyz");
175        assert_eq!(s.preview(), "sk-a***wxyz");
176    }
177
178    #[test]
179    fn preview_masks_short_values_completely() {
180        for short in ["", "abc", "sk-12345", "12345678901"] {
181            let s = RedactedString::new(short);
182            assert_eq!(
183                s.preview(),
184                "***",
185                "values shorter than 12 chars must render as `***`, got input {short:?}"
186            );
187        }
188    }
189
190    #[test]
191    fn preview_does_not_panic_on_multibyte_utf8() {
192        let s = RedactedString::new("αβγδ-中文-emoji-🔑🔑🔑🔑");
193        let preview = s.preview();
194        assert!(preview.contains("***"));
195        assert!(!preview.contains("中文"));
196    }
197}