Skip to main content

uselesskey_core/srp/
seed.rs

1//! Seed parsing and redaction primitives for uselesskey.
2//!
3//! Provides the [`Seed`] type that wraps 32 bytes of entropy used for
4//! deterministic fixture derivation. Implements `Debug` with redaction
5//! to prevent accidental leakage of seed material in logs.
6
7use alloc::string::String;
8use rand_chacha10::ChaCha20Rng;
9use rand_core10::{Rng, SeedableRng};
10
11/// Seed bytes derived from user input for deterministic fixtures.
12#[derive(Clone, Copy, Eq, PartialEq, Hash)]
13pub struct Seed(pub(crate) [u8; 32]);
14
15impl Seed {
16    /// Create a seed from raw bytes.
17    pub fn new(bytes: [u8; 32]) -> Self {
18        Self(bytes)
19    }
20
21    /// Access raw seed bytes.
22    pub fn bytes(&self) -> &[u8; 32] {
23        &self.0
24    }
25
26    /// Derive a seed from plain text.
27    ///
28    /// This hashes the provided text verbatim with BLAKE3. Unlike
29    /// [`Seed::from_env_value`], it does not trim whitespace or interpret
30    /// 64-character strings as hex.
31    pub fn from_text(text: &str) -> Self {
32        Self(*blake3::hash(text.as_bytes()).as_bytes())
33    }
34
35    /// Fill the destination buffer with deterministic bytes derived from this seed.
36    ///
37    /// This keeps RNG implementation details private while allowing callers to
38    /// derive stable byte sequences from seed material.
39    pub fn fill_bytes(&self, dest: &mut [u8]) {
40        let mut rng = ChaCha20Rng::from_seed(self.0);
41        rng.fill_bytes(dest);
42    }
43
44    /// Derive a seed from a user-provided string.
45    ///
46    /// Accepted formats:
47    /// - 64-char hex (with optional `0x` prefix)
48    /// - any other string (hashed with BLAKE3)
49    pub fn from_env_value(value: &str) -> Result<Self, String> {
50        let v = value.trim();
51
52        if let Some(hex) = hex_seed_candidate(v) {
53            return parse_hex_32(hex).map(Self);
54        }
55
56        Ok(Self::from_text(v))
57    }
58}
59
60fn hex_seed_candidate(value: &str) -> Option<&str> {
61    let hex = value
62        .strip_prefix("0x")
63        .or_else(|| value.strip_prefix("0X"))
64        .unwrap_or(value);
65
66    (hex.len() == 64).then_some(hex)
67}
68
69impl core::fmt::Debug for Seed {
70    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
71        f.write_str("Seed(**redacted**)")
72    }
73}
74
75fn parse_hex_32(hex: &str) -> Result<[u8; 32], String> {
76    fn val(c: u8) -> Option<u8> {
77        match c {
78            b'0'..=b'9' => Some(c - b'0'),
79            b'a'..=b'f' => Some(c - b'a' + 10),
80            b'A'..=b'F' => Some(c - b'A' + 10),
81            _ => None,
82        }
83    }
84
85    if hex.len() != 64 {
86        return Err(alloc::format!("expected 64 hex chars, got {}", hex.len()));
87    }
88
89    let bytes = hex.as_bytes();
90    let mut out = [0u8; 32];
91
92    for (i, chunk) in bytes.chunks_exact(2).enumerate() {
93        let hi = val(chunk[0])
94            .ok_or_else(|| alloc::format!("invalid hex char: {}", chunk[0] as char))?;
95        let lo = val(chunk[1])
96            .ok_or_else(|| alloc::format!("invalid hex char: {}", chunk[1] as char))?;
97        out[i] = (hi << 4) | lo;
98    }
99
100    Ok(out)
101}
102
103#[cfg(all(test, feature = "std"))]
104mod tests {
105    use super::{Seed, parse_hex_32};
106
107    #[test]
108    fn seed_debug_is_redacted() {
109        let seed = Seed::new([7u8; 32]);
110        assert_eq!(format!("{:?}", seed), "Seed(**redacted**)");
111    }
112
113    #[test]
114    fn parse_hex_32_rejects_wrong_length() {
115        let err = parse_hex_32("abcd").unwrap_err();
116        assert!(err.contains("expected 64 hex chars"));
117    }
118
119    #[test]
120    fn parse_hex_32_rejects_invalid_char() {
121        let mut s = "0".repeat(64);
122        s.replace_range(10..11, "g");
123
124        let err = parse_hex_32(&s).unwrap_err();
125        assert!(err.contains("invalid hex char"));
126    }
127
128    #[test]
129    fn seed_from_env_value_parses_hex_with_prefix_and_whitespace() {
130        let hex = "0x0000000000000000000000000000000000000000000000000000000000000001";
131        let seed = Seed::from_env_value(&format!("  {hex}  ")).unwrap();
132        assert_eq!(seed.bytes()[31], 1);
133        assert!(seed.bytes()[..31].iter().all(|b| *b == 0));
134    }
135
136    #[test]
137    fn seed_from_env_value_parses_uppercase_0x_prefix() {
138        let hex = "0X0000000000000000000000000000000000000000000000000000000000000001";
139        let seed = Seed::from_env_value(hex).unwrap();
140        assert_eq!(seed.bytes()[31], 1);
141        assert!(seed.bytes()[..31].iter().all(|b| *b == 0));
142    }
143
144    #[test]
145    fn seed_from_env_value_parses_uppercase_hex() {
146        let hex = "F".repeat(64);
147        let seed = Seed::from_env_value(&hex).unwrap();
148        assert!(seed.bytes().iter().all(|b| *b == 0xFF));
149    }
150
151    #[test]
152    fn string_seed_is_hashed_with_blake3() {
153        let seed = Seed::from_env_value("  deterministic-seed-value  ").unwrap();
154        let expected = blake3::hash("deterministic-seed-value".as_bytes());
155        assert_eq!(seed.bytes(), expected.as_bytes());
156    }
157
158    #[test]
159    fn from_text_hashes_verbatim_input() {
160        let text = "  deterministic-seed-value  ";
161        let seed = Seed::from_text(text);
162        let expected = blake3::hash(text.as_bytes());
163        assert_eq!(seed.bytes(), expected.as_bytes());
164        assert_ne!(seed, Seed::from_env_value(text).unwrap());
165    }
166
167    #[test]
168    fn from_text_does_not_parse_hex_shaped_strings() {
169        let text = "ab".repeat(32);
170        let seed = Seed::from_text(&text);
171        let expected = blake3::hash(text.as_bytes());
172        assert_eq!(seed.bytes(), expected.as_bytes());
173        assert_ne!(seed, Seed::from_env_value(&text).unwrap());
174    }
175
176    #[test]
177    fn parse_hex_32_lowercase_valid() {
178        let hex = "aa".repeat(32);
179        let result = parse_hex_32(&hex).unwrap();
180        assert!(result.iter().all(|b| *b == 0xAA));
181    }
182
183    #[test]
184    fn parse_hex_32_mixed_case_valid() {
185        let hex = "aAbBcCdDeEfF".repeat(5);
186        // 60 chars — pad to 64
187        let hex = format!("{hex}0000");
188        assert_eq!(hex.len(), 64);
189        assert!(parse_hex_32(&hex).is_ok());
190    }
191
192    #[test]
193    fn parse_hex_32_invalid_lo_nibble() {
194        // Valid hi nibble, invalid lo nibble at position 1
195        let mut hex = "0".repeat(64);
196        hex.replace_range(1..2, "z");
197        let err = parse_hex_32(&hex).unwrap_err();
198        assert!(err.contains("invalid hex char: z"));
199    }
200
201    #[test]
202    fn seed_equality_and_clone() {
203        let a = Seed::new([42u8; 32]);
204        let b = a;
205        assert_eq!(a, b);
206        assert_eq!(a.bytes(), b.bytes());
207    }
208
209    #[test]
210    fn seed_inequality() {
211        let a = Seed::new([1u8; 32]);
212        let b = Seed::new([2u8; 32]);
213        assert_ne!(a, b);
214    }
215
216    #[test]
217    fn seed_hash_consistent() {
218        use core::hash::{Hash, Hasher};
219        let seed = Seed::new([99u8; 32]);
220
221        let mut h1 = std::collections::hash_map::DefaultHasher::new();
222        seed.hash(&mut h1);
223        let hash1 = h1.finish();
224
225        let mut h2 = std::collections::hash_map::DefaultHasher::new();
226        seed.hash(&mut h2);
227        assert_eq!(hash1, h2.finish());
228    }
229
230    #[test]
231    fn fill_bytes_is_seed_stable() {
232        let seed = Seed::new([7u8; 32]);
233        let mut a = [0u8; 16];
234        let mut b = [0u8; 16];
235
236        seed.fill_bytes(&mut a);
237        seed.fill_bytes(&mut b);
238
239        assert_eq!(a, b);
240    }
241
242    #[test]
243    fn fill_bytes_overwrites_destination_buffer() {
244        let seed = Seed::new([7u8; 32]);
245        let mut out = [0xAA; 16];
246
247        seed.fill_bytes(&mut out);
248
249        assert_ne!(out, [0xAA; 16]);
250    }
251
252    #[test]
253    fn from_env_value_short_string_uses_blake3() {
254        let seed = Seed::from_env_value("abc").unwrap();
255        let expected = blake3::hash(b"abc");
256        assert_eq!(seed.bytes(), expected.as_bytes());
257    }
258
259    #[test]
260    fn from_env_value_63_char_non_hex_uses_blake3() {
261        // 63 chars — not 64, so falls through to blake3 hashing.
262        let input = "a".repeat(63);
263        let seed = Seed::from_env_value(&input).unwrap();
264        let expected = blake3::hash(input.as_bytes());
265        assert_eq!(seed.bytes(), expected.as_bytes());
266    }
267
268    #[test]
269    fn from_env_value_65_char_non_hex_uses_blake3() {
270        // 65 chars — not 64, so falls through to blake3 hashing.
271        let input = "a".repeat(65);
272        let seed = Seed::from_env_value(&input).unwrap();
273        let expected = blake3::hash(input.as_bytes());
274        assert_eq!(seed.bytes(), expected.as_bytes());
275    }
276
277    #[test]
278    fn from_env_value_short_0x_prefixed_string_hashes_original_input() -> Result<(), String> {
279        let input = "0xabc";
280        let seed = Seed::from_env_value(input)?;
281        let expected = blake3::hash(input.as_bytes());
282        assert_eq!(seed.bytes(), expected.as_bytes());
283        Ok(())
284    }
285
286    #[test]
287    fn from_env_value_invalid_length_0x_prefixed_hex_hashes_original_input() -> Result<(), String> {
288        let input = format!("0x{}", "a".repeat(63));
289        let seed = Seed::from_env_value(&input)?;
290        let expected = blake3::hash(input.as_bytes());
291        assert_eq!(seed.bytes(), expected.as_bytes());
292        Ok(())
293    }
294
295    #[test]
296    fn from_env_value_64_char_invalid_hex_returns_error() {
297        // 64 chars but not valid hex — parse_hex_32 error path.
298        let input = "g".repeat(64);
299        assert!(Seed::from_env_value(&input).is_err());
300    }
301}