Skip to main content

recall_server/
bootstrap.rs

1//! The one-time code that, beside `RECALL_TOKEN`, registers the first
2//! passkey for `/admin`.
3//!
4//! The token alone once did, at first deploy and again after every
5//! `recall-server reset-passkeys`. The token is long-lived and sits in more
6//! places than the server (a password manager, `.env`, every machine not yet
7//! enrolled as a device), so a leaked copy could plant a passkey that
8//! survived rotating the token. The code closes that: it is printed only
9//! where the server runs, in its log when it starts with no passkey and by
10//! `reset-passkeys`, it works for an hour, and it works once. The server
11//! keeps only its SHA-256.
12
13use 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
22/// How long a code works for. Restarting a server that has no passkey, or
23/// running `reset-passkeys` again, prints a new one.
24pub const TTL: Duration = Duration::from_secs(60 * 60);
25
26/// How many characters a code has, not counting its dashes: 16 from a
27/// 20-letter alphabet is 69 bits, and the token is needed as well.
28const LENGTH: usize = 16;
29
30/// A code just issued, for printing: [`BootstrapCode::instructions`].
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct BootstrapCode {
33    /// As it is shown and typed: `BCDF-GHJK-LMNP-QRST`.
34    pub code: String,
35    /// When it stops working, in [`crate::now`]'s format.
36    pub expires_at: String,
37}
38
39impl BootstrapCode {
40    /// What the owner is told: where to use the code, and until when.
41    /// `admin_url` is where the page is, when the server knows.
42    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
54/// Makes a new code the only one, until [`TTL`] after `now`, with the
55/// server's `bootstrap_code` leaf, which says until when and never the
56/// code.
57pub 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
75/// Removes every passkey and admin session, and makes a new code the only
76/// one: `recall-server reset-passkeys`. Answers how many passkeys went.
77///
78/// Its `passkey_reset` leaf names the host as the actor: whoever ran it had
79/// a shell where the server runs, which no route gives.
80pub 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
101/// The SHA-256 the store knows a typed code by: case, dashes and spaces do
102/// not matter. [`None`] for anything that cannot be a code.
103pub 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
113/// A new code, as shown, and its hash. Letters are drawn uniformly: a byte
114/// is used only below the largest multiple of 20 that fits.
115fn 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}