@jpgilldev / steid

steid/src/domain/setup_token.rs
6.4 KBRaw
1use std::fmt;
2
3use rand::Rng;
4use subtle::ConstantTimeEq;
5
6use super::error::DomainError;
7
8/// The one-time secret that authorises claiming an unclaimed installation.
9///
10/// Held in memory only: restarting an unclaimed instance rotates it, and it never
11/// reaches the database. See `plans/decisions/0002`.
12#[derive(Clone)]
13pub struct SetupToken(String);
14
15impl SetupToken {
16 /// Bytes of entropy. 32 is the same width as a session token.
17 const BYTES: usize = 32;
18
19 /// Mints a fresh token from the OS random source.
20 pub fn generate() -> Self {
21 let mut bytes = [0u8; Self::BYTES];
22 rand::rng().fill_bytes(&mut bytes);
23
24 Self(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
25 }
26
27 /// Whether a presented value matches.
28 ///
29 /// Compared in constant time. A byte-by-byte comparison that returns early leaks
30 /// how much of the token is correct, which is enough to recover it one character
31 /// at a time.
32 pub fn matches(&self, presented: &str) -> bool {
33 self.0.as_bytes().ct_eq(presented.as_bytes()).into()
34 }
35
36 /// Characters an operator-supplied token must have.
37 ///
38 /// A generated token is 64 hex characters; requiring half that of a hand-supplied
39 /// one leaves room for the shapes an install script actually produces —
40 /// `openssl rand -hex 16`, a UUID, a passphrase — while staying far outside
41 /// guessing range. Below this the claim window stops being protected by the token
42 /// at all, which is the whole point of `plans/decisions/0002`.
43 const MIN_OPERATOR_CHARS: usize = 32;
44
45 /// Distinct characters required, so length alone cannot be padding.
46 ///
47 /// A crude entropy floor, not a real estimate: it rejects `aaaa…`, `0000…` and
48 /// `abababab…` without pretending to score a passphrase. Anything a random
49 /// generator produces clears it easily.
50 const MIN_DISTINCT_CHARS: usize = 8;
51
52 /// Adopts a token the operator supplied, so a scripted install can claim an
53 /// instance without scraping the log for a generated one.
54 ///
55 /// Validates rather than accepting whatever it is given: a short token turns the
56 /// claim window into something guessable, and failing loudly at startup is the
57 /// only moment anyone is watching.
58 ///
59 /// # Errors
60 ///
61 /// Returns a validation error if the value is too short, contains whitespace or
62 /// control characters, or repeats too few distinct characters. The message never
63 /// includes the value.
64 pub fn from_operator(value: &str) -> Result<Self, DomainError> {
65 let invalid = |reason: &str| DomainError::validation("setup token", reason);
66
67 if value.chars().any(|c| c.is_whitespace() || c.is_control()) {
68 return Err(invalid("must not contain whitespace or control characters"));
69 }
70
71 let characters = value.chars().count();
72 if characters < Self::MIN_OPERATOR_CHARS {
73 return Err(invalid(&format!(
74 "must be at least {} characters, and this one is {characters}",
75 Self::MIN_OPERATOR_CHARS
76 )));
77 }
78
79 let distinct: std::collections::BTreeSet<char> = value.chars().collect();
80 if distinct.len() < Self::MIN_DISTINCT_CHARS {
81 return Err(invalid(&format!(
82 "repeats too few distinct characters to be unguessable ({} of {} needed)",
83 distinct.len(),
84 Self::MIN_DISTINCT_CHARS
85 )));
86 }
87
88 Ok(Self(value.to_owned()))
89 }
90
91 /// The token, for printing to the operator exactly once.
92 pub fn reveal(&self) -> &str {
93 &self.0
94 }
95}
96
97/// Redacted, so the token cannot reach a log line except through [`reveal`](Self::reveal).
98impl fmt::Debug for SetupToken {
99 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100 f.write_str("SetupToken(redacted)")
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
109 fn a_token_matches_itself() {
110 let token = SetupToken::generate();
111
112 assert!(token.matches(token.reveal()));
113 }
114
115 #[test]
116 fn a_different_token_does_not_match() {
117 let token = SetupToken::generate();
118 let other = SetupToken::generate();
119
120 assert!(!token.matches(other.reveal()));
121 }
122
123 #[test]
124 fn tokens_are_unique_per_generation() {
125 assert_ne!(
126 SetupToken::generate().reveal(),
127 SetupToken::generate().reveal()
128 );
129 }
130
131 #[test]
132 fn tokens_carry_full_entropy_as_hex() {
133 let token = SetupToken::generate();
134
135 assert_eq!(token.reveal().len(), SetupToken::BYTES * 2);
136 assert!(token.reveal().chars().all(|c| c.is_ascii_hexdigit()));
137 }
138
139 #[test]
140 fn a_prefix_of_the_token_does_not_match() {
141 let token = SetupToken::generate();
142 let prefix = &token.reveal()[..16];
143
144 assert!(!token.matches(prefix));
145 }
146
147 #[test]
148 fn the_empty_string_does_not_match() {
149 assert!(!SetupToken::generate().matches(""));
150 }
151
152 #[test]
153 fn an_operator_token_is_adopted_as_given() {
154 let supplied = "b2a9c17e4d5f80316a7c9e2b4d8f0135";
155
156 let token = SetupToken::from_operator(supplied).expect("32 varied characters");
157
158 assert!(token.matches(supplied));
159 }
160
161 #[test]
162 fn a_short_operator_token_is_refused() {
163 let error = SetupToken::from_operator("b2a9c17e4d5f8031").expect_err("16 characters");
164
165 assert!(format!("{error}").contains("at least 32"));
166 }
167
168 #[test]
169 fn a_repetitive_operator_token_is_refused() {
170 assert!(SetupToken::from_operator(&"ab".repeat(24)).is_err());
171 }
172
173 #[test]
174 fn an_operator_token_with_whitespace_is_refused() {
175 assert!(SetupToken::from_operator("b2a9c17e4d5f8031 6a7c9e2b4d8f0135").is_err());
176 }
177
178 #[test]
179 fn a_rejected_operator_token_is_never_echoed() {
180 let secret = "short-but-secret";
181
182 let error = SetupToken::from_operator(secret).expect_err("too short");
183
184 assert!(!format!("{error}").contains(secret));
185 }
186
187 #[test]
188 fn a_generated_token_would_satisfy_the_operator_rules() {
189 let generated = SetupToken::generate();
190
191 assert!(SetupToken::from_operator(generated.reveal()).is_ok());
192 }
193
194 #[test]
195 fn debug_output_redacts_the_token() {
196 let token = SetupToken::generate();
197
198 let rendered = format!("{token:?}");
199
200 assert_eq!(rendered, "SetupToken(redacted)");
201 assert!(!rendered.contains(token.reveal()));
202 }
203}