tirbofish/kitgit
main / src / mfa.rs · 5605 bytes
src/mfa.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
//! App-local TOTP MFA (enforced in kitgit after Authentik password succeeds).
use anyhow::{anyhow, Result};
use base64::Engine;
use data_encoding::BASE32_NOPAD;
use hmac::{Hmac, Mac};
use qrcode::render::svg;
use qrcode::QrCode;
use rand::RngExt;
use sha1::Sha1;
use sha2::{Digest, Sha256};
use std::time::{SystemTime, UNIX_EPOCH};
type HmacSha1 = Hmac<Sha1>;
const TOTP_DIGITS: u32 = 6;
const TOTP_STEP: u64 = 30;
const TOTP_WINDOW: i64 = 1;
const RECOVERY_CODE_COUNT: usize = 8;
pub fn generate_totp_secret() -> String {
let mut buf = [0u8; 20];
rand::rng().fill(&mut buf);
BASE32_NOPAD.encode(&buf)
}
pub fn otpauth_uri(username: &str, secret_base32: &str, issuer: &str) -> String {
let label_raw = format!("{issuer}:{username}");
let label = urlencoding::encode(&label_raw);
let issuer_q = urlencoding::encode(issuer);
format!(
"otpauth://totp/{label}?secret={secret_base32}&issuer={issuer_q}&digits={TOTP_DIGITS}&period={TOTP_STEP}"
)
}
pub fn qr_svg_data_uri(otpauth: &str) -> Result<String> {
let code = QrCode::new(otpauth.as_bytes()).map_err(|e| anyhow!("qr encode: {e}"))?;
let svg = code
.render::<svg::Color>()
.min_dimensions(180, 180)
.dark_color(svg::Color("#111111"))
.light_color(svg::Color("#ffffff"))
.build();
Ok(format!(
"data:image/svg+xml;base64,{}",
base64::engine::general_purpose::STANDARD.encode(svg.as_bytes())
))
}
fn hotp(secret: &[u8], counter: u64) -> Result<u32> {
let mut mac =
HmacSha1::new_from_slice(secret).map_err(|_| anyhow!("invalid hmac key"))?;
mac.update(&counter.to_be_bytes());
let result = mac.finalize().into_bytes();
let offset = (result[19] & 0x0f) as usize;
let code = ((u32::from(result[offset]) & 0x7f) << 24)
| ((u32::from(result[offset + 1]) & 0xff) << 16)
| ((u32::from(result[offset + 2]) & 0xff) << 8)
| (u32::from(result[offset + 3]) & 0xff);
Ok(code % 10u32.pow(TOTP_DIGITS))
}
fn decode_secret(secret_base32: &str) -> Result<Vec<u8>> {
let cleaned: String = secret_base32
.chars()
.filter(|c| !c.is_whitespace() && *c != '=')
.map(|c| c.to_ascii_uppercase())
.collect();
BASE32_NOPAD
.decode(cleaned.as_bytes())
.map_err(|_| anyhow!("invalid authenticator secret"))
}
fn time_counter(now: u64) -> u64 {
now / TOTP_STEP
}
pub fn verify_totp(secret_base32: &str, code: &str) -> bool {
let code = code.trim().replace(' ', "");
if code.len() != TOTP_DIGITS as usize || !code.chars().all(|c| c.is_ascii_digit()) {
return false;
}
let Ok(expected) = code.parse::<u32>() else {
return false;
};
let Ok(secret) = decode_secret(secret_base32) else {
return false;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let counter = time_counter(now) as i64;
for skew in -TOTP_WINDOW..=TOTP_WINDOW {
let c = (counter + skew) as u64;
if let Ok(n) = hotp(&secret, c) {
if n == expected {
return true;
}
}
}
false
}
pub fn generate_recovery_codes() -> Vec<String> {
let mut out = Vec::with_capacity(RECOVERY_CODE_COUNT);
let mut rng = rand::rng();
for _ in 0..RECOVERY_CODE_COUNT {
let mut buf = [0u8; 5];
rng.fill(&mut buf);
let hex = hex::encode(buf);
out.push(format!("{}-{}", &hex[..5], &hex[5..]));
}
out
}
pub fn hash_recovery_code(code: &str) -> String {
let normalized = code.trim().to_lowercase().replace([' ', '-'], "");
let mut h = Sha256::new();
h.update(normalized.as_bytes());
hex::encode(h.finalize())
}
pub fn verify_recovery_code(hashes: &[String], code: &str) -> Option<usize> {
let want = hash_recovery_code(code);
hashes.iter().position(|h| h == &want)
}
/// Normalize user-facing auth errors so "Authentik" never appears in the browser.
pub fn sanitize_user_error(msg: &str) -> String {
let lower = msg.to_lowercase();
if lower.contains("invalid username or password") {
return "invalid username or password".into();
}
if lower.contains("mfa") || lower.contains("authenticator") {
return "additional verification required".into();
}
if lower.contains("misconfigured") || lower.contains("captcha") {
return "login is temporarily unavailable".into();
}
if lower.contains("already taken") {
return "username or email already taken".into();
}
if lower.contains("too short") || lower.contains("at least") {
return msg.to_string();
}
if lower.contains("email") && lower.contains("required") {
return msg.to_string();
}
if lower.contains("account suspended") || lower.contains("suspended") {
return "this account has been suspended".into();
}
if lower.contains("invite") {
return msg.to_string();
}
if lower.contains("signup unavailable") || lower.contains("api token") {
return "signup is temporarily unavailable".into();
}
if lower.contains("signups disabled") || lower.contains("disabled new signups") {
return msg.to_string();
}
if lower.contains("authentik") || lower.contains("oidc") {
return "authentication failed".into();
}
// Keep short, safe messages; hide internal detail.
if msg.len() < 80 && !lower.contains("http") && !lower.contains("api/") {
return msg.to_string();
}
"authentication failed".into()
}