feat(daemon): TOTP secret generation + base32 + otpauth URI (feature-gated)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
99
daemon/src/totp/mod.rs
Normal file
99
daemon/src/totp/mod.rs
Normal file
@@ -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/<Issuer>:<account>?secret=<b32>&issuer=<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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user