1use std::{future::Future, pin::Pin};
42
43use crate::{
44 challenge::{ChallengeHandler, ChallengeOutcome, ChallengeState},
45 error::CertError,
46 protocol::CertRequest,
47};
48
49const DIGITS: u32 = 6;
51
52const DEFAULT_WINDOW: u64 = 20;
54
55pub struct YubikeyHotpChallenge {
61 seed: Vec<u8>,
63 initial_counter: u64,
65 window: u64,
67 max_tries: u8,
69}
70
71impl YubikeyHotpChallenge {
72 pub fn new(seed: Vec<u8>, initial_counter: u64) -> Self {
77 Self {
78 seed,
79 initial_counter,
80 window: DEFAULT_WINDOW,
81 max_tries: 3,
82 }
83 }
84
85 pub fn with_window(mut self, window: u64) -> Self {
87 self.window = window;
88 self
89 }
90
91 pub fn with_max_tries(mut self, max_tries: u8) -> Self {
93 self.max_tries = max_tries;
94 self
95 }
96}
97
98fn hotp(seed: &[u8], counter: u64) -> u32 {
100 let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, seed);
101 let counter_bytes = counter.to_be_bytes();
102 let tag = ring::hmac::sign(&key, &counter_bytes);
103 let digest = tag.as_ref(); let offset = (digest[19] & 0x0f) as usize;
107 let code = u32::from_be_bytes([
108 digest[offset] & 0x7f,
109 digest[offset + 1],
110 digest[offset + 2],
111 digest[offset + 3],
112 ]);
113
114 let modulus = 10u32.pow(DIGITS);
115 code % modulus
116}
117
118fn verify_hotp(seed: &[u8], counter: u64, window: u64, otp: u32) -> Option<u64> {
123 for i in 0..=window {
124 if hotp(seed, counter + i) == otp {
125 return Some(counter + i + 1);
126 }
127 }
128 None
129}
130
131impl ChallengeHandler for YubikeyHotpChallenge {
132 fn challenge_type(&self) -> &'static str {
133 "yubikey-hotp"
134 }
135
136 fn begin<'a>(
137 &'a self,
138 _req: &'a CertRequest,
139 ) -> Pin<Box<dyn Future<Output = Result<ChallengeState, CertError>> + Send + 'a>> {
140 let seed_hex = hex_encode(&self.seed);
141 let counter = self.initial_counter;
142 let window = self.window;
143 let max_tries = self.max_tries;
144
145 Box::pin(async move {
146 Ok(ChallengeState {
147 challenge_type: "yubikey-hotp".to_string(),
148 data: serde_json::json!({
149 "seed_hex": seed_hex,
150 "counter": counter,
151 "window": window,
152 "remaining_tries": max_tries,
153 }),
154 })
155 })
156 }
157
158 fn verify<'a>(
159 &'a self,
160 state: &'a ChallengeState,
161 parameters: &'a serde_json::Map<String, serde_json::Value>,
162 ) -> Pin<Box<dyn Future<Output = Result<ChallengeOutcome, CertError>> + Send + 'a>> {
163 let otp_str = parameters
164 .get("otp")
165 .and_then(|v| v.as_str())
166 .map(str::to_string);
167
168 let seed_hex = state
169 .data
170 .get("seed_hex")
171 .and_then(|v| v.as_str())
172 .unwrap_or_default()
173 .to_string();
174 let counter = state
175 .data
176 .get("counter")
177 .and_then(|v| v.as_u64())
178 .unwrap_or(0);
179 let window = state
180 .data
181 .get("window")
182 .and_then(|v| v.as_u64())
183 .unwrap_or(DEFAULT_WINDOW);
184 let remaining_tries = state
185 .data
186 .get("remaining_tries")
187 .and_then(|v| v.as_u64())
188 .unwrap_or(1) as u8;
189
190 Box::pin(async move {
191 let otp_str = match otp_str {
193 Some(s) => s,
194 None => {
195 return Ok(ChallengeOutcome::Denied(
196 "missing 'otp' parameter".to_string(),
197 ));
198 }
199 };
200 let otp: u32 = match otp_str.trim().parse() {
201 Ok(n) => n,
202 Err(_) => {
203 return Ok(ChallengeOutcome::Denied(
204 "invalid OTP format — expected a numeric code".to_string(),
205 ));
206 }
207 };
208
209 let seed = hex_decode(&seed_hex).unwrap_or_default();
211 if seed.is_empty() {
212 return Err(CertError::InvalidRequest(
213 "corrupt HOTP challenge state".into(),
214 ));
215 }
216
217 if let Some(next_counter) = verify_hotp(&seed, counter, window, otp) {
219 let _ = next_counter; return Ok(ChallengeOutcome::Approved);
222 }
223
224 if remaining_tries <= 1 {
226 return Ok(ChallengeOutcome::Denied(
227 "YubiKey OTP verification failed: no attempts remaining".to_string(),
228 ));
229 }
230
231 let new_tries = remaining_tries - 1;
232 Ok(ChallengeOutcome::Pending {
233 status_message: format!(
234 "Invalid OTP — press the YubiKey button again ({new_tries} attempt(s) left)"
235 ),
236 remaining_tries: new_tries,
237 remaining_time_secs: 300,
238 next_state: ChallengeState {
239 challenge_type: "yubikey-hotp".to_string(),
240 data: serde_json::json!({
241 "seed_hex": seed_hex,
242 "counter": counter,
243 "window": window,
244 "remaining_tries": new_tries,
245 }),
246 },
247 })
248 })
249 }
250}
251
252fn hex_encode(bytes: &[u8]) -> String {
255 bytes.iter().map(|b| format!("{b:02x}")).collect()
256}
257
258fn hex_decode(s: &str) -> Option<Vec<u8>> {
259 if !s.len().is_multiple_of(2) {
260 return None;
261 }
262 (0..s.len())
263 .step_by(2)
264 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
265 .collect()
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 const TEST_SEED: &[u8] = b"12345678901234567890"; #[test]
275 fn hotp_rfc4226_test_vectors() {
276 let expected = [
278 755224, 287082, 359152, 969429, 338314, 254676, 287922, 162583, 399871, 520489,
279 ];
280 for (counter, &expected_code) in expected.iter().enumerate() {
281 assert_eq!(
282 hotp(TEST_SEED, counter as u64),
283 expected_code,
284 "counter={counter}"
285 );
286 }
287 }
288
289 #[test]
290 fn verify_hotp_exact_counter() {
291 let code = hotp(TEST_SEED, 0);
293 let next = verify_hotp(TEST_SEED, 0, 20, code);
294 assert_eq!(next, Some(1));
295 }
296
297 #[test]
298 fn verify_hotp_window_lookahead() {
299 let code = hotp(TEST_SEED, 5);
301 let next = verify_hotp(TEST_SEED, 0, 20, code);
302 assert_eq!(next, Some(6));
303 }
304
305 #[test]
306 fn verify_hotp_outside_window() {
307 let code = hotp(TEST_SEED, 25);
309 let next = verify_hotp(TEST_SEED, 0, 20, code);
310 assert!(next.is_none());
311 }
312
313 #[test]
314 fn hex_roundtrip() {
315 let bytes = b"hello yubikey";
316 assert_eq!(hex_decode(&hex_encode(bytes)).unwrap(), bytes);
317 }
318
319 #[tokio::test]
320 async fn begin_stores_initial_counter() {
321 let challenge = YubikeyHotpChallenge::new(TEST_SEED.to_vec(), 42);
322 let req = crate::protocol::CertRequest {
323 name: "test".to_string(),
324 public_key: String::new(),
325 not_before: 0,
326 not_after: 0,
327 };
328 let state = challenge.begin(&req).await.unwrap();
329 assert_eq!(state.data["counter"], 42);
330 }
331
332 #[tokio::test]
333 async fn verify_correct_otp_returns_approved() {
334 let seed = TEST_SEED.to_vec();
335 let counter = 0u64;
336 let otp = hotp(&seed, counter);
337
338 let challenge = YubikeyHotpChallenge::new(seed.clone(), counter);
339 let req = crate::protocol::CertRequest {
340 name: "test".to_string(),
341 public_key: String::new(),
342 not_before: 0,
343 not_after: 0,
344 };
345 let state = challenge.begin(&req).await.unwrap();
346
347 let mut params = serde_json::Map::new();
348 params.insert(
349 "otp".to_string(),
350 serde_json::Value::String(otp.to_string()),
351 );
352
353 let outcome = challenge.verify(&state, ¶ms).await.unwrap();
354 assert!(matches!(outcome, ChallengeOutcome::Approved));
355 }
356
357 #[tokio::test]
358 async fn verify_wrong_otp_decrements_tries() {
359 let challenge = YubikeyHotpChallenge::new(TEST_SEED.to_vec(), 0).with_max_tries(3);
360 let req = crate::protocol::CertRequest {
361 name: "test".to_string(),
362 public_key: String::new(),
363 not_before: 0,
364 not_after: 0,
365 };
366 let state = challenge.begin(&req).await.unwrap();
367
368 let mut params = serde_json::Map::new();
369 params.insert(
370 "otp".to_string(),
371 serde_json::Value::String("000000".to_string()),
372 );
373
374 let outcome = challenge.verify(&state, ¶ms).await.unwrap();
375 assert!(matches!(
376 outcome,
377 ChallengeOutcome::Pending {
378 remaining_tries: 2,
379 ..
380 }
381 ));
382 }
383
384 #[tokio::test]
385 async fn verify_exhausted_tries_denies() {
386 let challenge = YubikeyHotpChallenge::new(TEST_SEED.to_vec(), 0).with_max_tries(1);
387 let req = crate::protocol::CertRequest {
388 name: "test".to_string(),
389 public_key: String::new(),
390 not_before: 0,
391 not_after: 0,
392 };
393 let state = challenge.begin(&req).await.unwrap();
394
395 let mut params = serde_json::Map::new();
396 params.insert(
397 "otp".to_string(),
398 serde_json::Value::String("000000".to_string()),
399 );
400
401 let outcome = challenge.verify(&state, ¶ms).await.unwrap();
402 assert!(matches!(outcome, ChallengeOutcome::Denied(_)));
403 }
404}