steid

@jamesgill /

steid/src/domain/setup_token.rs
2.7 KBCode·Blame·Raw
c1a35b0feat: claim the instance on first run instead of bootstrapping from config1mo
1use std::fmt;
2
3use rand::Rng;
4use subtle::ConstantTimeEq;
5
6/// The one-time secret that authorises claiming an unclaimed installation.
7///
8/// Held in memory only: restarting an unclaimed instance rotates it, and it never
9/// reaches the database. See `plans/decisions/0002`.
10#[derive(Clone)]
11pub struct SetupToken(String);
12
13impl SetupToken {
14 /// Bytes of entropy. 32 is the same width as a session token.
15 const BYTES: usize = 32;
16
17 /// Mints a fresh token from the OS random source.
18 pub fn generate() -> Self {
19 let mut bytes = [0u8; Self::BYTES];
20 rand::rng().fill_bytes(&mut bytes);
21
22 Self(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
23 }
24
25 /// Whether a presented value matches.
26 ///
27 /// Compared in constant time. A byte-by-byte comparison that returns early leaks
28 /// how much of the token is correct, which is enough to recover it one character
29 /// at a time.
30 pub fn matches(&self, presented: &str) -> bool {
31 self.0.as_bytes().ct_eq(presented.as_bytes()).into()
32 }
33
34 /// The token, for printing to the operator exactly once.
35 pub fn reveal(&self) -> &str {
36 &self.0
37 }
38}
39
40/// Redacted, so the token cannot reach a log line except through [`reveal`](Self::reveal).
41impl fmt::Debug for SetupToken {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 f.write_str("SetupToken(redacted)")
44 }
45}
46
47#[cfg(test)]
48mod tests {
49 use super::*;
50
51 #[test]
52 fn a_token_matches_itself() {
53 let token = SetupToken::generate();
54
55 assert!(token.matches(token.reveal()));
56 }
57
58 #[test]
59 fn a_different_token_does_not_match() {
60 let token = SetupToken::generate();
61 let other = SetupToken::generate();
62
63 assert!(!token.matches(other.reveal()));
64 }
65
66 #[test]
67 fn tokens_are_unique_per_generation() {
68 assert_ne!(
69 SetupToken::generate().reveal(),
70 SetupToken::generate().reveal()
71 );
72 }
73
74 #[test]
75 fn tokens_carry_full_entropy_as_hex() {
76 let token = SetupToken::generate();
77
78 assert_eq!(token.reveal().len(), SetupToken::BYTES * 2);
79 assert!(token.reveal().chars().all(|c| c.is_ascii_hexdigit()));
80 }
81
82 #[test]
83 fn a_prefix_of_the_token_does_not_match() {
84 let token = SetupToken::generate();
85 let prefix = &token.reveal()[..16];
86
87 assert!(!token.matches(prefix));
88 }
89
90 #[test]
91 fn the_empty_string_does_not_match() {
92 assert!(!SetupToken::generate().matches(""));
93 }
94
95 #[test]
96 fn debug_output_redacts_the_token() {
97 let token = SetupToken::generate();
98
99 let rendered = format!("{token:?}");
100
101 assert_eq!(rendered, "SetupToken(redacted)");
102 assert!(!rendered.contains(token.reveal()));
103 }
104}