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