forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
8.4 KiB
1//! Two-factor helpers: TOTP (RFC 6238) over HMAC-SHA1 with 6 digits and a 30 second step,
2//! RFC 4648 base32 for the shared secret, and recovery codes.
3//!
4//! Codes are checked at the current step and one step either side. The caller records the
5//! matched step so that a code is accepted only once (see `Store::accept_totp_step`).
6
7use hmac::{Hmac, KeyInit, Mac};
8use sha1::Sha1;
9use sha2::{Digest, Sha256};
10
11use crate::error::{Error, Result};
12use crate::token::{constant_time_eq, random_readable};
13
14/// Shared secret length in bytes (160 bits, the RFC 4226 recommendation).
15pub const SECRET_LEN: usize = 20;
16/// Seconds per time step.
17pub const STEP_SECS: u64 = 30;
18/// Digits in a code.
19pub const DIGITS: usize = 6;
20/// Steps accepted on each side of the current one.
21pub const SKEW_STEPS: i64 = 1;
22
23const BASE32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
24
25type HmacSha1 = Hmac<Sha1>;
26
27/// A fresh random secret.
28pub fn new_secret() -> [u8; SECRET_LEN] {
29 let mut secret = [0u8; SECRET_LEN];
30 rand::fill(&mut secret);
31 secret
32}
33
34/// HOTP (RFC 4226) with HMAC-SHA1, reduced to [`DIGITS`] digits.
35pub fn hotp(secret: &[u8], counter: u64) -> u32 {
36 // HMAC accepts keys of any length, so construction cannot fail.
37 let mut mac = <HmacSha1 as KeyInit>::new_from_slice(secret).expect("HMAC takes any key length");
38 mac.update(&counter.to_be_bytes());
39 let tag = mac.finalize().into_bytes();
40 let offset = usize::from(tag[tag.len() - 1] & 0x0f);
41 let binary = u32::from_be_bytes([
42 tag[offset] & 0x7f,
43 tag[offset + 1],
44 tag[offset + 2],
45 tag[offset + 3],
46 ]);
47 binary % 10u32.pow(DIGITS as u32)
48}
49
50/// The time step containing `unix_secs`.
51pub fn step_for(unix_secs: u64) -> u64 {
52 unix_secs / STEP_SECS
53}
54
55/// The zero-padded code for one step.
56pub fn code_at(secret: &[u8], step: u64) -> String {
57 format!("{:0width$}", hotp(secret, step), width = DIGITS)
58}
59
60/// Checks `code` against the steps around `now_step`. Returns the matched step when it is
61/// later than `last_step`, which is the replay guard. Any malformed code returns `None`.
62pub fn verify(secret: &[u8], code: &str, now_step: u64, last_step: u64) -> Option<u64> {
63 if code.len() != DIGITS || !code.bytes().all(|b| b.is_ascii_digit()) {
64 return None;
65 }
66 let now = i64::try_from(now_step).ok()?;
67 let mut matched = None;
68 // Every candidate is compared, so timing does not reveal which step matched.
69 for delta in -SKEW_STEPS..=SKEW_STEPS {
70 let Some(step) = now.checked_add(delta).and_then(|s| u64::try_from(s).ok()) else {
71 continue;
72 };
73 let expected = code_at(secret, step);
74 if constant_time_eq(expected.as_bytes(), code.as_bytes()) && matched.is_none() {
75 matched = Some(step);
76 }
77 }
78 matched.filter(|&step| step > last_step)
79}
80
81/// RFC 4648 base32, uppercase, without padding. This is the form authenticator apps expect.
82pub fn base32_encode(data: &[u8]) -> String {
83 let mut out = String::with_capacity(data.len().div_ceil(5) * 8);
84 let mut buffer: u64 = 0;
85 let mut bits: u32 = 0;
86 for &byte in data {
87 buffer = (buffer << 8) | u64::from(byte);
88 bits += 8;
89 while bits >= 5 {
90 bits -= 5;
91 out.push(char::from(
92 BASE32_ALPHABET[((buffer >> bits) & 31) as usize],
93 ));
94 }
95 }
96 if bits > 0 {
97 out.push(char::from(
98 BASE32_ALPHABET[((buffer << (5 - bits)) & 31) as usize],
99 ));
100 }
101 out
102}
103
104/// Decodes base32 case-insensitively. Spaces and `=` padding are ignored.
105pub fn base32_decode(input: &str) -> Result<Vec<u8>> {
106 let mut out = Vec::with_capacity(input.len() * 5 / 8);
107 let mut buffer: u64 = 0;
108 let mut bits: u32 = 0;
109 for c in input.chars() {
110 if c.is_whitespace() || c == '=' {
111 continue;
112 }
113 let upper = c.to_ascii_uppercase();
114 let value = BASE32_ALPHABET
115 .iter()
116 .position(|&a| char::from(a) == upper)
117 .ok_or_else(|| Error::Invalid("secret is not valid base32".into()))?;
118 buffer = (buffer << 5) | value as u64;
119 bits += 5;
120 if bits >= 8 {
121 bits -= 8;
122 out.push(((buffer >> bits) & 0xff) as u8);
123 }
124 }
125 Ok(out)
126}
127
128/// The `otpauth://` URI that authenticator apps import. `account` is the username.
129pub fn otpauth_uri(account: &str, secret: &[u8]) -> String {
130 format!(
131 "otpauth://totp/forge:{account}?secret={}&issuer=forge",
132 base32_encode(secret)
133 )
134}
135
136/// A new recovery code, shown as `xxxx-xxxx`.
137pub fn new_recovery_code() -> String {
138 let raw = random_readable(8);
139 format!("{}-{}", &raw[..4], &raw[4..])
140}
141
142/// Hex sha256 of a recovery code after normalization: lowercase, with dashes and spaces removed.
143/// Used both when storing and when checking, so the typed form does not matter.
144pub fn recovery_code_hash(code: &str) -> String {
145 let normalized: String = code
146 .chars()
147 .filter(|c| !c.is_whitespace() && *c != '-')
148 .map(|c| c.to_ascii_lowercase())
149 .collect();
150 hex::encode(Sha256::digest(normalized.as_bytes()))
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 /// RFC 6238 appendix B, SHA-1 row. The 8-digit codes reduce to the 6-digit ones below.
158 const RFC_KEY: &[u8] = b"12345678901234567890";
159
160 #[test]
161 fn rfc6238_sha1_test_vectors() {
162 let vectors: [(u64, &str); 6] = [
163 (59, "287082"),
164 (1_111_111_109, "081804"),
165 (1_111_111_111, "050471"),
166 (1_234_567_890, "005924"),
167 (2_000_000_000, "279037"),
168 (20_000_000_000, "353130"),
169 ];
170 for (time, expected) in vectors {
171 assert_eq!(code_at(RFC_KEY, time / STEP_SECS), expected, "time {time}");
172 }
173 }
174
175 #[test]
176 fn rfc4226_hotp_test_vectors() {
177 let expected = [
178 755_224, 287_082, 359_152, 969_429, 338_314, 254_676, 287_922, 162_583, 399_871,
179 520_489,
180 ];
181 for (counter, code) in expected.into_iter().enumerate() {
182 assert_eq!(hotp(RFC_KEY, counter as u64), code, "counter {counter}");
183 }
184 }
185
186 #[test]
187 fn base32_rfc4648_vectors() {
188 let cases = [
189 ("", ""),
190 ("f", "MY"),
191 ("fo", "MZXQ"),
192 ("foo", "MZXW6"),
193 ("foob", "MZXW6YQ"),
194 ("fooba", "MZXW6YTB"),
195 ("foobar", "MZXW6YTBOI"),
196 ];
197 for (plain, encoded) in cases {
198 assert_eq!(base32_encode(plain.as_bytes()), encoded);
199 assert_eq!(base32_decode(encoded).unwrap(), plain.as_bytes());
200 assert_eq!(
201 base32_decode(&encoded.to_lowercase()).unwrap(),
202 plain.as_bytes()
203 );
204 }
205 assert!(base32_decode("MZ1Q").is_err());
206 }
207
208 #[test]
209 fn verify_accepts_the_skew_window_and_returns_the_step() {
210 let now = 1_000;
211 for step in [now - 1, now, now + 1] {
212 let code = code_at(RFC_KEY, step);
213 assert_eq!(verify(RFC_KEY, &code, now, 0), Some(step));
214 }
215 let far = code_at(RFC_KEY, now + 2);
216 assert_eq!(verify(RFC_KEY, &far, now, 0), None);
217 }
218
219 #[test]
220 fn verify_rejects_steps_at_or_before_the_last_used_one() {
221 let now = 1_000;
222 let code = code_at(RFC_KEY, now);
223 assert_eq!(verify(RFC_KEY, &code, now, now), None);
224 assert_eq!(verify(RFC_KEY, &code, now, now + 1), None);
225 assert_eq!(verify(RFC_KEY, &code, now, now - 1), Some(now));
226 }
227
228 #[test]
229 fn verify_rejects_malformed_codes() {
230 for bad in ["", "12345", "1234567", "12345a", " 28708", "-87082"] {
231 assert_eq!(verify(RFC_KEY, bad, 1_000, 0), None, "input {bad:?}");
232 }
233 }
234
235 #[test]
236 fn otpauth_uri_carries_the_base32_secret() {
237 let uri = otpauth_uri("alice", b"foobar");
238 assert_eq!(
239 uri,
240 "otpauth://totp/forge:alice?secret=MZXW6YTBOI&issuer=forge"
241 );
242 }
243
244 #[test]
245 fn recovery_codes_are_dashed_and_hash_normalized() {
246 let code = new_recovery_code();
247 assert_eq!(code.len(), 9);
248 assert_eq!(code.as_bytes()[4], b'-');
249 assert_eq!(
250 recovery_code_hash(&code),
251 recovery_code_hash(&code.to_uppercase().replace('-', " "))
252 );
253 assert_ne!(
254 recovery_code_hash("abcd-efgh"),
255 recovery_code_hash("abcd-efgj")
256 );
257 }
258}