Skip to main content

shardline_protocol/
security.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4use subtle::ConstantTimeEq;
5use zeroize::{Zeroize, ZeroizeOnDrop};
6
7/// Zeroizing byte-oriented secret material.
8///
9/// Constant-time equality is used for [`PartialEq`] to avoid timing side-channels.
10///
11/// # Examples
12///
13/// ```
14/// use shardline_protocol::SecretBytes;
15///
16/// let secret = SecretBytes::from_slice(b"my-secret-key");
17/// assert_eq!(secret.expose_secret(), b"my-secret-key");
18/// assert!(!secret.is_empty());
19/// ```
20#[derive(Clone, Eq, Deserialize, Zeroize, ZeroizeOnDrop)]
21pub struct SecretBytes(Vec<u8>);
22
23impl PartialEq for SecretBytes {
24    fn eq(&self, other: &Self) -> bool {
25        self.0.ct_eq(&other.0).into()
26    }
27}
28
29impl SecretBytes {
30    /// Wraps owned secret bytes.
31    #[must_use]
32    pub const fn new(secret: Vec<u8>) -> Self {
33        Self(secret)
34    }
35
36    /// Copies borrowed secret bytes into zeroizing storage.
37    #[must_use]
38    pub fn from_slice(secret: &[u8]) -> Self {
39        Self(secret.to_vec())
40    }
41
42    /// Returns the secret bytes.
43    #[must_use]
44    pub fn expose_secret(&self) -> &[u8] {
45        &self.0
46    }
47
48    /// Returns the secret length in bytes.
49    #[must_use]
50    pub const fn len(&self) -> usize {
51        self.0.len()
52    }
53
54    /// Returns whether the secret is empty.
55    #[must_use]
56    pub const fn is_empty(&self) -> bool {
57        self.0.is_empty()
58    }
59}
60
61impl AsRef<[u8]> for SecretBytes {
62    fn as_ref(&self) -> &[u8] {
63        self.expose_secret()
64    }
65}
66
67impl From<Vec<u8>> for SecretBytes {
68    fn from(secret: Vec<u8>) -> Self {
69        Self(secret)
70    }
71}
72
73impl fmt::Debug for SecretBytes {
74    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
75        formatter.write_str("***")
76    }
77}
78
79/// Zeroizing UTF-8 secret material.
80///
81/// Constant-time equality is used for [`PartialEq`] to avoid timing side-channels.
82///
83/// # Examples
84///
85/// ```
86/// use shardline_protocol::SecretString;
87///
88/// let secret = SecretString::from_secret("bootstrap-token");
89/// assert_eq!(secret.expose_secret(), "bootstrap-token");
90/// ```
91#[derive(Clone, Eq, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
92pub struct SecretString(String);
93
94impl PartialEq for SecretString {
95    fn eq(&self, other: &Self) -> bool {
96        self.0.as_bytes().ct_eq(other.0.as_bytes()).into()
97    }
98}
99
100impl SecretString {
101    /// Wraps owned secret text.
102    #[must_use]
103    pub const fn new(secret: String) -> Self {
104        Self(secret)
105    }
106
107    /// Copies borrowed secret text into zeroizing storage.
108    ///
109    /// Note: the input `&str` memory is not zeroed; callers should independently
110    /// clear their source buffer if it contains secret data.
111    #[must_use]
112    pub fn from_secret(secret: &str) -> Self {
113        Self(secret.to_owned())
114    }
115
116    /// Returns the secret text.
117    #[must_use]
118    pub fn expose_secret(&self) -> &str {
119        &self.0
120    }
121
122    /// Returns whether the secret text is empty.
123    #[must_use]
124    pub const fn is_empty(&self) -> bool {
125        self.0.is_empty()
126    }
127}
128
129impl AsRef<str> for SecretString {
130    fn as_ref(&self) -> &str {
131        self.expose_secret()
132    }
133}
134
135impl fmt::Debug for SecretString {
136    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
137        formatter.write_str("***")
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::{SecretBytes, SecretString};
144
145    #[test]
146    fn secret_bytes_debug_redacts_contents() {
147        let secret = SecretBytes::from_slice(b"test-signing-key-32-bytes-long!!");
148
149        assert_eq!(format!("{secret:?}"), "***");
150    }
151
152    #[test]
153    fn secret_bytes_exposes_underlying_bytes() {
154        let secret = SecretBytes::from_slice(b"test-signing-key-32-bytes-long!!");
155
156        assert_eq!(secret.expose_secret(), b"test-signing-key-32-bytes-long!!");
157    }
158
159    #[test]
160    fn secret_string_debug_redacts_contents() {
161        let secret = SecretString::from_secret("bootstrap-token");
162
163        assert_eq!(format!("{secret:?}"), "***");
164    }
165
166    #[test]
167    fn secret_string_exposes_underlying_text() {
168        let secret = SecretString::from_secret("bootstrap-token");
169
170        assert_eq!(secret.expose_secret(), "bootstrap-token");
171    }
172
173    #[test]
174    fn secret_bytes_new_wraps_owned() {
175        let data = vec![1, 2, 3, 4];
176        let secret = SecretBytes::new(data);
177        assert_eq!(secret.expose_secret(), &[1, 2, 3, 4]);
178    }
179
180    #[test]
181    fn secret_bytes_len_and_is_empty() {
182        let empty = SecretBytes::new(Vec::new());
183        assert!(empty.is_empty());
184        assert_eq!(empty.len(), 0);
185
186        let non_empty = SecretBytes::from_slice(b"abc");
187        assert!(!non_empty.is_empty());
188        assert_eq!(non_empty.len(), 3);
189    }
190
191    #[test]
192    fn secret_bytes_as_ref() {
193        use std::convert::AsRef;
194        let secret = SecretBytes::from_slice(b"hello");
195        let bytes: &[u8] = secret.as_ref();
196        assert_eq!(bytes, b"hello");
197    }
198
199    #[test]
200    fn secret_string_new_wraps_owned() {
201        let secret = SecretString::new("owned".to_owned());
202        assert_eq!(secret.expose_secret(), "owned");
203    }
204
205    #[test]
206    fn secret_string_is_empty() {
207        let empty = SecretString::new(String::new());
208        assert!(empty.is_empty());
209
210        let non_empty = SecretString::from_secret("data");
211        assert!(!non_empty.is_empty());
212    }
213
214    #[test]
215    fn secret_string_as_ref() {
216        use std::convert::AsRef;
217        let secret = SecretString::from_secret("text");
218        let s: &str = secret.as_ref();
219        assert_eq!(s, "text");
220    }
221
222    #[test]
223    fn secret_bytes_clone_produces_equal_data() {
224        let a = SecretBytes::from_slice(b"secret-data");
225        let b = a.clone();
226        assert_eq!(a.expose_secret(), b.expose_secret());
227    }
228
229    #[test]
230    fn secret_string_clone_produces_equal_data() {
231        let a = SecretString::from_secret("secret-text");
232        let b = a.clone();
233        assert_eq!(a.expose_secret(), b.expose_secret());
234    }
235
236    #[test]
237    fn secret_bytes_partial_eq_compares_content() {
238        let a = SecretBytes::from_slice(b"same");
239        let b = SecretBytes::from_slice(b"same");
240        let c = SecretBytes::from_slice(b"different");
241        assert_eq!(a, b);
242        assert_ne!(a, c);
243    }
244
245    #[test]
246    fn secret_string_partial_eq_compares_content() {
247        let a = SecretString::from_secret("same");
248        let b = SecretString::from_secret("same");
249        let c = SecretString::from_secret("different");
250        assert_eq!(a, b);
251        assert_ne!(a, c);
252    }
253
254    #[test]
255    fn secret_bytes_len_various_sizes() {
256        assert_eq!(SecretBytes::new(vec![]).len(), 0);
257        assert_eq!(SecretBytes::from_slice(b"a").len(), 1);
258        assert_eq!(SecretBytes::from_slice(b"hello").len(), 5);
259        assert_eq!(SecretBytes::from_slice(&[0; 100]).len(), 100);
260    }
261
262    #[test]
263    fn secret_string_empty_vs_non_empty() {
264        assert!(SecretString::new(String::new()).is_empty());
265        assert!(!SecretString::from_secret("data").is_empty());
266    }
267
268    #[test]
269    fn secret_bytes_is_empty_various() {
270        assert!(SecretBytes::new(vec![]).is_empty());
271        assert!(!SecretBytes::from_slice(b"x").is_empty());
272    }
273
274    #[test]
275    fn secret_bytes_debug_no_content_leak() {
276        let secret = SecretBytes::from_slice(b"hunter2-password-12345");
277        let debug = format!("{secret:?}");
278        assert_eq!(debug, "***");
279        assert!(!debug.contains("hunter2"));
280    }
281
282    #[test]
283    fn secret_string_debug_no_content_leak() {
284        let secret = SecretString::from_secret("my-secret-api-token");
285        let debug = format!("{secret:?}");
286        assert_eq!(debug, "***");
287        assert!(!debug.contains("api-token"));
288    }
289
290    #[test]
291    fn secret_bytes_zeroize_clears_memory() {
292        use zeroize::Zeroize;
293        let mut secret = SecretBytes::from_slice(b"hello-world");
294        assert!(!secret.is_empty());
295        assert_eq!(secret.len(), 11);
296        // Explicitly zeroize the buffer and verify the underlying Vec is cleared.
297        secret.zeroize();
298        assert!(secret.is_empty());
299        assert_eq!(secret.len(), 0);
300        assert!(secret.expose_secret().is_empty());
301    }
302}