use ring::hmac;
use ring::rand::{SecureRandom, SystemRandom};
use subtle::ConstantTimeEq;
use url::Url;
pub(crate) const BASE32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
pub const SECRET_LEN: usize = 20;
pub const PERIOD_SECONDS: i64 = 30;
pub const DIGITS: u32 = 6;
pub const SKEW_STEPS: i64 = 1;
pub const ISSUER: &str = "acme-proxy";
#[derive(Debug, Clone)]
pub struct Enrolment {
pub secret: Vec<u8>,
pub secret_base32: String,
pub uri: String,
}
#[must_use]
pub fn begin_enrolment(issuer: &str, account: &str) -> Enrolment {
let secret = generate_secret();
let secret_base32 = base32_encode(&secret);
let uri = provisioning_uri(&secret_base32, issuer, account);
Enrolment {
secret,
secret_base32,
uri,
}
}
#[must_use]
pub fn generate_secret() -> Vec<u8> {
let mut secret = vec![0u8; SECRET_LEN];
SystemRandom::new()
.fill(&mut secret)
.expect("system RNG unavailable");
secret
}
#[must_use]
pub fn account_label(username: &str, base_url: &str) -> String {
match Url::parse(base_url).ok().and_then(|url| {
url.host_str()
.filter(|host| !host.is_empty())
.map(str::to_string)
}) {
Some(host) => format!("{username}@{host}"),
None => username.to_string(),
}
}
#[must_use]
pub fn provisioning_uri(secret_base32: &str, issuer: &str, account: &str) -> String {
let mut url = Url::parse("otpauth://totp").expect("a constant, valid URL");
url.path_segments_mut()
.expect("otpauth://totp has an authority, so it can be a base")
.push(&format!("{issuer}:{account}"));
url.query_pairs_mut()
.append_pair("secret", secret_base32)
.append_pair("issuer", issuer)
.append_pair("algorithm", "SHA1")
.append_pair("digits", &DIGITS.to_string())
.append_pair("period", &PERIOD_SECONDS.to_string());
url.to_string()
}
#[must_use]
pub fn base32_encode(bytes: &[u8]) -> String {
let mut encoded = String::with_capacity(bytes.len().div_ceil(5) * 8);
for chunk in bytes.chunks(5) {
let mut buffer: u64 = 0;
for (index, byte) in chunk.iter().enumerate() {
buffer |= u64::from(*byte) << (32 - 8 * index);
}
let groups = match chunk.len() {
1 => 2,
2 => 4,
3 => 5,
4 => 7,
_ => 8,
};
for group in 0..groups {
let value = ((buffer >> (35 - 5 * group)) & 0x1f) as usize;
encoded.push(BASE32_ALPHABET[value] as char);
}
}
encoded
}
#[must_use]
pub fn step_at(unix_seconds: i64) -> i64 {
unix_seconds.div_euclid(PERIOD_SECONDS)
}
#[must_use]
pub fn hotp(secret: &[u8], counter: u64, digits: u32) -> String {
let key = hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, secret);
let tag = hmac::sign(&key, &counter.to_be_bytes());
let digest = tag.as_ref();
let offset = (digest[digest.len() - 1] & 0x0f) as usize;
let binary = (u32::from(digest[offset] & 0x7f) << 24)
| (u32::from(digest[offset + 1]) << 16)
| (u32::from(digest[offset + 2]) << 8)
| u32::from(digest[offset + 3]);
let digits = digits.clamp(1, 9);
let width = digits as usize;
format!("{:0width$}", binary % 10u32.pow(digits))
}
#[must_use]
pub fn totp_at(secret: &[u8], step: i64, digits: u32) -> String {
hotp(secret, step as u64, digits)
}
#[must_use]
pub fn verify(secret: &[u8], code: &str, now_unix: i64) -> Option<i64> {
let width = DIGITS as usize;
if code.len() != width || !code.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
let current = step_at(now_unix);
let mut matched: Option<i64> = None;
for step in (current - SKEW_STEPS)..=(current + SKEW_STEPS) {
let candidate = totp_at(secret, step, DIGITS);
if bool::from(candidate.as_bytes().ct_eq(code.as_bytes())) {
matched = Some(step);
}
}
matched
}
#[cfg(test)]
mod tests {
use super::*;
const RFC_SEED: &[u8] = b"12345678901234567890";
#[test]
fn rfc_4226_appendix_d_vectors() {
let expected = [
"755224", "287082", "359152", "969429", "338314", "254676", "287922", "162583",
"399871", "520489",
];
for (counter, want) in expected.iter().enumerate() {
assert_eq!(
&hotp(RFC_SEED, counter as u64, DIGITS),
want,
"HOTP counter {counter}"
);
}
}
#[test]
fn rfc_6238_appendix_b_sha1_vectors() {
let cases = [
(59_i64, "94287082"),
(1_111_111_109, "07081804"),
(1_111_111_111, "14050471"),
(1_234_567_890, "89005924"),
(2_000_000_000, "69279037"),
(20_000_000_000, "65353130"),
];
for (time, want) in cases {
assert_eq!(
totp_at(RFC_SEED, step_at(time), 8),
want,
"RFC 6238 vector at T = {time}"
);
}
}
#[test]
fn rfc_4648_base32_vectors_unpadded() {
let cases = [
("", ""),
("f", "MY"),
("fo", "MZXQ"),
("foo", "MZXW6"),
("foob", "MZXW6YQ"),
("fooba", "MZXW6YTB"),
("foobar", "MZXW6YTBOI"),
];
for (input, want) in cases {
assert_eq!(base32_encode(input.as_bytes()), want, "base32({input:?})");
}
}
#[test]
fn a_generated_secret_is_exactly_thirty_two_unpadded_characters() {
let secret = generate_secret();
assert_eq!(secret.len(), SECRET_LEN);
let encoded = base32_encode(&secret);
assert_eq!(
encoded.len(),
32,
"160 bits is chosen so the operator never has to type a `=`"
);
assert!(!encoded.contains('='));
assert!(
encoded.bytes().all(|byte| BASE32_ALPHABET.contains(&byte)),
"every character must come from the RFC 4648 alphabet"
);
assert_ne!(secret, generate_secret());
}
#[test]
fn a_code_is_accepted_one_step_either_side_and_no_further() {
let secret = generate_secret();
let now = 1_700_000_000_i64;
let current = step_at(now);
for offset in [-1_i64, 0, 1] {
let code = totp_at(&secret, current + offset, DIGITS);
assert_eq!(
verify(&secret, &code, now),
Some(current + offset),
"a code {offset} steps away must be accepted, and report its own step"
);
}
for offset in [-2_i64, 2, 10] {
let code = totp_at(&secret, current + offset, DIGITS);
assert_eq!(
verify(&secret, &code, now),
None,
"a code {offset} steps away is outside the window"
);
}
}
#[test]
fn a_wrong_shaped_code_is_refused_before_any_hmac() {
let secret = generate_secret();
let now = 1_700_000_000_i64;
let cases = [
("empty", ""),
("too short", "12345"),
("too long", "1234567"),
("not all digits", "12a456"),
("leading space", " 12345"),
("trailing space", "12345 "),
("non-ascii", "123456"),
];
for (name, code) in cases {
assert_eq!(verify(&secret, code, now), None, "case `{name}`");
}
}
#[test]
fn step_at_does_not_straddle_the_epoch() {
assert_eq!(step_at(0), 0);
assert_eq!(step_at(29), 0);
assert_eq!(step_at(30), 1);
assert_eq!(step_at(59), 1);
assert_eq!(step_at(-1), -1);
assert_eq!(step_at(-30), -1);
assert_eq!(step_at(-31), -2);
}
#[test]
fn the_provisioning_uri_carries_every_parameter_an_app_reads() {
let uri = provisioning_uri("ABCDEFGH", ISSUER, "alice@admin.example.com");
assert_eq!(
uri,
"otpauth://totp/acme-proxy:alice@admin.example.com\
?secret=ABCDEFGH&issuer=acme-proxy&algorithm=SHA1&digits=6&period=30"
);
}
#[test]
fn a_hostile_username_is_percent_encoded_into_one_path_segment() {
let uri = provisioning_uri("ABCDEFGH", ISSUER, "a/b?c#d e");
let parsed = Url::parse(&uri).expect("the built URI must parse back");
let segments: Vec<&str> = parsed
.path_segments()
.expect("otpauth:// can be a base")
.collect();
assert_eq!(
segments.len(),
1,
"a `/` in the username must not become a second path segment: {uri}"
);
let names: Vec<String> = parsed
.query_pairs()
.map(|(name, _)| name.into_owned())
.collect();
assert_eq!(names, ["secret", "issuer", "algorithm", "digits", "period"]);
}
#[test]
fn begin_enrolment_agrees_with_itself() {
let enrolment = begin_enrolment(ISSUER, "alice@admin.example.com");
assert_eq!(enrolment.secret_base32, base32_encode(&enrolment.secret));
assert!(
enrolment
.uri
.contains(&format!("secret={}", enrolment.secret_base32)),
"the URI must carry the same secret the page shows: {}",
enrolment.uri
);
let now = 1_700_000_000_i64;
let code = totp_at(&enrolment.secret, step_at(now), DIGITS);
assert!(verify(&enrolment.secret, &code, now).is_some());
}
#[test]
fn account_label_prefers_the_configured_host() {
assert_eq!(
account_label("alice", "https://admin.example.com:3001"),
"alice@admin.example.com"
);
assert_eq!(
account_label("alice", "http://localhost:3001"),
"alice@localhost"
);
assert_eq!(account_label("alice", "not a url"), "alice");
assert_eq!(account_label("alice", ""), "alice");
}
}