auth_framework/methods/hardware_token/
mod.rs1use crate::errors::Result;
19
20#[derive(Debug, Clone)]
25pub struct HardwareOtpTokenConfig {
26 pub yubico_client_id: Option<String>,
28 pub yubico_secret_key: Option<String>,
30 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
45pub struct HardwareOtpToken {
53 pub device_id: String,
55 pub token_type: String,
57 config: Option<HardwareOtpTokenConfig>,
59}
60
61impl HardwareOtpToken {
62 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 pub fn with_config(mut self, config: HardwareOtpTokenConfig) -> Self {
73 self.config = Some(config);
74 self
75 }
76
77 pub async fn authenticate(&self, challenge: &str) -> Result<bool> {
79 if challenge.is_empty() {
83 return Ok(false);
84 }
85
86 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 async fn validate_yubikey_response(&self, challenge: &str) -> Result<bool> {
106 tracing::debug!("Validating YubiKey response for challenge: {}", challenge);
107
108 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 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 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 async fn validate_yubikey_via_api(
160 &self,
161 otp: &str,
162 client_id: &str,
163 validation_url: &str,
164 ) -> Result<bool> {
165 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 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 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 #[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 #[tokio::test]
240 async fn test_yubikey_valid_otp_without_config_returns_err() {
241 let token = yubikey("yk-device-001");
242 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)); 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()); }
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)); 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 #[tokio::test]
282 async fn test_fido2_token_type_is_unknown() {
283 let token = HardwareOtpToken::new("dev-fido2".to_string(), "fido2".to_string());
284 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 #[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 #[tokio::test]
324 async fn test_yubikey_format_only_without_api_config() {
325 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 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 #[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 #[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 #[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 #[tokio::test]
399 async fn test_yubikey_bad_format_skips_api() {
400 let mut server = mockito::Server::new_async().await;
401 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}