use super::random_bytes;
use crate::auth::{b64url, b64url_decode};
use ring::hmac;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Purpose {
ConfirmEmail,
ResetPassword,
ChangeEmail(String),
ChangePhone(String),
TwoFactor,
}
impl Purpose {
pub fn name(&self) -> String {
match self {
Purpose::ConfirmEmail => "EmailConfirmation".into(),
Purpose::ResetPassword => "ResetPassword".into(),
Purpose::ChangeEmail(e) => format!("ChangeEmail:{e}"),
Purpose::ChangePhone(p) => format!("ChangePhoneNumber:{p}"),
Purpose::TwoFactor => "TwoFactor".into(),
}
}
}
const TOKEN_VERSION: u8 = 1;
pub(crate) struct Keys {
token: hmac::Key,
code: hmac::Key,
recovery: hmac::Key,
stamp: hmac::Key,
}
impl Keys {
pub(crate) fn new(secret: &[u8]) -> Keys {
let root = hmac::Key::new(hmac::HMAC_SHA256, secret);
let derive = |label: &[u8]| hmac::Key::new(hmac::HMAC_SHA256, hmac::sign(&root, label).as_ref());
Keys {
token: derive(b"zeyn accounts purpose token v1"),
code: derive(b"zeyn accounts short code v1"),
recovery: derive(b"zeyn accounts recovery code v1"),
stamp: derive(b"zeyn accounts stamp check v1"),
}
}
fn token_mac(&self, created: i64, user: &str, purpose: &str, stamp: &str) -> hmac::Tag {
let mut ctx = hmac::Context::with_key(&self.token);
ctx.update(&created.to_be_bytes());
for part in [user, purpose, stamp] {
ctx.update(&(part.len() as u32).to_be_bytes());
ctx.update(part.as_bytes());
}
ctx.sign()
}
pub(crate) fn token(&self, now: i64, user: &str, purpose: &Purpose, stamp: &str) -> String {
let mut out = Vec::with_capacity(41);
out.push(TOKEN_VERSION);
out.extend_from_slice(&now.to_be_bytes());
out.extend_from_slice(self.token_mac(now, user, &purpose.name(), stamp).as_ref());
b64url(&out)
}
pub(crate) fn check_token(
&self,
token: &str,
now: i64,
lifetime: i64,
user: &str,
purpose: &Purpose,
stamp: &str,
) -> bool {
if token.len() > 128 {
return false;
}
let Some(bytes) = b64url_decode(token.trim()) else { return false };
if bytes.len() != 41 || bytes[0] != TOKEN_VERSION {
return false;
}
let created = i64::from_be_bytes(bytes[1..9].try_into().unwrap_or_default());
if created > now + 60 || created + lifetime < now {
return false;
}
let mut ctx = hmac::Context::with_key(&self.token);
ctx.update(&created.to_be_bytes());
for part in [user, purpose.name().as_str(), stamp] {
ctx.update(&(part.len() as u32).to_be_bytes());
ctx.update(part.as_bytes());
}
let expected = ctx.sign();
crate::server::same_bytes(expected.as_ref(), &bytes[9..])
}
fn code_key(&self, stamp: &str) -> Vec<u8> {
hmac::sign(&self.code, stamp.as_bytes()).as_ref().to_vec()
}
pub(crate) fn short_code(&self, now: i64, stamp: &str, modifier: &str) -> String {
let step = (now.max(0) as u64) / SHORT_STEP;
format!("{:06}", totp(&self.code_key(stamp), step, modifier.as_bytes()))
}
pub(crate) fn check_short_code(&self, code: &str, now: i64, stamp: &str, modifier: &str) -> bool {
let Some(code) = six_digits(code) else { return false };
let key = self.code_key(stamp);
let step = (now.max(0) as u64) / SHORT_STEP;
let mut hit = false;
for i in -2i64..=2 {
let s = (step as i64 + i).max(0) as u64;
hit |= totp(&key, s, modifier.as_bytes()) == code;
}
hit
}
pub(crate) fn recovery_hash(&self, code: &str) -> String {
let normal: String = code.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_uppercase).collect();
hex(hmac::sign(&self.recovery, normal.as_bytes()).as_ref())
}
pub(crate) fn stamp_tag(&self, user: &str, stamp: &str) -> String {
let mut ctx = hmac::Context::with_key(&self.stamp);
ctx.update(&(user.len() as u32).to_be_bytes());
ctx.update(user.as_bytes());
ctx.update(stamp.as_bytes());
b64url(&ctx.sign().as_ref()[..16])
}
}
const SHORT_STEP: u64 = 180;
pub(crate) const AUTH_STEP: u64 = 30;
fn six_digits(code: &str) -> Option<u32> {
let code: String = code.chars().filter(|c| !c.is_whitespace()).collect();
if code.len() != 6 || !code.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
code.parse().ok()
}
fn totp(key: &[u8], step: u64, modifier: &[u8]) -> u32 {
let k = hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, key);
let mut ctx = hmac::Context::with_key(&k);
ctx.update(&step.to_be_bytes());
ctx.update(modifier);
let hash = ctx.sign();
let h = hash.as_ref();
let offset = (h[h.len() - 1] & 0x0f) as usize;
let binary = (u32::from(h[offset]) & 0x7f) << 24
| u32::from(h[offset + 1]) << 16
| u32::from(h[offset + 2]) << 8
| u32::from(h[offset + 3]);
binary % 1_000_000
}
pub(crate) fn authenticator_code(key: &[u8], step: u64) -> u32 {
totp(key, step, &[])
}
pub(crate) fn check_authenticator(key: &[u8], code: &str, now: i64, last: u64) -> Option<u64> {
let code = six_digits(code)?;
let step = (now.max(0) as u64) / AUTH_STEP;
let mut found = None;
for i in -2i64..=2 {
let s = (step as i64 + i).max(0) as u64;
if authenticator_code(key, s) == code && s > last && found.is_none() {
found = Some(s);
}
}
found
}
const RECOVERY_ALPHABET: &[u8; 26] = b"23456789BCDFGHJKMNPQRTVWXY";
pub(crate) fn recovery_code() -> String {
let mut out = String::with_capacity(11);
while out.len() < 11 {
if out.len() == 5 {
out.push('-');
continue;
}
let [b] = random_bytes::<1>();
if b < 26 * 9 {
out.push(RECOVERY_ALPHABET[(b % 26) as usize] as char);
}
}
out
}
pub(crate) fn authenticator_key() -> String {
crate::auth::totp::base32(&random_bytes::<20>())
}
pub(crate) fn base32_decode(s: &str) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(s.len() * 5 / 8);
let (mut buffer, mut bits) = (0u32, 0u32);
for c in s.chars().filter(|c| !c.is_whitespace() && *c != '=') {
let v = match c.to_ascii_uppercase() {
c @ 'A'..='Z' => c as u32 - 'A' as u32,
c @ '2'..='7' => c as u32 - '2' as u32 + 26,
_ => return None,
};
buffer = (buffer << 5) | v;
bits += 5;
if bits >= 8 {
bits -= 8;
out.push((buffer >> bits) as u8);
}
}
Some(out)
}
pub(crate) fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rfc6238_sha1_vectors_match() {
let key = b"12345678901234567890";
assert_eq!(authenticator_code(key, 59 / 30), 287082);
assert_eq!(authenticator_code(key, 1111111109 / 30), 81804);
assert_eq!(authenticator_code(key, 2000000000 / 30), 279037);
}
#[test]
fn base32_round_trips() {
let bytes = random_bytes::<20>();
assert_eq!(base32_decode(&crate::auth::totp::base32(&bytes)).unwrap(), bytes);
}
#[test]
fn recovery_codes_have_the_dotnet_shape() {
for _ in 0..50 {
let c = recovery_code();
assert_eq!(c.len(), 11);
assert_eq!(&c[5..6], "-");
assert!(c.chars().filter(|c| *c != '-').all(|c| RECOVERY_ALPHABET.contains(&(c as u8))));
}
}
}