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:
@@ -10,6 +10,8 @@ mod polkit;
|
|||||||
mod recovery;
|
mod recovery;
|
||||||
mod state;
|
mod state;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
#[cfg(feature = "totp")]
|
||||||
|
mod totp;
|
||||||
|
|
||||||
const BUS_NAME: &str = "io.dangerousthings.AuthForge";
|
const BUS_NAME: &str = "io.dangerousthings.AuthForge";
|
||||||
const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge";
|
const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge";
|
||||||
|
|||||||
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