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:
michael
2026-04-27 11:20:00 -07:00
parent 30e36ccf5d
commit e753a33ee4
2 changed files with 101 additions and 0 deletions

View File

@@ -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";

99
daemon/src/totp/mod.rs Normal file
View 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"));
}
}