Skip to main content

auth_framework/methods/hardware_token/
mod.rs

1//! OTP-mode hardware token authentication.
2//!
3//! This module covers hardware tokens that authenticate via a **one-time password (OTP)**
4//! delivered as a plain string — currently YubiKey OTP validated against the Yubico
5//! cloud API.
6//!
7//! It also accepts `smart_card` and `piv_card` token types in its dispatch table, but
8//! those return a configuration error explaining that PC/SC-based PKI authentication
9//! cannot be reduced to a string exchange and must go through mTLS instead.
10//!
11//! # What does NOT belong here
12//!
13//! **FIDO2 / WebAuthn** is intentionally absent.  WebAuthn is a two-phase protocol
14//! (challenge generation → signed assertion) that requires credential storage and
15//! cryptographic signature verification.  Use
16//! [`PasskeyAuthMethod`](crate::methods::passkey::PasskeyAuthMethod) for that.
17
18use crate::errors::Result;
19
20/// Configuration for [`HardwareOtpToken`] validation.
21///
22/// By default, the Yubico API URL points to the official Yubico validation
23/// service. Override `yubico_validation_url` in tests to point at a mock server.
24#[derive(Debug, Clone)]
25pub struct HardwareOtpTokenConfig {
26    /// Yubico client ID — required to call the Yubico OTP validation API.
27    pub yubico_client_id: Option<String>,
28    /// Yubico HMAC-SHA1 secret key — reserved for request signing.
29    pub yubico_secret_key: Option<String>,
30    /// Base URL of the Yubico OTP validation endpoint.
31    /// Defaults to `https://api.yubico.com/wsapi/2.0/verify`.
32    pub yubico_validation_url: String,
33}
34
35impl Default for HardwareOtpTokenConfig {
36    fn default() -> Self {
37        Self {
38            yubico_client_id: None,
39            yubico_secret_key: None,
40            yubico_validation_url: "https://api.yubico.com/wsapi/2.0/verify".to_string(),
41        }
42    }
43}
44
45/// OTP-mode hardware token authenticator.
46///
47/// Supports YubiKey OTP (validated via the Yubico cloud API) and exposes
48/// `smart_card` / `piv_card` variants that return a clear error directing
49/// callers to mTLS-based authentication.
50///
51/// For FIDO2/WebAuthn use [`PasskeyAuthMethod`](crate::methods::passkey::PasskeyAuthMethod).
52pub struct HardwareOtpToken {
53    /// Device identifier
54    pub device_id: String,
55    /// Token type
56    pub token_type: String,
57    /// Optional configuration (Yubico API credentials, custom URL for tests)
58    config: Option<HardwareOtpTokenConfig>,
59}
60
61impl HardwareOtpToken {
62    /// Create a new OTP hardware token.
63    pub fn new(device_id: String, token_type: String) -> Self {
64        Self {
65            device_id,
66            token_type,
67            config: None,
68        }
69    }
70
71    /// Builder: attach a [`HardwareOtpTokenConfig`] (e.g., Yubico API credentials).
72    pub fn with_config(mut self, config: HardwareOtpTokenConfig) -> Self {
73        self.config = Some(config);
74        self
75    }
76
77    /// Authenticate using hardware token
78    pub async fn authenticate(&self, challenge: &str) -> Result<bool> {
79        // Hardware token authentication implementation
80
81        // Basic validation
82        if challenge.is_empty() {
83            return Ok(false);
84        }
85
86        // Simulate hardware token authentication process
87        match self.token_type.as_str() {
88            "yubikey" => {
89                tracing::info!("Authenticating with YubiKey device: {}", self.device_id);
90                self.validate_yubikey_response(challenge).await
91            }
92            _ => {
93                tracing::warn!(
94                    "Unknown OTP token type '{}'. HardwareOtpToken only supports 'yubikey'. \
95                     For smart card / PIV certificate authentication use ClientCertAuthMethod; \
96                     for FIDO2/WebAuthn use PasskeyAuthMethod.",
97                    self.token_type
98                );
99                Ok(false)
100            }
101        }
102    }
103
104    /// Validate YubiKey response
105    async fn validate_yubikey_response(&self, challenge: &str) -> Result<bool> {
106        tracing::debug!("Validating YubiKey response for challenge: {}", challenge);
107
108        // YubiKey OTP format: starts with the 12-char device prefix (modhex) followed
109        // by a 32-char encrypted OTP — total 44 characters.  The default public-ID
110        // prefix for most keys shipped by Yubico starts with "cccc", but any 44-char
111        // modhex string is structurally valid.
112        if !challenge.starts_with("cccc") || challenge.len() != 44 {
113            tracing::warn!(
114                "YubiKey validation failed — invalid OTP format \
115                (expected 44-char modhex starting with 'cccc')"
116            );
117            return Ok(false);
118        }
119
120        // If the caller supplied API credentials, validate against the Yubico cloud.
121        if let Some(cfg) = &self.config
122            && let Some(client_id) = &cfg.yubico_client_id
123        {
124            return self
125                .validate_yubikey_via_api(challenge, client_id, &cfg.yubico_validation_url)
126                .await;
127        }
128
129        // No API credentials configured: format alone proves nothing.
130        // A well-formed OTP can be constructed by anyone; without the Yubico
131        // validation API (or equivalent HMAC verification) we cannot confirm
132        // the OTP is genuine.  Reject and tell the operator what to fix.
133        Err(crate::errors::AuthError::Configuration {
134            message: "YubiKey OTP validation requires a Yubico client_id and secret_key. \
135                      Call HardwareOtpToken::with_config(HardwareOtpTokenConfig { yubico_client_id: Some(...), \
136                      yubico_secret_key: Some(...), .. }) before authenticating."
137                .to_string(),
138            source: None,
139            help: Some(
140                "Register at https://upgrade.yubico.com/getapikey/ to obtain API credentials."
141                    .to_string(),
142            ),
143            docs_url: Some(
144                "https://developers.yubico.com/yubikey-val/Getting_Started_Writing_Clients.html"
145                    .to_string(),
146            ),
147            suggested_fix: Some(
148                "HardwareOtpToken::new(id, \"yubikey\") \
149                 .with_config(HardwareOtpTokenConfig { yubico_client_id: Some(client_id), .. })"
150                    .to_string(),
151            ),
152        })
153    }
154
155    /// Call the Yubico OTP validation API.
156    ///
157    /// Returns `true` only when the API responds with `status=OK`.
158    /// All other statuses (e.g. `REPLAYED_OTP`, `BAD_OTP`) return `false`.
159    async fn validate_yubikey_via_api(
160        &self,
161        otp: &str,
162        client_id: &str,
163        validation_url: &str,
164    ) -> Result<bool> {
165        // Generate a random nonce to prevent reply attacks.
166        let nonce = {
167            use ring::rand::{SecureRandom, SystemRandom};
168            let rng = SystemRandom::new();
169            let mut bytes = [0u8; 16];
170            rng.fill(&mut bytes).map_err(|_| {
171                crate::errors::AuthError::internal("Failed to generate nonce for Yubico request")
172            })?;
173            hex::encode(bytes)
174        };
175
176        let client = reqwest::Client::new();
177        // otp, client_id, and nonce are all modhex/hex/numeric — no URL encoding needed.
178        let url = format!(
179            "{}?id={}&otp={}&nonce={}",
180            validation_url, client_id, otp, nonce
181        );
182        let response = client.get(&url).send().await.map_err(|e| {
183            crate::errors::AuthError::internal(format!("Yubico API request failed: {}", e))
184        })?;
185
186        let body: String = response.text().await.map_err(|e: reqwest::Error| {
187            crate::errors::AuthError::internal(format!("Failed to read Yubico API response: {}", e))
188        })?;
189
190        // The Yubico API returns a CRLF-delimited key=value list.
191        // We only need the `status` line.
192        for line in body.lines() {
193            if let Some(status) = line.strip_prefix("status=") {
194                let status = status.trim();
195                return match status {
196                    "OK" => {
197                        tracing::info!("Yubico API: OTP valid");
198                        Ok(true)
199                    }
200                    "REPLAYED_OTP" => {
201                        tracing::warn!("Yubico API: OTP already used (REPLAYED_OTP)");
202                        Ok(false)
203                    }
204                    other => {
205                        tracing::warn!("Yubico API: validation rejected — status={}", other);
206                        Ok(false)
207                    }
208                };
209            }
210        }
211
212        tracing::warn!("Yubico API: response contained no status line");
213        Ok(false)
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    fn yubikey(id: &str) -> HardwareOtpToken {
222        HardwareOtpToken::new(id.to_string(), "yubikey".to_string())
223    }
224
225    // ── HardwareOtpToken::new ─────────────────────────────────────────────────
226
227    #[test]
228    fn test_new_stores_fields() {
229        let token = HardwareOtpToken::new("dev-001".to_string(), "yubikey".to_string());
230        assert_eq!(token.device_id, "dev-001");
231        assert_eq!(token.token_type, "yubikey");
232    }
233
234    // ── YubiKey ──────────────────────────────────────────────────────────────
235
236    /// A well-formed OTP (correct prefix + length) with **no API config** must
237    /// return a configuration `Err` — format alone cannot prove OTP authenticity.
238    /// (Full end-to-end validation is covered by the mockito API tests below.)
239    #[tokio::test]
240    async fn test_yubikey_valid_otp_without_config_returns_err() {
241        let token = yubikey("yk-device-001");
242        // 4 prefix chars + 40 more = 44 total — structurally valid
243        let valid_otp = format!("cccc{}", "a".repeat(40));
244        assert!(
245            token.authenticate(&valid_otp).await.is_err(),
246            "Expected Err(Configuration) when no API credentials provided"
247        );
248    }
249
250    #[tokio::test]
251    async fn test_yubikey_wrong_prefix() {
252        let token = yubikey("yk-device-001");
253        let bad_otp = format!("xxxx{}", "a".repeat(40)); // wrong prefix
254        assert!(!token.authenticate(&bad_otp).await.unwrap());
255    }
256
257    #[tokio::test]
258    async fn test_yubikey_too_short() {
259        let token = yubikey("yk-device-001");
260        assert!(!token.authenticate("cccc123").await.unwrap()); // < 44 chars
261    }
262
263    #[tokio::test]
264    async fn test_yubikey_too_long() {
265        let token = yubikey("yk-device-001");
266        let long_otp = format!("cccc{}", "a".repeat(41)); // 45 chars > 44
267        assert!(!token.authenticate(&long_otp).await.unwrap());
268    }
269
270    #[tokio::test]
271    async fn test_yubikey_empty_challenge() {
272        let token = yubikey("yk-device-001");
273        assert!(!token.authenticate("").await.unwrap());
274    }
275
276    // ── Unknown token type ───────────────────────────────────────────────────
277
278    /// The `"fido2"` token type was removed — it is not an OTP protocol.
279    /// Callers that previously used `token_type = "fido2"` now hit the unknown-type
280    /// branch and get `false`, with a tracing warning directing them to `PasskeyAuthMethod`.
281    #[tokio::test]
282    async fn test_fido2_token_type_is_unknown() {
283        let token = HardwareOtpToken::new("dev-fido2".to_string(), "fido2".to_string());
284        // Hits the `_ =>` arm — returns false, logs a warning.
285        assert!(!token.authenticate("some-challenge").await.unwrap());
286    }
287
288    #[tokio::test]
289    async fn test_unknown_token_type_returns_false() {
290        let token = HardwareOtpToken::new("dev-999".to_string(), "unknown_type".to_string());
291        assert!(!token.authenticate("some-challenge").await.unwrap());
292    }
293
294    // ── HardwareOtpTokenConfig / with_config builder ─────────────────────────
295
296    #[test]
297    fn test_hardware_token_config_default() {
298        let cfg = HardwareOtpTokenConfig::default();
299        assert!(cfg.yubico_client_id.is_none());
300        assert!(cfg.yubico_secret_key.is_none());
301        assert_eq!(
302            cfg.yubico_validation_url,
303            "https://api.yubico.com/wsapi/2.0/verify"
304        );
305    }
306
307    #[test]
308    fn test_with_config_builder() {
309        let cfg = HardwareOtpTokenConfig {
310            yubico_client_id: Some("my_id".to_string()),
311            yubico_secret_key: Some("my_secret".to_string()),
312            yubico_validation_url: "https://example.com/verify".to_string(),
313        };
314        let token = HardwareOtpToken::new("dev-001".to_string(), "yubikey".to_string())
315            .with_config(cfg.clone());
316        let stored = token.config.as_ref().unwrap();
317        assert_eq!(stored.yubico_client_id.as_deref(), Some("my_id"));
318        assert_eq!(stored.yubico_validation_url, "https://example.com/verify");
319    }
320
321    // ── YubiKey format-only fallback (no API config) ─────────────────────────
322
323    #[tokio::test]
324    async fn test_yubikey_format_only_without_api_config() {
325        // Valid format, no API config → must be rejected with a configuration error.
326        // Format alone proves nothing; the Yubico API is required.
327        let token = HardwareOtpToken::new("yk-001".to_string(), "yubikey".to_string());
328        let valid_otp = format!("cccc{}", "b".repeat(40));
329        let result = token.authenticate(&valid_otp).await;
330        assert!(
331            result.is_err(),
332            "Expected Err when no API credentials configured, got {:?}",
333            result
334        );
335    }
336
337    // ── YubiKey Yubico API tests (mockito) ────────────────────────────────────
338
339    fn make_yubikey_with_mock_url(mock_url: &str, client_id: &str) -> HardwareOtpToken {
340        let cfg = HardwareOtpTokenConfig {
341            yubico_client_id: Some(client_id.to_string()),
342            yubico_secret_key: None,
343            yubico_validation_url: mock_url.to_string(),
344        };
345        HardwareOtpToken::new("yk-mock".to_string(), "yubikey".to_string()).with_config(cfg)
346    }
347
348    /// Valid OTP format + Yubico API responds `status=OK` → should return `true`.
349    #[tokio::test]
350    async fn test_yubikey_api_ok() {
351        let mut server = mockito::Server::new_async().await;
352        let _m = server
353            .mock("GET", mockito::Matcher::Any)
354            .with_status(200)
355            .with_body("t=2024-01-01T00:00:00Z0000\nnonce=abcdef\nstatus=OK\n")
356            .create_async()
357            .await;
358
359        let token = make_yubikey_with_mock_url(&server.url(), "test_client");
360        let valid_otp = format!("cccc{}", "c".repeat(40));
361        assert!(token.authenticate(&valid_otp).await.unwrap());
362    }
363
364    /// Valid OTP format + API responds `status=REPLAYED_OTP` → `false`.
365    #[tokio::test]
366    async fn test_yubikey_api_replayed_otp() {
367        let mut server = mockito::Server::new_async().await;
368        let _m = server
369            .mock("GET", mockito::Matcher::Any)
370            .with_status(200)
371            .with_body("t=2024-01-01T00:00:00Z0000\nnonce=abcdef\nstatus=REPLAYED_OTP\n")
372            .create_async()
373            .await;
374
375        let token = make_yubikey_with_mock_url(&server.url(), "test_client");
376        let valid_otp = format!("cccc{}", "d".repeat(40));
377        assert!(!token.authenticate(&valid_otp).await.unwrap());
378    }
379
380    /// Valid OTP format + API responds `status=BAD_OTP` → `false`.
381    #[tokio::test]
382    async fn test_yubikey_api_bad_otp_status() {
383        let mut server = mockito::Server::new_async().await;
384        let _m = server
385            .mock("GET", mockito::Matcher::Any)
386            .with_status(200)
387            .with_body("t=2024-01-01T00:00:00Z0000\nnonce=abcdef\nstatus=BAD_OTP\n")
388            .create_async()
389            .await;
390
391        let token = make_yubikey_with_mock_url(&server.url(), "test_client");
392        let valid_otp = format!("cccc{}", "e".repeat(40));
393        assert!(!token.authenticate(&valid_otp).await.unwrap());
394    }
395
396    /// Bad OTP format → rejected before any API call is made.
397    /// The mock has zero expected calls; mockito will panic on drop if hit.
398    #[tokio::test]
399    async fn test_yubikey_bad_format_skips_api() {
400        let mut server = mockito::Server::new_async().await;
401        // Expect zero hits — if the API is called, the test fails.
402        let _m = server
403            .mock("GET", mockito::Matcher::Any)
404            .with_status(200)
405            .with_body("status=OK\n")
406            .expect(0)
407            .create_async()
408            .await;
409
410        let token = make_yubikey_with_mock_url(&server.url(), "test_client");
411        let bad_otp = "XXXX_this_is_not_a_valid_otp_at_all";
412        assert!(!token.authenticate(bad_otp).await.unwrap());
413    }
414}