ndn_cert/challenge/
yubikey.rs

1//! YubiKey HOTP challenge — hardware one-time-password bootstrapping.
2//!
3//! The YubiKey's slot 2 (long-press) can be programmed to emit RFC 4226 HOTP
4//! codes via USB HID (it appears as a USB keyboard). This challenge uses that
5//! to bootstrap headless devices without any secrets stored in plaintext on the
6//! device itself.
7//!
8//! # Enrollment flow
9//!
10//! ```text
11//! Admin dashboard                   CA                   Headless router
12//!       |                            |                          |
13//!       |--- provision seed -------> |                          |
14//!       |    (stored in CA config)   |                          |
15//!       |                            |                          |
16//!       |--- ykpersonalize --------> YubiKey                   |
17//!       |    (seed programmed)       |                          |
18//!       |                            |                          |
19//!   (YubiKey shipped / plugged into headless router)           |
20//!                                    |                          |
21//!                                    |<--- NEW request -------- |
22//!                                    |--- NewResponse --------> |
23//!                                    |                          |
24//!                                    |<--- CHALLENGE (begin) -- |
25//!                                    |--- "Press YubiKey..." -> |
26//!                                    |                          |
27//!                               (operator presses YubiKey button)
28//!                                    |                          |
29//!                                    |<--- CHALLENGE (otp) ---- | (USB HID → stdin capture)
30//!                                    |--- Approved / Issued --> |
31//! ```
32//!
33//! # HOTP algorithm (RFC 4226)
34//!
35//! `HOTP(K, C) = Truncate(HMAC-SHA1(K, C)) mod 10^digits`
36//!
37//! - K: shared secret (seed, 20+ bytes recommended)
38//! - C: 8-byte big-endian counter — incremented after each valid code
39//! - Lookahead window (default 20): handles button presses that weren't captured
40
41use std::{future::Future, pin::Pin};
42
43use crate::{
44    challenge::{ChallengeHandler, ChallengeOutcome, ChallengeState},
45    error::CertError,
46    protocol::CertRequest,
47};
48
49/// Number of HOTP digits to verify.
50const DIGITS: u32 = 6;
51
52/// Default lookahead window — tolerate up to this many unsynchronised button presses.
53const DEFAULT_WINDOW: u64 = 20;
54
55/// NDNCERT challenge that verifies a YubiKey HOTP code.
56///
57/// The CA is pre-seeded with the same HMAC-SHA1 seed that was programmed into
58/// the YubiKey via `ykpersonalize`. At verification time the CA checks codes
59/// in a lookahead window and advances the counter to stay synchronised.
60pub struct YubikeyHotpChallenge {
61    /// Shared HOTP secret (same as the YubiKey HMAC-SHA1 seed).
62    seed: Vec<u8>,
63    /// Starting HOTP counter (must match YubiKey's initial counter).
64    initial_counter: u64,
65    /// Lookahead window for counter re-sync.
66    window: u64,
67    /// Maximum OTP submission attempts before the request is denied.
68    max_tries: u8,
69}
70
71impl YubikeyHotpChallenge {
72    /// Create a new challenge handler.
73    ///
74    /// - `seed`: the same secret programmed into the YubiKey with `ykpersonalize -2 -a <hex-seed>`
75    /// - `initial_counter`: must match the YubiKey's counter state (default 0 for freshly provisioned)
76    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    /// Set the lookahead window (default 20).
86    pub fn with_window(mut self, window: u64) -> Self {
87        self.window = window;
88        self
89    }
90
91    /// Set maximum submission attempts (default 3).
92    pub fn with_max_tries(mut self, max_tries: u8) -> Self {
93        self.max_tries = max_tries;
94        self
95    }
96}
97
98/// Compute one HOTP value for the given seed and counter (RFC 4226).
99fn 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(); // 20 bytes for HMAC-SHA1
104
105    // Dynamic truncation (RFC 4226 §5.3).
106    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
118/// Try to verify an OTP against `(seed, counter)` with the given window.
119///
120/// Returns `Some(matched_counter + 1)` if found — the caller should advance
121/// its stored counter to this value.
122fn 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            // Decode OTP string to u32.
192            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            // Decode stored seed.
210            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            // Verify against the HOTP counter window.
218            if let Some(next_counter) = verify_hotp(&seed, counter, window, otp) {
219                // Valid code — update counter in approved state.
220                let _ = next_counter; // Counter advance is implicit; CA config handles persistence.
221                return Ok(ChallengeOutcome::Approved);
222            }
223
224            // Invalid code.
225            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
252// ── Hex helpers (avoids adding a hex crate dep) ───────────────────────────────
253
254fn 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"; // RFC 4226 test seed
273
274    #[test]
275    fn hotp_rfc4226_test_vectors() {
276        // RFC 4226 Appendix D test vectors with seed "12345678901234567890".
277        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        // Code for counter=0.
292        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        // Code for counter=5, but CA counter is at 0 — within window of 20.
300        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        // Code for counter=25, but CA counter is at 0 with window=20 — too far ahead.
308        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, &params).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, &params).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, &params).await.unwrap();
402        assert!(matches!(outcome, ChallengeOutcome::Denied(_)));
403    }
404}