]> git.proxmox.com Git - proxmox.git/blame - proxmox-tfa/src/api/mod.rs
tfa: don't automatically drop empty recovery
[proxmox.git] / proxmox-tfa / src / api / mod.rs
CommitLineData
313d0a6b
WB
1//! TFA configuration and user data.
2//!
3//! This is the same as used in PBS but without the `#[api]` type.
4//!
5//! We may want to move this into a shared crate making the `#[api]` macro feature-gated!
6
7use std::collections::HashMap;
8
9use anyhow::{bail, format_err, Error};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
637188d4 12use url::Url;
313d0a6b 13
313d0a6b
WB
14use webauthn_rs::{proto::UserVerificationPolicy, Webauthn};
15
16use crate::totp::Totp;
17use proxmox_uuid::Uuid;
18
19#[cfg(feature = "api-types")]
20use proxmox_schema::api;
21
22mod serde_tools;
23
24mod recovery;
25mod u2f;
26mod webauthn;
27
28pub mod methods;
29
30pub use recovery::RecoveryState;
31pub use u2f::U2fConfig;
637188d4 32use webauthn::WebauthnConfigInstance;
148950fd 33pub use webauthn::{WebauthnConfig, WebauthnCredential};
313d0a6b
WB
34
35#[cfg(feature = "api-types")]
36pub use webauthn::WebauthnConfigUpdater;
37
38use recovery::Recovery;
39use u2f::{U2fChallenge, U2fChallengeEntry, U2fRegistrationChallenge};
40use webauthn::{WebauthnAuthChallenge, WebauthnRegistrationChallenge};
41
42trait IsExpired {
43 fn is_expired(&self, at_epoch: i64) -> bool;
44}
45
5349ae20
WB
46pub trait OpenUserChallengeData {
47 fn open(&self, userid: &str) -> Result<Box<dyn UserChallengeAccess>, Error>;
313d0a6b 48
5349ae20 49 fn open_no_create(&self, userid: &str) -> Result<Option<Box<dyn UserChallengeAccess>>, Error>;
313d0a6b
WB
50
51 /// Should return `true` if something was removed, `false` if no data existed for the user.
52 fn remove(&self, userid: &str) -> Result<bool, Error>;
53}
54
5349ae20 55pub trait UserChallengeAccess {
313d0a6b 56 fn get_mut(&mut self) -> &mut TfaUserChallenges;
5349ae20 57 fn save(&mut self) -> Result<(), Error>;
313d0a6b
WB
58}
59
60const CHALLENGE_TIMEOUT_SECS: i64 = 2 * 60;
61
62/// TFA Configuration for this instance.
63#[derive(Clone, Default, Deserialize, Serialize)]
64pub struct TfaConfig {
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub u2f: Option<U2fConfig>,
67
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub webauthn: Option<WebauthnConfig>,
70
71 #[serde(skip_serializing_if = "TfaUsers::is_empty", default)]
72 pub users: TfaUsers,
73}
74
75/// Helper to get a u2f instance from a u2f config, or `None` if there isn't one configured.
76fn get_u2f(u2f: &Option<U2fConfig>) -> Option<u2f::U2f> {
54e97d35
WB
77 u2f.as_ref().map(|cfg| {
78 u2f::U2f::new(
79 cfg.appid.clone(),
80 cfg.origin.clone().unwrap_or_else(|| cfg.appid.clone()),
81 )
82 })
313d0a6b
WB
83}
84
85/// Helper to get a u2f instance from a u2f config.
86///
87/// This is outside of `TfaConfig` to not borrow its `&self`.
88fn check_u2f(u2f: &Option<U2fConfig>) -> Result<u2f::U2f, Error> {
89 get_u2f(u2f).ok_or_else(|| format_err!("no u2f configuration available"))
90}
91
92/// Helper to get a `Webauthn` instance from a `WebauthnConfig`, or `None` if there isn't one
93/// configured.
637188d4
WB
94fn get_webauthn<'a, 'config: 'a, 'origin: 'a>(
95 waconfig: &'config Option<WebauthnConfig>,
96 origin: Option<&'origin Url>,
97) -> Option<Result<Webauthn<WebauthnConfigInstance<'a>>, Error>> {
98 Some(waconfig.as_ref()?.instantiate(origin).map(Webauthn::new))
313d0a6b
WB
99}
100
d0b4f0bf 101/// Helper to get a `WebauthnConfigInstance` from a `WebauthnConfig`
313d0a6b
WB
102///
103/// This is outside of `TfaConfig` to not borrow its `&self`.
637188d4
WB
104fn check_webauthn<'a, 'config: 'a, 'origin: 'a>(
105 waconfig: &'config Option<WebauthnConfig>,
106 origin: Option<&'origin Url>,
107) -> Result<Webauthn<WebauthnConfigInstance<'a>>, Error> {
108 get_webauthn(waconfig, origin)
109 .ok_or_else(|| format_err!("no webauthn configuration available"))?
313d0a6b
WB
110}
111
112impl TfaConfig {
113 // Get a u2f registration challenge.
5349ae20 114 pub fn u2f_registration_challenge<A: ?Sized + OpenUserChallengeData>(
313d0a6b 115 &mut self,
5349ae20 116 access: &A,
313d0a6b
WB
117 userid: &str,
118 description: String,
119 ) -> Result<String, Error> {
120 let u2f = check_u2f(&self.u2f)?;
121
122 self.users
123 .entry(userid.to_owned())
124 .or_default()
125 .u2f_registration_challenge(access, userid, &u2f, description)
126 }
127
128 /// Finish a u2f registration challenge.
5349ae20 129 pub fn u2f_registration_finish<A: ?Sized + OpenUserChallengeData>(
313d0a6b 130 &mut self,
5349ae20 131 access: &A,
313d0a6b
WB
132 userid: &str,
133 challenge: &str,
134 response: &str,
135 ) -> Result<String, Error> {
136 let u2f = check_u2f(&self.u2f)?;
137
138 match self.users.get_mut(userid) {
139 Some(user) => user.u2f_registration_finish(access, userid, &u2f, challenge, response),
140 None => bail!("no such challenge"),
141 }
142 }
143
144 /// Get a webauthn registration challenge.
5349ae20 145 pub fn webauthn_registration_challenge<A: ?Sized + OpenUserChallengeData>(
313d0a6b 146 &mut self,
5349ae20 147 access: &A,
313d0a6b
WB
148 user: &str,
149 description: String,
637188d4 150 origin: Option<&Url>,
313d0a6b 151 ) -> Result<String, Error> {
637188d4 152 let webauthn = check_webauthn(&self.webauthn, origin)?;
313d0a6b
WB
153
154 self.users
155 .entry(user.to_owned())
156 .or_default()
157 .webauthn_registration_challenge(access, webauthn, user, description)
158 }
159
160 /// Finish a webauthn registration challenge.
5349ae20 161 pub fn webauthn_registration_finish<A: ?Sized + OpenUserChallengeData>(
313d0a6b 162 &mut self,
5349ae20 163 access: &A,
313d0a6b
WB
164 userid: &str,
165 challenge: &str,
166 response: &str,
637188d4 167 origin: Option<&Url>,
313d0a6b 168 ) -> Result<String, Error> {
637188d4 169 let webauthn = check_webauthn(&self.webauthn, origin)?;
313d0a6b
WB
170
171 let response: webauthn_rs::proto::RegisterPublicKeyCredential =
172 serde_json::from_str(response)
173 .map_err(|err| format_err!("error parsing challenge response: {}", err))?;
174
175 match self.users.get_mut(userid) {
176 Some(user) => {
177 user.webauthn_registration_finish(access, webauthn, userid, challenge, response)
178 }
179 None => bail!("no such challenge"),
180 }
181 }
182
183 /// Add a TOTP entry for a user.
184 ///
185 /// Unlike U2F/WA, this does not require a challenge/response. The user can choose their secret
186 /// themselves.
187 pub fn add_totp(&mut self, userid: &str, description: String, value: Totp) -> String {
188 self.users
189 .entry(userid.to_owned())
190 .or_default()
191 .add_totp(description, value)
192 }
193
194 /// Add a Yubico key to a user.
195 ///
196 /// Unlike U2F/WA, this does not require a challenge/response. The user can choose their secret
197 /// themselves.
198 pub fn add_yubico(&mut self, userid: &str, description: String, key: String) -> String {
199 self.users
200 .entry(userid.to_owned())
201 .or_default()
202 .add_yubico(description, key)
203 }
204
205 /// Add a new set of recovery keys. There can only be 1 set of keys at a time.
206 pub fn add_recovery(&mut self, userid: &str) -> Result<Vec<String>, Error> {
207 self.users
208 .entry(userid.to_owned())
209 .or_default()
210 .add_recovery()
211 }
212
213 /// Get a two factor authentication challenge for a user, if the user has TFA set up.
5349ae20 214 pub fn authentication_challenge<A: ?Sized + OpenUserChallengeData>(
313d0a6b 215 &mut self,
5349ae20 216 access: &A,
313d0a6b 217 userid: &str,
637188d4 218 origin: Option<&Url>,
313d0a6b
WB
219 ) -> Result<Option<TfaChallenge>, Error> {
220 match self.users.get_mut(userid) {
221 Some(udata) => udata.challenge(
222 access,
223 userid,
637188d4 224 get_webauthn(&self.webauthn, origin),
313d0a6b
WB
225 get_u2f(&self.u2f).as_ref(),
226 ),
227 None => Ok(None),
228 }
229 }
230
231 /// Verify a TFA challenge.
5349ae20 232 pub fn verify<A: ?Sized + OpenUserChallengeData>(
313d0a6b 233 &mut self,
5349ae20 234 access: &A,
313d0a6b
WB
235 userid: &str,
236 challenge: &TfaChallenge,
237 response: TfaResponse,
637188d4 238 origin: Option<&Url>,
313d0a6b
WB
239 ) -> Result<NeedsSaving, Error> {
240 match self.users.get_mut(userid) {
241 Some(user) => match response {
242 TfaResponse::Totp(value) => user.verify_totp(&value),
243 TfaResponse::U2f(value) => match &challenge.u2f {
244 Some(challenge) => {
245 let u2f = check_u2f(&self.u2f)?;
d85ebbb4 246 user.verify_u2f(access, userid, u2f, &challenge.challenge, value)
313d0a6b
WB
247 }
248 None => bail!("no u2f factor available for user '{}'", userid),
249 },
250 TfaResponse::Webauthn(value) => {
637188d4 251 let webauthn = check_webauthn(&self.webauthn, origin)?;
d85ebbb4 252 user.verify_webauthn(access, userid, webauthn, value)
313d0a6b
WB
253 }
254 TfaResponse::Recovery(value) => {
255 user.verify_recovery(&value)?;
256 return Ok(NeedsSaving::Yes);
257 }
258 },
259 None => bail!("no 2nd factor available for user '{}'", userid),
260 }?;
261
262 Ok(NeedsSaving::No)
263 }
264
5349ae20 265 pub fn remove_user<A: ?Sized + OpenUserChallengeData>(
313d0a6b 266 &mut self,
5349ae20 267 access: &A,
313d0a6b
WB
268 userid: &str,
269 ) -> Result<NeedsSaving, Error> {
270 let mut save = access.remove(userid)?;
271 if self.users.remove(userid).is_some() {
272 save = true;
273 }
274 Ok(save.into())
275 }
276}
277
278#[must_use = "must save the config in order to ensure one-time use of recovery keys"]
279#[derive(Clone, Copy)]
280pub enum NeedsSaving {
281 No,
282 Yes,
283}
284
285impl NeedsSaving {
286 /// Convenience method so we don't need to import the type name.
287 pub fn needs_saving(self) -> bool {
288 matches!(self, NeedsSaving::Yes)
289 }
290}
291
292impl From<bool> for NeedsSaving {
293 fn from(v: bool) -> Self {
294 if v {
295 NeedsSaving::Yes
296 } else {
297 NeedsSaving::No
298 }
299 }
300}
301
302/// Mapping of userid to TFA entry.
303pub type TfaUsers = HashMap<String, TfaUserData>;
304
305/// TFA data for a user.
306#[derive(Clone, Default, Deserialize, Serialize)]
307#[serde(deny_unknown_fields)]
308#[serde(rename_all = "kebab-case")]
313d0a6b
WB
309pub struct TfaUserData {
310 /// Totp keys for a user.
311 #[serde(skip_serializing_if = "Vec::is_empty", default)]
312 pub totp: Vec<TfaEntry<Totp>>,
313
314 /// Registered u2f tokens for a user.
315 #[serde(skip_serializing_if = "Vec::is_empty", default)]
316 pub u2f: Vec<TfaEntry<u2f::Registration>>,
317
318 /// Registered webauthn tokens for a user.
319 #[serde(skip_serializing_if = "Vec::is_empty", default)]
320 pub webauthn: Vec<TfaEntry<WebauthnCredential>>,
321
322 /// Recovery keys. (Unordered OTP values).
ea1d023a 323 #[serde(skip_serializing_if = "Option::is_none", default)]
313d0a6b
WB
324 pub recovery: Option<Recovery>,
325
326 /// Yubico keys for a user. NOTE: This is not directly supported currently, we just need this
327 /// available for PVE, where the yubico API server configuration is part if the realm.
328 #[serde(skip_serializing_if = "Vec::is_empty", default)]
329 pub yubico: Vec<TfaEntry<String>>,
330}
331
332impl TfaUserData {
313d0a6b
WB
333 /// `true` if no second factors exist
334 pub fn is_empty(&self) -> bool {
335 self.totp.is_empty()
336 && self.u2f.is_empty()
337 && self.webauthn.is_empty()
338 && self.yubico.is_empty()
ea1d023a 339 && self.recovery.is_none()
313d0a6b
WB
340 }
341
342 /// Find an entry by id, except for the "recovery" entry which we're currently treating
343 /// specially.
344 pub fn find_entry_mut<'a>(&'a mut self, id: &str) -> Option<&'a mut TfaInfo> {
345 for entry in &mut self.totp {
346 if entry.info.id == id {
347 return Some(&mut entry.info);
348 }
349 }
350
351 for entry in &mut self.webauthn {
352 if entry.info.id == id {
353 return Some(&mut entry.info);
354 }
355 }
356
357 for entry in &mut self.u2f {
358 if entry.info.id == id {
359 return Some(&mut entry.info);
360 }
361 }
362
363 for entry in &mut self.yubico {
364 if entry.info.id == id {
365 return Some(&mut entry.info);
366 }
367 }
368
369 None
370 }
371
372 /// Create a u2f registration challenge.
373 ///
374 /// The description is required at this point already mostly to better be able to identify such
375 /// challenges in the tfa config file if necessary. The user otherwise has no access to this
376 /// information at this point, as the challenge is identified by its actual challenge data
377 /// instead.
5349ae20 378 fn u2f_registration_challenge<A: ?Sized + OpenUserChallengeData>(
313d0a6b 379 &mut self,
5349ae20 380 access: &A,
313d0a6b
WB
381 userid: &str,
382 u2f: &u2f::U2f,
383 description: String,
384 ) -> Result<String, Error> {
385 let challenge = serde_json::to_string(&u2f.registration_challenge()?)?;
386
387 let mut data = access.open(userid)?;
388 data.get_mut()
389 .u2f_registrations
390 .push(U2fRegistrationChallenge::new(
391 challenge.clone(),
392 description,
393 ));
394 data.save()?;
395
396 Ok(challenge)
397 }
398
5349ae20 399 fn u2f_registration_finish<A: ?Sized + OpenUserChallengeData>(
313d0a6b 400 &mut self,
5349ae20 401 access: &A,
313d0a6b
WB
402 userid: &str,
403 u2f: &u2f::U2f,
404 challenge: &str,
405 response: &str,
406 ) -> Result<String, Error> {
407 let mut data = access.open(userid)?;
408 let entry = data
409 .get_mut()
410 .u2f_registration_finish(u2f, challenge, response)?;
411 data.save()?;
412
413 let id = entry.info.id.clone();
414 self.u2f.push(entry);
415 Ok(id)
416 }
417
418 /// Create a webauthn registration challenge.
419 ///
420 /// The description is required at this point already mostly to better be able to identify such
421 /// challenges in the tfa config file if necessary. The user otherwise has no access to this
422 /// information at this point, as the challenge is identified by its actual challenge data
423 /// instead.
5349ae20 424 fn webauthn_registration_challenge<A: ?Sized + OpenUserChallengeData>(
313d0a6b 425 &mut self,
5349ae20 426 access: &A,
637188d4 427 webauthn: Webauthn<WebauthnConfigInstance>,
313d0a6b
WB
428 userid: &str,
429 description: String,
430 ) -> Result<String, Error> {
431 let cred_ids: Vec<_> = self
432 .enabled_webauthn_entries()
433 .map(|cred| cred.cred_id.clone())
434 .collect();
435
436 let (challenge, state) = webauthn.generate_challenge_register_options(
437 userid.as_bytes().to_vec(),
438 userid.to_owned(),
439 userid.to_owned(),
440 Some(cred_ids),
441 Some(UserVerificationPolicy::Discouraged),
91932da1 442 None,
313d0a6b
WB
443 )?;
444
445 let challenge_string = challenge.public_key.challenge.to_string();
446 let challenge = serde_json::to_string(&challenge)?;
447
448 let mut data = access.open(userid)?;
449 data.get_mut()
450 .webauthn_registrations
451 .push(WebauthnRegistrationChallenge::new(
452 state,
453 challenge_string,
454 description,
455 ));
456 data.save()?;
457
458 Ok(challenge)
459 }
460
461 /// Finish a webauthn registration. The challenge should correspond to an output of
462 /// `webauthn_registration_challenge`. The response should come directly from the client.
5349ae20 463 fn webauthn_registration_finish<A: ?Sized + OpenUserChallengeData>(
313d0a6b 464 &mut self,
5349ae20 465 access: &A,
637188d4 466 webauthn: Webauthn<WebauthnConfigInstance>,
313d0a6b
WB
467 userid: &str,
468 challenge: &str,
469 response: webauthn_rs::proto::RegisterPublicKeyCredential,
470 ) -> Result<String, Error> {
471 let mut data = access.open(userid)?;
472 let entry = data.get_mut().webauthn_registration_finish(
473 webauthn,
474 challenge,
475 response,
476 &self.webauthn,
477 )?;
478 data.save()?;
479
480 let id = entry.info.id.clone();
481 self.webauthn.push(entry);
482 Ok(id)
483 }
484
485 fn add_totp(&mut self, description: String, totp: Totp) -> String {
486 let entry = TfaEntry::new(description, totp);
487 let id = entry.info.id.clone();
488 self.totp.push(entry);
489 id
490 }
491
492 fn add_yubico(&mut self, description: String, key: String) -> String {
493 let entry = TfaEntry::new(description, key);
494 let id = entry.info.id.clone();
495 self.yubico.push(entry);
496 id
497 }
498
499 /// Add a new set of recovery keys. There can only be 1 set of keys at a time.
500 fn add_recovery(&mut self) -> Result<Vec<String>, Error> {
501 if self.recovery.is_some() {
502 bail!("user already has recovery keys");
503 }
504
505 let (recovery, original) = Recovery::generate()?;
506
507 self.recovery = Some(recovery);
508
509 Ok(original)
510 }
511
512 /// Helper to iterate over enabled totp entries.
513 fn enabled_totp_entries(&self) -> impl Iterator<Item = &Totp> {
514 self.totp
515 .iter()
516 .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None })
517 }
518
519 /// Helper to iterate over enabled u2f entries.
520 fn enabled_u2f_entries(&self) -> impl Iterator<Item = &u2f::Registration> {
521 self.u2f
522 .iter()
523 .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None })
524 }
525
526 /// Helper to iterate over enabled u2f entries.
527 fn enabled_webauthn_entries(&self) -> impl Iterator<Item = &WebauthnCredential> {
528 self.webauthn
529 .iter()
530 .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None })
531 }
532
533 /// Helper to iterate over enabled yubico entries.
534 pub fn enabled_yubico_entries(&self) -> impl Iterator<Item = &str> {
535 self.yubico.iter().filter_map(|e| {
536 if e.info.enable {
537 Some(e.entry.as_str())
538 } else {
539 None
540 }
541 })
542 }
543
544 /// Verify a totp challenge. The `value` should be the totp digits as plain text.
545 fn verify_totp(&self, value: &str) -> Result<(), Error> {
546 let now = std::time::SystemTime::now();
547
548 for entry in self.enabled_totp_entries() {
549 if entry.verify(value, now, -1..=1)?.is_some() {
550 return Ok(());
551 }
552 }
553
554 bail!("totp verification failed");
555 }
556
557 /// Generate a generic TFA challenge. See the [`TfaChallenge`] description for details.
5349ae20 558 fn challenge<A: ?Sized + OpenUserChallengeData>(
313d0a6b 559 &mut self,
5349ae20 560 access: &A,
313d0a6b 561 userid: &str,
637188d4 562 webauthn: Option<Result<Webauthn<WebauthnConfigInstance>, Error>>,
313d0a6b
WB
563 u2f: Option<&u2f::U2f>,
564 ) -> Result<Option<TfaChallenge>, Error> {
565 if self.is_empty() {
566 return Ok(None);
567 }
568
569 Ok(Some(TfaChallenge {
570 totp: self.totp.iter().any(|e| e.info.enable),
ea1d023a 571 recovery: self.recovery_state(),
313d0a6b 572 webauthn: match webauthn {
5349ae20 573 Some(webauthn) => self.webauthn_challenge(access, userid, webauthn?)?,
313d0a6b
WB
574 None => None,
575 },
576 u2f: match u2f {
d85ebbb4 577 Some(u2f) => self.u2f_challenge(access, userid, u2f)?,
313d0a6b
WB
578 None => None,
579 },
580 yubico: self.yubico.iter().any(|e| e.info.enable),
581 }))
582 }
583
584 /// Get the recovery state.
ea1d023a
WB
585 pub fn recovery_state(&self) -> Option<RecoveryState> {
586 self.recovery.as_ref().map(RecoveryState::from)
313d0a6b
WB
587 }
588
589 /// Generate an optional webauthn challenge.
5349ae20 590 fn webauthn_challenge<A: ?Sized + OpenUserChallengeData>(
313d0a6b 591 &mut self,
5349ae20 592 access: &A,
313d0a6b 593 userid: &str,
637188d4 594 webauthn: Webauthn<WebauthnConfigInstance>,
313d0a6b
WB
595 ) -> Result<Option<webauthn_rs::proto::RequestChallengeResponse>, Error> {
596 if self.webauthn.is_empty() {
597 return Ok(None);
598 }
599
148950fd
FG
600 let creds: Vec<_> = self
601 .enabled_webauthn_entries()
602 .map(|cred| cred.clone().into())
603 .collect();
313d0a6b
WB
604
605 if creds.is_empty() {
606 return Ok(None);
607 }
608
91932da1
FG
609 let (challenge, state) = webauthn.generate_challenge_authenticate(creds)?;
610
313d0a6b
WB
611 let challenge_string = challenge.public_key.challenge.to_string();
612 let mut data = access.open(userid)?;
613 data.get_mut()
614 .webauthn_auths
615 .push(WebauthnAuthChallenge::new(state, challenge_string));
616 data.save()?;
617
618 Ok(Some(challenge))
619 }
620
621 /// Generate an optional u2f challenge.
5349ae20 622 fn u2f_challenge<A: ?Sized + OpenUserChallengeData>(
313d0a6b 623 &self,
5349ae20 624 access: &A,
313d0a6b
WB
625 userid: &str,
626 u2f: &u2f::U2f,
627 ) -> Result<Option<U2fChallenge>, Error> {
628 if self.u2f.is_empty() {
629 return Ok(None);
630 }
631
632 let keys: Vec<crate::u2f::RegisteredKey> = self
633 .enabled_u2f_entries()
634 .map(|registration| registration.key.clone())
635 .collect();
636
637 if keys.is_empty() {
638 return Ok(None);
639 }
640
641 let challenge = U2fChallenge {
642 challenge: u2f.auth_challenge()?,
643 keys,
644 };
645
646 let mut data = access.open(userid)?;
647 data.get_mut()
648 .u2f_auths
649 .push(U2fChallengeEntry::new(&challenge));
650 data.save()?;
651
652 Ok(Some(challenge))
653 }
654
655 /// Verify a u2f response.
5349ae20 656 fn verify_u2f<A: ?Sized + OpenUserChallengeData>(
313d0a6b 657 &self,
5349ae20 658 access: &A,
313d0a6b
WB
659 userid: &str,
660 u2f: u2f::U2f,
661 challenge: &crate::u2f::AuthChallenge,
662 response: Value,
663 ) -> Result<(), Error> {
664 let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
665
666 let response: crate::u2f::AuthResponse = serde_json::from_value(response)
667 .map_err(|err| format_err!("invalid u2f response: {}", err))?;
668
669 if let Some(entry) = self
670 .enabled_u2f_entries()
671 .find(|e| e.key.key_handle == response.key_handle())
672 {
673 if u2f
674 .auth_verify_obj(&entry.public_key, &challenge.challenge, response)?
675 .is_some()
676 {
677 let mut data = match access.open_no_create(userid)? {
678 Some(data) => data,
679 None => bail!("no such challenge"),
680 };
681 let index = data
682 .get_mut()
683 .u2f_auths
684 .iter()
685 .position(|r| r == challenge)
686 .ok_or_else(|| format_err!("no such challenge"))?;
687 let entry = data.get_mut().u2f_auths.remove(index);
688 if entry.is_expired(expire_before) {
689 bail!("no such challenge");
690 }
691 data.save()
692 .map_err(|err| format_err!("failed to save challenge file: {}", err))?;
693
694 return Ok(());
695 }
696 }
697
698 bail!("u2f verification failed");
699 }
700
701 /// Verify a webauthn response.
5349ae20 702 fn verify_webauthn<A: ?Sized + OpenUserChallengeData>(
313d0a6b 703 &mut self,
5349ae20 704 access: &A,
313d0a6b 705 userid: &str,
637188d4 706 webauthn: Webauthn<WebauthnConfigInstance>,
313d0a6b
WB
707 mut response: Value,
708 ) -> Result<(), Error> {
709 let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
710
711 let challenge = match response
712 .as_object_mut()
713 .ok_or_else(|| format_err!("invalid response, must be a json object"))?
714 .remove("challenge")
715 .ok_or_else(|| format_err!("missing challenge data in response"))?
716 {
717 Value::String(s) => s,
718 _ => bail!("invalid challenge data in response"),
719 };
720
721 let response: webauthn_rs::proto::PublicKeyCredential = serde_json::from_value(response)
722 .map_err(|err| format_err!("invalid webauthn response: {}", err))?;
723
724 let mut data = match access.open_no_create(userid)? {
725 Some(data) => data,
726 None => bail!("no such challenge"),
727 };
728
729 let index = data
730 .get_mut()
731 .webauthn_auths
732 .iter()
733 .position(|r| r.challenge == challenge)
734 .ok_or_else(|| format_err!("no such challenge"))?;
735
736 let challenge = data.get_mut().webauthn_auths.remove(index);
737 if challenge.is_expired(expire_before) {
738 bail!("no such challenge");
739 }
740
741 // we don't allow re-trying the challenge, so make the removal persistent now:
742 data.save()
743 .map_err(|err| format_err!("failed to save challenge file: {}", err))?;
744
91932da1
FG
745 webauthn.authenticate_credential(&response, &challenge.state)?;
746
747 Ok(())
313d0a6b
WB
748 }
749
750 /// Verify a recovery key.
751 ///
752 /// NOTE: If successful, the key will automatically be removed from the list of available
753 /// recovery keys, so the configuration needs to be saved afterwards!
754 fn verify_recovery(&mut self, value: &str) -> Result<(), Error> {
755 if let Some(r) = &mut self.recovery {
756 if r.verify(value)? {
757 return Ok(());
758 }
759 }
760 bail!("recovery verification failed");
761 }
762}
763
764/// A TFA entry for a user.
765///
766/// This simply connects a raw registration to a non optional descriptive text chosen by the user.
767#[derive(Clone, Deserialize, Serialize)]
768#[serde(deny_unknown_fields)]
769pub struct TfaEntry<T> {
770 #[serde(flatten)]
771 pub info: TfaInfo,
772
773 /// The actual entry.
774 pub entry: T,
775}
776
777impl<T> TfaEntry<T> {
778 /// Create an entry with a description. The id will be autogenerated.
779 fn new(description: String, entry: T) -> Self {
780 Self {
781 info: TfaInfo {
782 id: Uuid::generate().to_string(),
783 enable: true,
784 description,
785 created: proxmox_time::epoch_i64(),
786 },
787 entry,
788 }
789 }
790
791 /// Create a raw entry from a `TfaInfo` and the corresponding entry data.
792 pub fn from_parts(info: TfaInfo, entry: T) -> Self {
793 Self { info, entry }
794 }
795}
796
797#[cfg_attr(feature = "api-types", api)]
798/// Over the API we only provide this part when querying a user's second factor list.
799#[derive(Clone, Deserialize, Serialize)]
800#[serde(deny_unknown_fields)]
801pub struct TfaInfo {
802 /// The id used to reference this entry.
803 pub id: String,
804
805 /// User chosen description for this entry.
806 #[serde(skip_serializing_if = "String::is_empty")]
807 pub description: String,
808
809 /// Creation time of this entry as unix epoch.
810 pub created: i64,
811
812 /// Whether this TFA entry is currently enabled.
813 #[serde(skip_serializing_if = "is_default_tfa_enable")]
814 #[serde(default = "default_tfa_enable")]
815 pub enable: bool,
816}
817
818impl TfaInfo {
819 /// For recovery keys we have a fixed entry.
820 pub fn recovery(created: i64) -> Self {
821 Self {
822 id: "recovery".to_string(),
823 description: String::new(),
824 enable: true,
825 created,
826 }
827 }
828}
829
830const fn default_tfa_enable() -> bool {
831 true
832}
833
834const fn is_default_tfa_enable(v: &bool) -> bool {
835 *v
836}
837
838/// When sending a TFA challenge to the user, we include information about what kind of challenge
839/// the user may perform. If webauthn credentials are available, a webauthn challenge will be
840/// included.
841#[derive(Deserialize, Serialize)]
842#[serde(rename_all = "kebab-case")]
843pub struct TfaChallenge {
844 /// True if the user has TOTP devices.
845 #[serde(skip_serializing_if = "bool_is_false", default)]
e5a43afe 846 pub totp: bool,
313d0a6b
WB
847
848 /// Whether there are recovery keys available.
ea1d023a
WB
849 #[serde(skip_serializing_if = "Option::is_none", default)]
850 pub recovery: Option<RecoveryState>,
313d0a6b
WB
851
852 /// If the user has any u2f tokens registered, this will contain the U2F challenge data.
853 #[serde(skip_serializing_if = "Option::is_none")]
e5a43afe 854 pub u2f: Option<U2fChallenge>,
313d0a6b
WB
855
856 /// If the user has any webauthn credentials registered, this will contain the corresponding
857 /// challenge data.
86f3c907 858 #[serde(skip_serializing_if = "Option::is_none")]
e5a43afe 859 pub webauthn: Option<webauthn_rs::proto::RequestChallengeResponse>,
313d0a6b
WB
860
861 /// True if the user has yubico keys configured.
862 #[serde(skip_serializing_if = "bool_is_false", default)]
e5a43afe 863 pub yubico: bool,
313d0a6b
WB
864}
865
866fn bool_is_false(v: &bool) -> bool {
867 !v
868}
869
870/// A user's response to a TFA challenge.
871pub enum TfaResponse {
872 Totp(String),
873 U2f(Value),
874 Webauthn(Value),
875 Recovery(String),
876}
877
878/// This is part of the REST API:
879impl std::str::FromStr for TfaResponse {
880 type Err = Error;
881
882 fn from_str(s: &str) -> Result<Self, Error> {
883 Ok(if let Some(totp) = s.strip_prefix("totp:") {
884 TfaResponse::Totp(totp.to_string())
885 } else if let Some(u2f) = s.strip_prefix("u2f:") {
886 TfaResponse::U2f(serde_json::from_str(u2f)?)
887 } else if let Some(webauthn) = s.strip_prefix("webauthn:") {
888 TfaResponse::Webauthn(serde_json::from_str(webauthn)?)
889 } else if let Some(recovery) = s.strip_prefix("recovery:") {
890 TfaResponse::Recovery(recovery.to_string())
891 } else {
892 bail!("invalid tfa response");
893 })
894 }
895}
896
897/// Active TFA challenges per user, stored in a restricted temporary file on the machine handling
898/// the current user's authentication.
899#[derive(Default, Deserialize, Serialize)]
900pub struct TfaUserChallenges {
901 /// Active u2f registration challenges for a user.
902 ///
903 /// Expired values are automatically filtered out while parsing the tfa configuration file.
904 #[serde(skip_serializing_if = "Vec::is_empty", default)]
905 #[serde(deserialize_with = "filter_expired_challenge")]
906 u2f_registrations: Vec<U2fRegistrationChallenge>,
907
908 /// Active u2f authentication challenges for a user.
909 ///
910 /// Expired values are automatically filtered out while parsing the tfa configuration file.
911 #[serde(skip_serializing_if = "Vec::is_empty", default)]
912 #[serde(deserialize_with = "filter_expired_challenge")]
913 u2f_auths: Vec<U2fChallengeEntry>,
914
915 /// Active webauthn registration challenges for a user.
916 ///
917 /// Expired values are automatically filtered out while parsing the tfa configuration file.
918 #[serde(skip_serializing_if = "Vec::is_empty", default)]
919 #[serde(deserialize_with = "filter_expired_challenge")]
920 webauthn_registrations: Vec<WebauthnRegistrationChallenge>,
921
922 /// Active webauthn authentication challenges for a user.
923 ///
924 /// Expired values are automatically filtered out while parsing the tfa configuration file.
925 #[serde(skip_serializing_if = "Vec::is_empty", default)]
926 #[serde(deserialize_with = "filter_expired_challenge")]
927 webauthn_auths: Vec<WebauthnAuthChallenge>,
928}
929
930/// Serde helper using our `FilteredVecVisitor` to filter out expired entries directly at load
931/// time.
932fn filter_expired_challenge<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
933where
934 D: serde::Deserializer<'de>,
935 T: Deserialize<'de> + IsExpired,
936{
937 let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
938 deserializer.deserialize_seq(serde_tools::fold(
939 "a challenge entry",
940 |cap| cap.map(Vec::with_capacity).unwrap_or_else(Vec::new),
941 move |out, reg: T| {
942 if !reg.is_expired(expire_before) {
943 out.push(reg);
944 }
945 },
946 ))
947}
948
949impl TfaUserChallenges {
950 /// Finish a u2f registration. The challenge should correspond to an output of
951 /// `u2f_registration_challenge` (which is a stringified `RegistrationChallenge`). The response
952 /// should come directly from the client.
953 fn u2f_registration_finish(
954 &mut self,
955 u2f: &u2f::U2f,
956 challenge: &str,
957 response: &str,
958 ) -> Result<TfaEntry<u2f::Registration>, Error> {
959 let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
960
961 let index = self
962 .u2f_registrations
963 .iter()
964 .position(|r| r.challenge == challenge)
965 .ok_or_else(|| format_err!("no such challenge"))?;
966
967 let reg = &self.u2f_registrations[index];
968 if reg.is_expired(expire_before) {
969 bail!("no such challenge");
970 }
971
972 // the verify call only takes the actual challenge string, so we have to extract it
973 // (u2f::RegistrationChallenge did not always implement Deserialize...)
974 let chobj: Value = serde_json::from_str(challenge)
975 .map_err(|err| format_err!("error parsing original registration challenge: {}", err))?;
976 let challenge = chobj["challenge"]
977 .as_str()
978 .ok_or_else(|| format_err!("invalid registration challenge"))?;
979
980 let (mut reg, description) = match u2f.registration_verify(challenge, response)? {
981 None => bail!("verification failed"),
982 Some(reg) => {
983 let entry = self.u2f_registrations.remove(index);
984 (reg, entry.description)
985 }
986 };
987
988 // we do not care about the attestation certificates, so don't store them
989 reg.certificate.clear();
990
991 Ok(TfaEntry::new(description, reg))
992 }
993
994 /// Finish a webauthn registration. The challenge should correspond to an output of
995 /// `webauthn_registration_challenge`. The response should come directly from the client.
996 fn webauthn_registration_finish(
997 &mut self,
637188d4 998 webauthn: Webauthn<WebauthnConfigInstance>,
313d0a6b
WB
999 challenge: &str,
1000 response: webauthn_rs::proto::RegisterPublicKeyCredential,
1001 existing_registrations: &[TfaEntry<WebauthnCredential>],
1002 ) -> Result<TfaEntry<WebauthnCredential>, Error> {
1003 let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
1004
1005 let index = self
1006 .webauthn_registrations
1007 .iter()
1008 .position(|r| r.challenge == challenge)
1009 .ok_or_else(|| format_err!("no such challenge"))?;
1010
1011 let reg = self.webauthn_registrations.remove(index);
1012 if reg.is_expired(expire_before) {
1013 bail!("no such challenge");
1014 }
1015
91932da1
FG
1016 let (credential, _authenticator) =
1017 webauthn.register_credential(&response, &reg.state, |id| -> Result<bool, ()> {
313d0a6b
WB
1018 Ok(existing_registrations
1019 .iter()
1020 .any(|cred| cred.entry.cred_id == *id))
1021 })?;
1022
148950fd 1023 Ok(TfaEntry::new(reg.description, credential.into()))
313d0a6b
WB
1024 }
1025}