diff --git a/daemon/src/main.rs b/daemon/src/main.rs index bdb4397..8a9f326 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -10,6 +10,8 @@ mod polkit; mod recovery; mod state; mod storage; +#[cfg(feature = "totp")] +mod totp; const BUS_NAME: &str = "io.dangerousthings.AuthForge"; const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge"; diff --git a/daemon/src/totp/mod.rs b/daemon/src/totp/mod.rs new file mode 100644 index 0000000..20d50b7 --- /dev/null +++ b/daemon/src/totp/mod.rs @@ -0,0 +1,99 @@ +//! Pure TOTP logic: generate 160-bit secret, base32-encode, render +//! the otpauth:// URI for QR display. No verification — that's +//! pam_google_authenticator's job at PAM time. + +#![allow(dead_code)] // wired through TotpStore (Task 3) and AppState (Task 4). + +use data_encoding::BASE32_NOPAD; +use rand::RngCore; + +/// 160 bits per RFC 6238 §5.1. +pub(crate) const SECRET_BYTES: usize = 20; + +pub(crate) fn generate_secret() -> [u8; SECRET_BYTES] { + let mut buf = [0u8; SECRET_BYTES]; + rand::rng().fill_bytes(&mut buf); + buf +} + +pub(crate) fn encode_secret(secret: &[u8]) -> String { + BASE32_NOPAD.encode(secret) +} + +/// Build an `otpauth://` URI suitable for QR encoding. +/// Format follows Google Authenticator's de-facto spec: +/// otpauth://totp/:?secret=&issuer= +pub(crate) fn otpauth_uri(secret_b32: &str, account: &str, issuer: &str) -> String { + let acct = url_encode(account); + let iss = url_encode(issuer); + format!( + "otpauth://totp/{iss}:{acct}?secret={secret_b32}&issuer={iss}&algorithm=SHA1&digits=6&period=30" + ) +} + +/// Minimal RFC 3986 url-encoder for the small set of characters that show +/// up in usernames + the literal "AuthForge" issuer. Avoids pulling in a +/// percent-encoding crate just for this one call site. +fn url_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(c), + _ => { + let mut buf = [0u8; 4]; + for b in c.encode_utf8(&mut buf).bytes() { + out.push_str(&format!("%{b:02X}")); + } + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_secret_is_160_bits() { + let s = generate_secret(); + assert_eq!(s.len(), 20); + // Two consecutive draws should differ with overwhelming probability. + let s2 = generate_secret(); + assert_ne!(s, s2); + } + + #[test] + fn base32_round_trips() { + let s = generate_secret(); + let enc = encode_secret(&s); + let dec = BASE32_NOPAD.decode(enc.as_bytes()).unwrap(); + assert_eq!(dec, s); + } + + #[test] + fn base32_uses_no_padding_uppercase() { + let s = [0u8; 20]; + let enc = encode_secret(&s); + assert!(enc + .chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())); + assert!(!enc.contains('=')); + } + + #[test] + fn otpauth_uri_includes_account_and_issuer() { + let uri = otpauth_uri("ABCDEFGH", "alice", "AuthForge"); + assert!(uri.starts_with("otpauth://totp/AuthForge:alice?")); + assert!(uri.contains("secret=ABCDEFGH")); + assert!(uri.contains("issuer=AuthForge")); + } + + #[test] + fn url_encode_handles_special_chars() { + // Realistic-ish: a username with a dot and a space (rare on Linux, + // but the encoder must not silently drop bytes). + let uri = otpauth_uri("S", "user name", "AuthForge"); + assert!(uri.contains("user%20name")); + } +}