use std::time::Duration;
use anyhow::{Context, Result};
use recall_wire::devices::USER_CODE_ALPHABET;
use time::OffsetDateTime;
use crate::audit::leaf;
use crate::{format_timestamp, Store};
pub const TTL: Duration = Duration::from_secs(60 * 60);
const LENGTH: usize = 16;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BootstrapCode {
pub code: String,
pub expires_at: String,
}
impl BootstrapCode {
pub fn instructions(&self, admin_url: Option<&str>) -> String {
let page = admin_url.map_or_else(|| "/admin".to_string(), |u| format!("{u}/admin"));
format!(
"No passkey is registered for /admin. To register the first, open {page} and \
enter RECALL_TOKEN and this one-time bootstrap code:\n\n {}\n\n\
It works once, until {} (an hour). Restarting the server while no passkey \
exists, or running `recall-server reset-passkeys`, prints a new one.",
self.code, self.expires_at
)
}
}
pub fn issue(store: &Store, now: OffsetDateTime) -> Result<BootstrapCode> {
let (code, hash) = generate()?;
let expires_at = format_timestamp(now + TTL);
store
.set_bootstrap_code_audited(&hash, &format_timestamp(now), &expires_at, |seq, at| {
leaf::encode(
seq,
at,
leaf::action::BOOTSTRAP_CODE,
&leaf::Actor::Server,
leaf::subject_bootstrap(None, &expires_at),
None,
)
})
.context("storing the bootstrap code")?;
Ok(BootstrapCode { code, expires_at })
}
pub fn reset(store: &Store, now: OffsetDateTime) -> Result<(usize, BootstrapCode)> {
let (code, hash) = generate()?;
let expires_at = format_timestamp(now + TTL);
let removed = store.reset_admin_credentials_audited(
&hash,
&format_timestamp(now),
&expires_at,
|seq, at, removed| {
leaf::encode(
seq,
at,
leaf::action::PASSKEY_RESET,
&leaf::Actor::Host,
leaf::subject_bootstrap(Some(removed), &expires_at),
None,
)
},
)?;
Ok((removed, BootstrapCode { code, expires_at }))
}
pub fn sha256(typed: &str) -> Option<String> {
let plain: String = typed
.chars()
.filter(|c| !matches!(c, '-' | ' ' | '\t'))
.map(|c| c.to_ascii_uppercase())
.collect();
let ok = plain.len() == LENGTH && plain.bytes().all(|b| USER_CODE_ALPHABET.contains(&b));
ok.then(|| recall_wire::content_sha256(&format!("recall bootstrap code\0{plain}")))
}
fn generate() -> Result<(String, String)> {
let mut plain = String::with_capacity(LENGTH);
while plain.len() < LENGTH {
let mut buf = [0u8; 32];
getrandom::fill(&mut buf).map_err(|e| anyhow::anyhow!("no randomness: {e}"))?;
for b in buf {
if plain.len() < LENGTH && b < 240 {
plain.push(USER_CODE_ALPHABET[(b % 20) as usize] as char);
}
}
}
let shown = plain
.as_bytes()
.chunks(4)
.map(|c| std::str::from_utf8(c).expect("ASCII"))
.collect::<Vec<_>>()
.join("-");
let hash = sha256(&shown).context("a generated bootstrap code did not read back")?;
Ok((shown, hash))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::BootstrapCode as Check;
#[test]
fn a_code_reads_back_however_it_is_typed() {
let (shown, hash) = generate().unwrap();
assert_eq!(shown.len(), LENGTH + 3);
assert_eq!(shown.matches('-').count(), 3);
assert_eq!(sha256(&shown).as_deref(), Some(hash.as_str()));
assert_eq!(
sha256(&shown.to_lowercase()).as_deref(),
Some(hash.as_str())
);
assert_eq!(
sha256(&shown.replace('-', " ")).as_deref(),
Some(hash.as_str())
);
assert_eq!(sha256(&shown[..shown.len() - 1]), None, "too short");
assert_eq!(sha256("AAAA-AAAA-AAAA-AAAA"), None, "not the alphabet");
assert_eq!(sha256(""), None);
assert_ne!(generate().unwrap().0, shown);
}
#[test]
fn an_issued_code_works_for_an_hour_and_the_next_replaces_it() {
let st = Store::open_in_memory().unwrap();
let t0 = OffsetDateTime::from_unix_timestamp(1_790_000_000).unwrap();
let first = issue(&st, t0).unwrap();
let hash = sha256(&first.code).unwrap();
let at = |secs: i64| format_timestamp(t0 + time::Duration::seconds(secs));
assert_eq!(
st.check_bootstrap_code(&hash, &at(3599)).unwrap(),
Check::Valid
);
assert_eq!(
st.check_bootstrap_code(&hash, &at(3600)).unwrap(),
Check::Expired
);
let second = issue(&st, t0).unwrap();
assert_eq!(
st.check_bootstrap_code(&hash, &at(0)).unwrap(),
Check::Wrong
);
let text = second.instructions(Some("https://recall.example.com"));
assert!(text.contains(&second.code), "{text}");
assert!(text.contains("https://recall.example.com/admin"), "{text}");
assert!(text.contains(&second.expires_at), "{text}");
}
}