Skip to main content

codlet_core/
secret.rs

1//! Secret-bearing and opaque-identifier newtypes.
2//!
3//! Secret types wrap a [`SecretString`] whose `Debug` (and `Display`, where
4//! present) implementations are redacted, so a plaintext code, session secret,
5//! or form-token secret cannot leak through logs, panic messages, or
6//! `{:?}`-formatting (threat model INV-1, SR-38). The plaintext is reachable
7//! only through an explicit [`SecretString::expose`] call, which is easy to
8//! grep for in review.
9//!
10//! These are the v0.1 foundations of the typestate model in RFC-019. They are
11//! deliberately minimal: enough to make misuse visible, without committing to
12//! the full typestate surface before the store traits exist.
13
14/// A string holding a sensitive value whose contents are never shown by
15/// `Debug` or `Display`.
16///
17/// The inner value is accessible only via [`SecretString::expose`]. Equality is
18/// provided for tests and lookup bookkeeping; it is **not** constant-time and
19/// must not be used to compare secrets that an attacker can influence by timing
20/// — compare derived [`crate::hashing::LookupKey`] values instead.
21#[derive(Clone, PartialEq, Eq)]
22pub struct SecretString(String);
23
24impl SecretString {
25    /// Wrap a value as a secret. The value is moved in and never copied to any
26    /// formatting buffer.
27    #[must_use]
28    pub fn new(value: String) -> Self {
29        Self(value)
30    }
31
32    /// Borrow the plaintext. Named `expose` so its use is visible in review and
33    /// easy to grep for; callers must not log or persist the returned value.
34    #[must_use]
35    pub fn expose(&self) -> &str {
36        &self.0
37    }
38
39    /// Number of bytes in the underlying value. Length is not considered
40    /// sensitive for the fixed-width secrets codlet generates.
41    #[must_use]
42    pub fn len(&self) -> usize {
43        self.0.len()
44    }
45
46    /// Whether the underlying value is empty.
47    #[must_use]
48    pub fn is_empty(&self) -> bool {
49        self.0.is_empty()
50    }
51}
52
53impl core::fmt::Debug for SecretString {
54    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
55        f.write_str("SecretString(<redacted>)")
56    }
57}
58
59impl core::fmt::Display for SecretString {
60    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
61        f.write_str("<redacted>")
62    }
63}
64
65/// Serialize as the redaction marker, never the plaintext (SR-3, SR-39).
66#[cfg(feature = "serde")]
67impl serde::Serialize for SecretString {
68    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
69        serializer.serialize_str("<redacted>")
70    }
71}
72
73/// Define a secret-bearing newtype over [`SecretString`] with redacted
74/// `Debug`/`Display` inherited from the inner type.
75macro_rules! secret_newtype {
76    ($(#[$meta:meta])* $name:ident) => {
77        $(#[$meta])*
78        #[derive(Clone, PartialEq, Eq, Debug)]
79        pub struct $name(SecretString);
80
81        impl $name {
82            /// Wrap an already-generated or received plaintext value.
83            #[must_use]
84            pub fn new(value: String) -> Self {
85                Self(SecretString::new(value))
86            }
87
88            /// Borrow the plaintext. See [`SecretString::expose`].
89            #[must_use]
90            pub fn expose(&self) -> &str {
91                self.0.expose()
92            }
93
94            /// Borrow the inner [`SecretString`].
95            #[must_use]
96            pub fn as_secret(&self) -> &SecretString {
97                &self.0
98            }
99        }
100
101        #[cfg(feature = "serde")]
102        impl serde::Serialize for $name {
103            fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
104                self.0.serialize(s)
105            }
106        }
107    };
108}
109
110secret_newtype! {
111    /// A one-time code in plaintext — either freshly generated for one-time
112    /// display, or received as user input. Never persisted (INV-1).
113    PlainCode
114}
115
116secret_newtype! {
117    /// A session secret in plaintext. Lives only in the cookie; only its
118    /// derived lookup key is stored (RFC-006).
119    SessionSecret
120}
121
122secret_newtype! {
123    /// A form-token secret in plaintext. Lives only in the rendered form or a
124    /// short-lived cookie; only its derived lookup key is stored (RFC-007).
125    FormTokenSecret
126}
127
128/// Define an opaque, non-secret string identifier newtype.
129macro_rules! id_newtype {
130    ($(#[$meta:meta])* $name:ident) => {
131        $(#[$meta])*
132        #[derive(Clone, PartialEq, Eq, Hash, Debug)]
133        #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
134        pub struct $name(String);
135
136        impl $name {
137            /// Wrap a host- or store-provided identifier.
138            #[must_use]
139            pub fn new(value: String) -> Self {
140                Self(value)
141            }
142
143            /// Borrow the identifier as a string slice.
144            #[must_use]
145            pub fn as_str(&self) -> &str {
146                &self.0
147            }
148        }
149
150        impl core::fmt::Display for $name {
151            fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
152                f.write_str(&self.0)
153            }
154        }
155
156        impl From<String> for $name {
157            fn from(value: String) -> Self {
158                Self(value)
159            }
160        }
161    };
162}
163
164id_newtype! {
165    /// Identifier of a code record. Not a secret; safe to log and display.
166    CodeId
167}
168
169id_newtype! {
170    /// Host-owned identity anchor returned after authentication. codlet does
171    /// not interpret its meaning (RFC-001).
172    SubjectId
173}
174
175id_newtype! {
176    /// Identifier of a session record. Not a bearer credential on its own
177    /// (RFC-006 §13.1).
178    SessionId
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    #[test]
185    fn secret_string_redacts_debug_and_display() {
186        let s = SecretString::new("hunter2".to_string());
187        assert_eq!(format!("{s:?}"), "SecretString(<redacted>)");
188        assert_eq!(format!("{s}"), "<redacted>");
189        // The plaintext must not appear in either rendering.
190        assert!(!format!("{s:?}").contains("hunter2"));
191        assert!(!format!("{s}").contains("hunter2"));
192        // But is reachable explicitly.
193        assert_eq!(s.expose(), "hunter2");
194    }
195
196    #[test]
197    fn secret_newtypes_redact_debug() {
198        let c = PlainCode::new("ABCD2345".to_string());
199        let dbg = format!("{c:?}");
200        assert!(
201            !dbg.contains("ABCD2345"),
202            "PlainCode Debug leaked plaintext: {dbg}"
203        );
204        assert!(dbg.contains("<redacted>"));
205        assert_eq!(c.expose(), "ABCD2345");
206    }
207
208    #[test]
209    fn id_newtype_displays_and_roundtrips() {
210        let id = CodeId::new("abc123".to_string());
211        assert_eq!(id.as_str(), "abc123");
212        assert_eq!(format!("{id}"), "abc123");
213        assert_eq!(CodeId::from("x".to_string()).as_str(), "x");
214    }
215
216    #[cfg(feature = "serde")]
217    #[test]
218    fn secret_serializes_redacted() {
219        let c = SessionSecret::new("supersecret".to_string());
220        let json = serde_json::to_string(&c).unwrap();
221        assert_eq!(json, "\"<redacted>\"");
222        assert!(!json.contains("supersecret"));
223    }
224}
225
226// ── RFC-019: additional typed wrappers ───────────────────────────────────────
227
228/// A one-time code after normalization (whitespace/hyphen stripped, uppercased).
229///
230/// Constructed only via [`crate::code::normalize()`] + validation so it is
231/// impossible to confuse raw user input with the canonical form used for
232/// HMAC derivation.
233#[derive(Clone, PartialEq, Eq, Debug)]
234pub struct NormalizedCode(String);
235
236impl NormalizedCode {
237    /// Wrap a value already known to be normalized. Prefer calling
238    /// [`crate::code::validate_code_input`] which constructs this type.
239    #[must_use]
240    pub fn new(value: String) -> Self {
241        Self(value)
242    }
243
244    /// Borrow the normalized code string.
245    #[must_use]
246    pub fn as_str(&self) -> &str {
247        &self.0
248    }
249}
250
251impl core::fmt::Display for NormalizedCode {
252    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
253        // NormalizedCode is not a secret — it is the index form used for lookup.
254        f.write_str(&self.0)
255    }
256}
257
258/// A validated purpose label for a code or form token (RFC-019).
259///
260/// Prevents mixing up purpose strings between operations; not a secret.
261#[derive(Clone, PartialEq, Eq, Hash, Debug)]
262#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
263pub struct Purpose(String);
264
265impl Purpose {
266    /// Wrap a purpose string. Must be non-empty; returns `None` otherwise.
267    #[must_use]
268    pub fn new(value: impl Into<String>) -> Option<Self> {
269        let s: String = value.into();
270        if s.is_empty() { None } else { Some(Self(s)) }
271    }
272
273    /// Borrow the purpose string.
274    #[must_use]
275    pub fn as_str(&self) -> &str {
276        &self.0
277    }
278}
279
280impl core::fmt::Display for Purpose {
281    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
282        f.write_str(&self.0)
283    }
284}
285
286/// A scope key — an optional host-owned boundary label (community ID, tenant,
287/// etc.) used to restrict code lookup and revocation (RFC-019).
288///
289/// Not a secret; safe to log and display.
290#[derive(Clone, PartialEq, Eq, Hash, Debug)]
291#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
292pub struct ScopeKey(String);
293
294impl ScopeKey {
295    /// Wrap a scope key string.
296    #[must_use]
297    pub fn new(value: impl Into<String>) -> Self {
298        Self(value.into())
299    }
300
301    /// Borrow the scope key string.
302    #[must_use]
303    pub fn as_str(&self) -> &str {
304        &self.0
305    }
306}
307
308impl core::fmt::Display for ScopeKey {
309    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
310        f.write_str(&self.0)
311    }
312}
313
314#[cfg(test)]
315mod rfc019_tests {
316    use super::*;
317
318    #[test]
319    fn normalized_code_displays_plainly() {
320        let n = NormalizedCode::new("ABCD2345".into());
321        assert_eq!(format!("{n}"), "ABCD2345");
322        assert_eq!(n.as_str(), "ABCD2345");
323    }
324
325    #[test]
326    fn purpose_rejects_empty() {
327        assert!(Purpose::new("").is_none());
328        assert!(Purpose::new("logout").is_some());
329    }
330
331    #[test]
332    fn scope_key_roundtrip() {
333        let s = ScopeKey::new("community-42");
334        assert_eq!(s.as_str(), "community-42");
335        assert_eq!(format!("{s}"), "community-42");
336    }
337}