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}