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