recall_server/
bootstrap.rs1use std::time::Duration;
14
15use anyhow::{Context, Result};
16use recall_wire::devices::USER_CODE_ALPHABET;
17use time::OffsetDateTime;
18
19use crate::audit::leaf;
20use crate::{format_timestamp, Store};
21
22pub const TTL: Duration = Duration::from_secs(60 * 60);
25
26const LENGTH: usize = 16;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct BootstrapCode {
33 pub code: String,
35 pub expires_at: String,
37}
38
39impl BootstrapCode {
40 pub fn instructions(&self, admin_url: Option<&str>) -> String {
43 let page = admin_url.map_or_else(|| "/admin".to_string(), |u| format!("{u}/admin"));
44 format!(
45 "No passkey is registered for /admin. To register the first, open {page} and \
46 enter RECALL_TOKEN and this one-time bootstrap code:\n\n {}\n\n\
47 It works once, until {} (an hour). Restarting the server while no passkey \
48 exists, or running `recall-server reset-passkeys`, prints a new one.",
49 self.code, self.expires_at
50 )
51 }
52}
53
54pub fn issue(store: &Store, now: OffsetDateTime) -> Result<BootstrapCode> {
58 let (code, hash) = generate()?;
59 let expires_at = format_timestamp(now + TTL);
60 store
61 .set_bootstrap_code_audited(&hash, &format_timestamp(now), &expires_at, |seq, at| {
62 leaf::encode(
63 seq,
64 at,
65 leaf::action::BOOTSTRAP_CODE,
66 &leaf::Actor::Server,
67 leaf::subject_bootstrap(None, &expires_at),
68 None,
69 )
70 })
71 .context("storing the bootstrap code")?;
72 Ok(BootstrapCode { code, expires_at })
73}
74
75pub fn reset(store: &Store, now: OffsetDateTime) -> Result<(usize, BootstrapCode)> {
81 let (code, hash) = generate()?;
82 let expires_at = format_timestamp(now + TTL);
83 let removed = store.reset_admin_credentials_audited(
84 &hash,
85 &format_timestamp(now),
86 &expires_at,
87 |seq, at, removed| {
88 leaf::encode(
89 seq,
90 at,
91 leaf::action::PASSKEY_RESET,
92 &leaf::Actor::Host,
93 leaf::subject_bootstrap(Some(removed), &expires_at),
94 None,
95 )
96 },
97 )?;
98 Ok((removed, BootstrapCode { code, expires_at }))
99}
100
101pub fn sha256(typed: &str) -> Option<String> {
104 let plain: String = typed
105 .chars()
106 .filter(|c| !matches!(c, '-' | ' ' | '\t'))
107 .map(|c| c.to_ascii_uppercase())
108 .collect();
109 let ok = plain.len() == LENGTH && plain.bytes().all(|b| USER_CODE_ALPHABET.contains(&b));
110 ok.then(|| recall_wire::content_sha256(&format!("recall bootstrap code\0{plain}")))
111}
112
113fn generate() -> Result<(String, String)> {
116 let mut plain = String::with_capacity(LENGTH);
117 while plain.len() < LENGTH {
118 let mut buf = [0u8; 32];
119 getrandom::fill(&mut buf).map_err(|e| anyhow::anyhow!("no randomness: {e}"))?;
120 for b in buf {
121 if plain.len() < LENGTH && b < 240 {
122 plain.push(USER_CODE_ALPHABET[(b % 20) as usize] as char);
123 }
124 }
125 }
126 let shown = plain
127 .as_bytes()
128 .chunks(4)
129 .map(|c| std::str::from_utf8(c).expect("ASCII"))
130 .collect::<Vec<_>>()
131 .join("-");
132 let hash = sha256(&shown).context("a generated bootstrap code did not read back")?;
133 Ok((shown, hash))
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use crate::store::BootstrapCode as Check;
140
141 #[test]
142 fn a_code_reads_back_however_it_is_typed() {
143 let (shown, hash) = generate().unwrap();
144 assert_eq!(shown.len(), LENGTH + 3);
145 assert_eq!(shown.matches('-').count(), 3);
146 assert_eq!(sha256(&shown).as_deref(), Some(hash.as_str()));
147 assert_eq!(
148 sha256(&shown.to_lowercase()).as_deref(),
149 Some(hash.as_str())
150 );
151 assert_eq!(
152 sha256(&shown.replace('-', " ")).as_deref(),
153 Some(hash.as_str())
154 );
155 assert_eq!(sha256(&shown[..shown.len() - 1]), None, "too short");
156 assert_eq!(sha256("AAAA-AAAA-AAAA-AAAA"), None, "not the alphabet");
157 assert_eq!(sha256(""), None);
158 assert_ne!(generate().unwrap().0, shown);
159 }
160
161 #[test]
162 fn an_issued_code_works_for_an_hour_and_the_next_replaces_it() {
163 let st = Store::open_in_memory().unwrap();
164 let t0 = OffsetDateTime::from_unix_timestamp(1_790_000_000).unwrap();
165 let first = issue(&st, t0).unwrap();
166 let hash = sha256(&first.code).unwrap();
167 let at = |secs: i64| format_timestamp(t0 + time::Duration::seconds(secs));
168 assert_eq!(
169 st.check_bootstrap_code(&hash, &at(3599)).unwrap(),
170 Check::Valid
171 );
172 assert_eq!(
173 st.check_bootstrap_code(&hash, &at(3600)).unwrap(),
174 Check::Expired
175 );
176 let second = issue(&st, t0).unwrap();
177 assert_eq!(
178 st.check_bootstrap_code(&hash, &at(0)).unwrap(),
179 Check::Wrong
180 );
181 let text = second.instructions(Some("https://recall.example.com"));
182 assert!(text.contains(&second.code), "{text}");
183 assert!(text.contains("https://recall.example.com/admin"), "{text}");
184 assert!(text.contains(&second.expires_at), "{text}");
185 }
186}