| 1 | use std::fmt; |
| 2 | |
| 3 | use rand::Rng; |
| 4 | use subtle::ConstantTimeEq; |
| 5 | |
| 6 | use 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)] |
| 13 | pub struct SetupToken(String); |
| 14 | |
| 15 | impl 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). |
| 98 | impl 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)] |
| 105 | mod 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 | } |