]> git.proxmox.com Git - proxmox.git/blame - proxmox-tfa/src/api/mod.rs
tfa: add api::methods::unlock_tfa
[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;
a3448feb 8use std::fmt;
313d0a6b
WB
9
10use anyhow::{bail, format_err, Error};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
637188d4 13use url::Url;
313d0a6b 14
313d0a6b
WB
15use webauthn_rs::{proto::UserVerificationPolicy, Webauthn};
16
17use crate::totp::Totp;
18use proxmox_uuid::Uuid;
19
313d0a6b
WB
20mod serde_tools;
21
22mod recovery;
23mod u2f;
24mod webauthn;
25
26pub mod methods;
27
28pub use recovery::RecoveryState;
29pub use u2f::U2fConfig;
637188d4 30use webauthn::WebauthnConfigInstance;
148950fd 31pub use webauthn::{WebauthnConfig, WebauthnCredential};
313d0a6b
WB
32
33#[cfg(feature = "api-types")]
34pub use webauthn::WebauthnConfigUpdater;
35
0d942e81
WB
36pub use crate::types::TfaInfo;
37
313d0a6b
WB
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>;
a3448feb
WB
53
54 /// This allows overriding the number of TOTP failures allowed before locking a user out of
55 /// TOTP.
56 fn totp_failure_limit(&self) -> u32 {
57 8
58 }
59
60 /// This allows overriding the number of consecutive TFA failures before an account gets rate
61 /// limited.
62 fn tfa_failure_limit(&self) -> u32 {
63 100
64 }
65
66 /// This allows overriding the time users are locked out when reaching the tfa failure limit.
67 fn tfa_failure_lock_time(&self) -> i64 {
68 3600 * 12
69 }
70
71 /// Since PVE needs cluster-wide package upgrades for new entries in [`TfaUserData`], TOTP code
72 /// reuse checks can be configured here.
73 fn enable_lockout(&self) -> bool {
74 true
75 }
76}
77
78#[test]
79fn ensure_open_user_challenge_data_is_dyn_safe() {
80 let _: Option<&dyn OpenUserChallengeData> = None;
313d0a6b
WB
81}
82
5349ae20 83pub trait UserChallengeAccess {
313d0a6b 84 fn get_mut(&mut self) -> &mut TfaUserChallenges;
5349ae20 85 fn save(&mut self) -> Result<(), Error>;
313d0a6b
WB
86}
87
88const CHALLENGE_TIMEOUT_SECS: i64 = 2 * 60;
89
90/// TFA Configuration for this instance.
91#[derive(Clone, Default, Deserialize, Serialize)]
92pub struct TfaConfig {
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub u2f: Option<U2fConfig>,
95
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub webauthn: Option<WebauthnConfig>,
98
99 #[serde(skip_serializing_if = "TfaUsers::is_empty", default)]
100 pub users: TfaUsers,
101}
102
103/// Helper to get a u2f instance from a u2f config, or `None` if there isn't one configured.
104fn get_u2f(u2f: &Option<U2fConfig>) -> Option<u2f::U2f> {
54e97d35
WB
105 u2f.as_ref().map(|cfg| {
106 u2f::U2f::new(
107 cfg.appid.clone(),
108 cfg.origin.clone().unwrap_or_else(|| cfg.appid.clone()),
109 )
110 })
313d0a6b
WB
111}
112
113/// Helper to get a u2f instance from a u2f config.
114///
115/// This is outside of `TfaConfig` to not borrow its `&self`.
116fn check_u2f(u2f: &Option<U2fConfig>) -> Result<u2f::U2f, Error> {
117 get_u2f(u2f).ok_or_else(|| format_err!("no u2f configuration available"))
118}
119
120/// Helper to get a `Webauthn` instance from a `WebauthnConfig`, or `None` if there isn't one
121/// configured.
637188d4
WB
122fn get_webauthn<'a, 'config: 'a, 'origin: 'a>(
123 waconfig: &'config Option<WebauthnConfig>,
124 origin: Option<&'origin Url>,
b6840e95
WB
125) -> Option<Webauthn<WebauthnConfigInstance<'a>>> {
126 match waconfig.as_ref()?.instantiate(origin) {
127 Ok(wa) => Some(Webauthn::new(wa)),
128 Err(err) => {
129 log::error!("webauthn error: {err}");
130 None
131 }
132 }
313d0a6b
WB
133}
134
d0b4f0bf 135/// Helper to get a `WebauthnConfigInstance` from a `WebauthnConfig`
313d0a6b
WB
136///
137/// This is outside of `TfaConfig` to not borrow its `&self`.
637188d4
WB
138fn check_webauthn<'a, 'config: 'a, 'origin: 'a>(
139 waconfig: &'config Option<WebauthnConfig>,
140 origin: Option<&'origin Url>,
141) -> Result<Webauthn<WebauthnConfigInstance<'a>>, Error> {
b6840e95 142 get_webauthn(waconfig, origin).ok_or_else(|| format_err!("no webauthn configuration available"))
313d0a6b
WB
143}
144
145impl TfaConfig {
39017fa3 146 /// Unlock a user's 2nd factor authentication (including TOTP).
a26ec45d
WB
147 /// Returns whether the user was locked before calling this method.
148 pub fn unlock_tfa(&mut self, userid: &str) -> Result<bool, Error> {
39017fa3
WB
149 match self.users.get_mut(userid) {
150 Some(user) => {
a26ec45d 151 let ret = user.totp_locked || user.tfa_is_locked();
39017fa3
WB
152 user.totp_locked = false;
153 user.tfa_locked_until = None;
a26ec45d 154 Ok(ret)
39017fa3 155 }
a26ec45d 156 None => bail!("no such user"),
39017fa3
WB
157 }
158 }
159
160 /// Unlock a user's TOTP challenges.
161 pub fn unlock_totp(&mut self, userid: &str) -> Result<(), Error> {
162 match self.users.get_mut(userid) {
163 Some(user) => {
164 user.totp_locked = false;
165 Ok(())
166 }
167 None => bail!("no such challenge"),
168 }
169 }
170
171 /// Get a u2f registration challenge.
5349ae20 172 pub fn u2f_registration_challenge<A: ?Sized + OpenUserChallengeData>(
313d0a6b 173 &mut self,
5349ae20 174 access: &A,
313d0a6b
WB
175 userid: &str,
176 description: String,
177 ) -> Result<String, Error> {
178 let u2f = check_u2f(&self.u2f)?;
179
180 self.users
181 .entry(userid.to_owned())
182 .or_default()
183 .u2f_registration_challenge(access, userid, &u2f, description)
184 }
185
186 /// Finish a u2f registration challenge.
5349ae20 187 pub fn u2f_registration_finish<A: ?Sized + OpenUserChallengeData>(
313d0a6b 188 &mut self,
5349ae20 189 access: &A,
313d0a6b
WB
190 userid: &str,
191 challenge: &str,
192 response: &str,
193 ) -> Result<String, Error> {
194 let u2f = check_u2f(&self.u2f)?;
195
196 match self.users.get_mut(userid) {
197 Some(user) => user.u2f_registration_finish(access, userid, &u2f, challenge, response),
198 None => bail!("no such challenge"),
199 }
200 }
201
202 /// Get a webauthn registration challenge.
5349ae20 203 pub fn webauthn_registration_challenge<A: ?Sized + OpenUserChallengeData>(
313d0a6b 204 &mut self,
5349ae20 205 access: &A,
313d0a6b
WB
206 user: &str,
207 description: String,
637188d4 208 origin: Option<&Url>,
313d0a6b 209 ) -> Result<String, Error> {
637188d4 210 let webauthn = check_webauthn(&self.webauthn, origin)?;
313d0a6b
WB
211
212 self.users
213 .entry(user.to_owned())
214 .or_default()
215 .webauthn_registration_challenge(access, webauthn, user, description)
216 }
217
218 /// Finish a webauthn registration challenge.
5349ae20 219 pub fn webauthn_registration_finish<A: ?Sized + OpenUserChallengeData>(
313d0a6b 220 &mut self,
5349ae20 221 access: &A,
313d0a6b
WB
222 userid: &str,
223 challenge: &str,
224 response: &str,
637188d4 225 origin: Option<&Url>,
313d0a6b 226 ) -> Result<String, Error> {
637188d4 227 let webauthn = check_webauthn(&self.webauthn, origin)?;
313d0a6b
WB
228
229 let response: webauthn_rs::proto::RegisterPublicKeyCredential =
230 serde_json::from_str(response)
231 .map_err(|err| format_err!("error parsing challenge response: {}", err))?;
232
233 match self.users.get_mut(userid) {
234 Some(user) => {
235 user.webauthn_registration_finish(access, webauthn, userid, challenge, response)
236 }
237 None => bail!("no such challenge"),
238 }
239 }
240
241 /// Add a TOTP entry for a user.
242 ///
243 /// Unlike U2F/WA, this does not require a challenge/response. The user can choose their secret
244 /// themselves.
245 pub fn add_totp(&mut self, userid: &str, description: String, value: Totp) -> String {
246 self.users
247 .entry(userid.to_owned())
248 .or_default()
249 .add_totp(description, value)
250 }
251
252 /// Add a Yubico key to a user.
253 ///
254 /// Unlike U2F/WA, this does not require a challenge/response. The user can choose their secret
255 /// themselves.
256 pub fn add_yubico(&mut self, userid: &str, description: String, key: String) -> String {
257 self.users
258 .entry(userid.to_owned())
259 .or_default()
260 .add_yubico(description, key)
261 }
262
263 /// Add a new set of recovery keys. There can only be 1 set of keys at a time.
264 pub fn add_recovery(&mut self, userid: &str) -> Result<Vec<String>, Error> {
265 self.users
266 .entry(userid.to_owned())
267 .or_default()
268 .add_recovery()
269 }
270
271 /// Get a two factor authentication challenge for a user, if the user has TFA set up.
5349ae20 272 pub fn authentication_challenge<A: ?Sized + OpenUserChallengeData>(
313d0a6b 273 &mut self,
5349ae20 274 access: &A,
313d0a6b 275 userid: &str,
637188d4 276 origin: Option<&Url>,
313d0a6b
WB
277 ) -> Result<Option<TfaChallenge>, Error> {
278 match self.users.get_mut(userid) {
279 Some(udata) => udata.challenge(
280 access,
281 userid,
637188d4 282 get_webauthn(&self.webauthn, origin),
313d0a6b
WB
283 get_u2f(&self.u2f).as_ref(),
284 ),
285 None => Ok(None),
286 }
287 }
288
289 /// Verify a TFA challenge.
5349ae20 290 pub fn verify<A: ?Sized + OpenUserChallengeData>(
313d0a6b 291 &mut self,
5349ae20 292 access: &A,
313d0a6b
WB
293 userid: &str,
294 challenge: &TfaChallenge,
295 response: TfaResponse,
637188d4 296 origin: Option<&Url>,
a3448feb
WB
297 ) -> TfaResult {
298 let user = match self.users.get_mut(userid) {
299 Some(user) => user,
300 None => {
301 // This should not be reachable, as an API should not try to verify a 2nd factor
302 // of a user that doesn't have any 2nd factors.
303 log::error!("no 2nd factor available for user '{userid}'");
304 return TfaResult::failure(false);
305 }
306 };
307
308 if user.tfa_is_locked() {
309 log::error!("refusing 2nd factor for user '{userid}'");
310 return TfaResult::Locked;
311 }
312
313 let mut was_totp = false;
314 let result = match response {
315 TfaResponse::Totp(value) => {
316 was_totp = true;
317 if user.totp_locked {
318 log::error!("TOTP of user '{userid}' is locked");
319 return TfaResult::Locked;
320 }
321 user.verify_totp(access, userid, &value)
322 .map(|needs_saving| TfaResult::Success { needs_saving })
323 }
324 TfaResponse::U2f(value) => match &challenge.u2f {
325 Some(challenge) => user
326 .verify_u2f(access, userid, &self.u2f, &challenge.challenge, value)
327 .map(|()| TfaResult::Success {
328 needs_saving: false,
329 }),
330 None => Err(format_err!("no u2f factor available for user '{}'", userid)),
331 },
332 TfaResponse::Webauthn(value) => user
333 .verify_webauthn(access, userid, &self.webauthn, origin, value)
334 .map(|()| TfaResult::Success {
335 needs_saving: false,
336 }),
337 TfaResponse::Recovery(value) => {
338 // recovery keys get used up so they always persist data:
339 user.verify_recovery(access, userid, &value)
340 .map(|()| TfaResult::Success { needs_saving: true })
341 }
342 };
343
344 match result {
345 Ok(r @ TfaResult::Success { .. }) => {
346 // reset tfa failure count on success:
347 let mut data = match access.open(userid) {
348 Ok(data) => data,
349 Err(err) => {
350 log::error!("failed to access user challenge data for '{userid}': {err}");
351 return r;
313d0a6b 352 }
a3448feb
WB
353 };
354
355 let access = data.get_mut();
356 let mut save = false;
357 if was_totp && access.totp_failures != 0 {
358 access.totp_failures = 0;
359 save = true;
313d0a6b 360 }
a3448feb
WB
361
362 if access.tfa_failures != 0 {
363 access.tfa_failures = 0;
364 save = true;
313d0a6b 365 }
313d0a6b 366
a3448feb
WB
367 if save {
368 if let Err(err) = data.save() {
369 log::error!("failed to store user challenge data: {err}");
370 }
371 }
372 r
373 }
374 Ok(r) => r,
375 Err(err) => {
376 log::error!("error in 2nd factor authentication for user '{userid}': {err}");
377 let mut data = match access.open(userid) {
378 Ok(data) => data,
379 Err(err) => {
380 log::error!("failed to access user challenge data for '{userid}': {err}");
381 return TfaResult::failure(false);
382 }
383 };
384
385 let data_mut = data.get_mut();
386 data_mut.tfa_failures += 1;
387 // totp failures are counted in `verify_totp`
388
389 let tfa_limit_reached = data_mut.tfa_failures >= access.tfa_failure_limit();
390 let totp_limit_reached =
391 was_totp && data_mut.totp_failures >= access.totp_failure_limit();
392
393 if !tfa_limit_reached && !totp_limit_reached {
394 if let Err(err) = data.save() {
395 log::error!("failed to store user challenge data: {err}");
396 }
397 return TfaResult::failure(false);
398 }
399
400 if let Err(err) = data.save() {
401 log::error!("failed to store user challenge data: {err}");
402 }
403 drop(data);
404
405 if totp_limit_reached {
406 user.totp_locked = access.enable_lockout();
407 }
408
409 if tfa_limit_reached && access.enable_lockout() {
410 user.tfa_locked_until =
411 Some(proxmox_time::epoch_i64() + access.tfa_failure_lock_time());
412 }
413
414 return TfaResult::Failure {
415 needs_saving: true,
416 tfa_limit_reached,
417 totp_limit_reached,
418 };
419 }
420 }
313d0a6b
WB
421 }
422
5349ae20 423 pub fn remove_user<A: ?Sized + OpenUserChallengeData>(
313d0a6b 424 &mut self,
5349ae20 425 access: &A,
313d0a6b
WB
426 userid: &str,
427 ) -> Result<NeedsSaving, Error> {
428 let mut save = access.remove(userid)?;
429 if self.users.remove(userid).is_some() {
430 save = true;
431 }
a3448feb
WB
432 Ok(if save {
433 NeedsSaving::Yes
434 } else {
435 NeedsSaving::No
436 })
437 }
438}
439
440#[must_use = "must save the config in order to ensure one-time use of recovery keys"]
441#[derive(Debug)]
442pub enum TfaResult {
443 /// Login succeeded. The user file might need updating.
444 Success { needs_saving: bool },
445 /// Login failed. The user file might need updating.
446 Failure {
447 needs_saving: bool,
448 totp_limit_reached: bool,
449 tfa_limit_reached: bool,
450 },
451 /// The current method is blocked.
452 Locked,
453}
454
455impl TfaResult {
456 const fn failure(needs_saving: bool) -> Self {
457 Self::Failure {
458 needs_saving,
459 totp_limit_reached: false,
460 tfa_limit_reached: false,
461 }
313d0a6b
WB
462 }
463}
464
465#[must_use = "must save the config in order to ensure one-time use of recovery keys"]
466#[derive(Clone, Copy)]
467pub enum NeedsSaving {
468 No,
469 Yes,
470}
471
472impl NeedsSaving {
473 /// Convenience method so we don't need to import the type name.
474 pub fn needs_saving(self) -> bool {
475 matches!(self, NeedsSaving::Yes)
476 }
477}
478
313d0a6b
WB
479/// Mapping of userid to TFA entry.
480pub type TfaUsers = HashMap<String, TfaUserData>;
481
482/// TFA data for a user.
483#[derive(Clone, Default, Deserialize, Serialize)]
484#[serde(deny_unknown_fields)]
485#[serde(rename_all = "kebab-case")]
313d0a6b
WB
486pub struct TfaUserData {
487 /// Totp keys for a user.
488 #[serde(skip_serializing_if = "Vec::is_empty", default)]
a3448feb 489 pub totp: Vec<TfaEntry<TotpEntry>>,
313d0a6b
WB
490
491 /// Registered u2f tokens for a user.
492 #[serde(skip_serializing_if = "Vec::is_empty", default)]
493 pub u2f: Vec<TfaEntry<u2f::Registration>>,
494
495 /// Registered webauthn tokens for a user.
496 #[serde(skip_serializing_if = "Vec::is_empty", default)]
497 pub webauthn: Vec<TfaEntry<WebauthnCredential>>,
498
499 /// Recovery keys. (Unordered OTP values).
ea1d023a 500 #[serde(skip_serializing_if = "Option::is_none", default)]
313d0a6b
WB
501 pub recovery: Option<Recovery>,
502
503 /// Yubico keys for a user. NOTE: This is not directly supported currently, we just need this
504 /// available for PVE, where the yubico API server configuration is part if the realm.
505 #[serde(skip_serializing_if = "Vec::is_empty", default)]
506 pub yubico: Vec<TfaEntry<String>>,
50b793db
WB
507
508 /// Once a user runs into a TOTP limit they get locked out of TOTP until they successfully use
509 /// a recovery key.
510 #[serde(skip_serializing_if = "bool_is_false", default)]
511 pub totp_locked: bool,
512
513 /// If a user hits too many 2nd factor failures, they get completely blocked for a while.
514 #[serde(skip_serializing_if = "Option::is_none", default)]
a3448feb
WB
515 #[serde(deserialize_with = "filter_expired_timestamp")]
516 pub tfa_locked_until: Option<i64>,
517}
518
519/// Serde helper to filter out an optional timestamp that should be removed.
520fn filter_expired_timestamp<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
521where
522 D: serde::Deserializer<'de>,
523{
524 match Option::<i64>::deserialize(deserializer)? {
525 Some(t) if t < proxmox_time::epoch_i64() => Ok(None),
526 other => Ok(other),
527 }
313d0a6b
WB
528}
529
530impl TfaUserData {
313d0a6b
WB
531 /// `true` if no second factors exist
532 pub fn is_empty(&self) -> bool {
533 self.totp.is_empty()
534 && self.u2f.is_empty()
535 && self.webauthn.is_empty()
536 && self.yubico.is_empty()
ea1d023a 537 && self.recovery.is_none()
313d0a6b
WB
538 }
539
540 /// Find an entry by id, except for the "recovery" entry which we're currently treating
541 /// specially.
542 pub fn find_entry_mut<'a>(&'a mut self, id: &str) -> Option<&'a mut TfaInfo> {
543 for entry in &mut self.totp {
544 if entry.info.id == id {
545 return Some(&mut entry.info);
546 }
547 }
548
549 for entry in &mut self.webauthn {
550 if entry.info.id == id {
551 return Some(&mut entry.info);
552 }
553 }
554
555 for entry in &mut self.u2f {
556 if entry.info.id == id {
557 return Some(&mut entry.info);
558 }
559 }
560
561 for entry in &mut self.yubico {
562 if entry.info.id == id {
563 return Some(&mut entry.info);
564 }
565 }
566
567 None
568 }
569
570 /// Create a u2f registration challenge.
571 ///
572 /// The description is required at this point already mostly to better be able to identify such
573 /// challenges in the tfa config file if necessary. The user otherwise has no access to this
574 /// information at this point, as the challenge is identified by its actual challenge data
575 /// instead.
5349ae20 576 fn u2f_registration_challenge<A: ?Sized + OpenUserChallengeData>(
313d0a6b 577 &mut self,
5349ae20 578 access: &A,
313d0a6b
WB
579 userid: &str,
580 u2f: &u2f::U2f,
581 description: String,
582 ) -> Result<String, Error> {
583 let challenge = serde_json::to_string(&u2f.registration_challenge()?)?;
584
585 let mut data = access.open(userid)?;
586 data.get_mut()
587 .u2f_registrations
588 .push(U2fRegistrationChallenge::new(
589 challenge.clone(),
590 description,
591 ));
592 data.save()?;
593
594 Ok(challenge)
595 }
596
5349ae20 597 fn u2f_registration_finish<A: ?Sized + OpenUserChallengeData>(
313d0a6b 598 &mut self,
5349ae20 599 access: &A,
313d0a6b
WB
600 userid: &str,
601 u2f: &u2f::U2f,
602 challenge: &str,
603 response: &str,
604 ) -> Result<String, Error> {
605 let mut data = access.open(userid)?;
606 let entry = data
607 .get_mut()
608 .u2f_registration_finish(u2f, challenge, response)?;
609 data.save()?;
610
611 let id = entry.info.id.clone();
612 self.u2f.push(entry);
613 Ok(id)
614 }
615
616 /// Create a webauthn registration challenge.
617 ///
618 /// The description is required at this point already mostly to better be able to identify such
619 /// challenges in the tfa config file if necessary. The user otherwise has no access to this
620 /// information at this point, as the challenge is identified by its actual challenge data
621 /// instead.
5349ae20 622 fn webauthn_registration_challenge<A: ?Sized + OpenUserChallengeData>(
313d0a6b 623 &mut self,
5349ae20 624 access: &A,
637188d4 625 webauthn: Webauthn<WebauthnConfigInstance>,
313d0a6b
WB
626 userid: &str,
627 description: String,
628 ) -> Result<String, Error> {
629 let cred_ids: Vec<_> = self
630 .enabled_webauthn_entries()
631 .map(|cred| cred.cred_id.clone())
632 .collect();
633
634 let (challenge, state) = webauthn.generate_challenge_register_options(
635 userid.as_bytes().to_vec(),
636 userid.to_owned(),
637 userid.to_owned(),
638 Some(cred_ids),
639 Some(UserVerificationPolicy::Discouraged),
91932da1 640 None,
313d0a6b
WB
641 )?;
642
643 let challenge_string = challenge.public_key.challenge.to_string();
644 let challenge = serde_json::to_string(&challenge)?;
645
646 let mut data = access.open(userid)?;
647 data.get_mut()
648 .webauthn_registrations
649 .push(WebauthnRegistrationChallenge::new(
650 state,
651 challenge_string,
652 description,
653 ));
654 data.save()?;
655
656 Ok(challenge)
657 }
658
659 /// Finish a webauthn registration. The challenge should correspond to an output of
660 /// `webauthn_registration_challenge`. The response should come directly from the client.
5349ae20 661 fn webauthn_registration_finish<A: ?Sized + OpenUserChallengeData>(
313d0a6b 662 &mut self,
5349ae20 663 access: &A,
637188d4 664 webauthn: Webauthn<WebauthnConfigInstance>,
313d0a6b
WB
665 userid: &str,
666 challenge: &str,
667 response: webauthn_rs::proto::RegisterPublicKeyCredential,
668 ) -> Result<String, Error> {
669 let mut data = access.open(userid)?;
670 let entry = data.get_mut().webauthn_registration_finish(
671 webauthn,
672 challenge,
673 response,
674 &self.webauthn,
675 )?;
676 data.save()?;
677
678 let id = entry.info.id.clone();
679 self.webauthn.push(entry);
680 Ok(id)
681 }
682
683 fn add_totp(&mut self, description: String, totp: Totp) -> String {
a3448feb 684 let entry = TfaEntry::new(description, TotpEntry::new(totp));
313d0a6b
WB
685 let id = entry.info.id.clone();
686 self.totp.push(entry);
687 id
688 }
689
690 fn add_yubico(&mut self, description: String, key: String) -> String {
691 let entry = TfaEntry::new(description, key);
692 let id = entry.info.id.clone();
693 self.yubico.push(entry);
694 id
695 }
696
697 /// Add a new set of recovery keys. There can only be 1 set of keys at a time.
698 fn add_recovery(&mut self) -> Result<Vec<String>, Error> {
699 if self.recovery.is_some() {
700 bail!("user already has recovery keys");
701 }
702
703 let (recovery, original) = Recovery::generate()?;
704
705 self.recovery = Some(recovery);
706
707 Ok(original)
708 }
709
710 /// Helper to iterate over enabled totp entries.
a3448feb
WB
711 /// Here we also need access to the ID.
712 fn enabled_totp_entries_mut(&mut self) -> impl Iterator<Item = &mut TfaEntry<TotpEntry>> {
713 self.totp.iter_mut().filter(|e| e.info.enable)
313d0a6b
WB
714 }
715
716 /// Helper to iterate over enabled u2f entries.
717 fn enabled_u2f_entries(&self) -> impl Iterator<Item = &u2f::Registration> {
718 self.u2f
719 .iter()
720 .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None })
721 }
722
723 /// Helper to iterate over enabled u2f entries.
724 fn enabled_webauthn_entries(&self) -> impl Iterator<Item = &WebauthnCredential> {
725 self.webauthn
726 .iter()
727 .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None })
728 }
729
730 /// Helper to iterate over enabled yubico entries.
731 pub fn enabled_yubico_entries(&self) -> impl Iterator<Item = &str> {
732 self.yubico.iter().filter_map(|e| {
733 if e.info.enable {
734 Some(e.entry.as_str())
735 } else {
736 None
737 }
738 })
739 }
740
741 /// Verify a totp challenge. The `value` should be the totp digits as plain text.
a3448feb
WB
742 ///
743 /// TOTP keys are stored in the user data, so we always need to save afterwards.
744 fn verify_totp<A: ?Sized + OpenUserChallengeData>(
745 &mut self,
746 access: &A,
747 userid: &str,
748 value: &str,
749 ) -> Result<bool, Error> {
313d0a6b
WB
750 let now = std::time::SystemTime::now();
751
a3448feb
WB
752 let needs_saving = access.enable_lockout();
753 for entry in self.enabled_totp_entries_mut() {
754 if let Some(current) = entry.entry.verify(value, now, -1..=1)? {
755 if needs_saving {
756 if current <= entry.entry.last_count {
757 let mut data = access.open(userid)?;
758 let data_access = data.get_mut();
759 data_access.totp_failures += 1;
760 data.save()?;
761 bail!("rejecting reused TOTP value");
762 }
763
764 entry.entry.last_count = current;
765 }
766
767 let mut data = access.open(userid)?;
768 let data_access = data.get_mut();
769 data_access.totp_failures = 0;
770 data.save()?;
771 return Ok(needs_saving);
313d0a6b
WB
772 }
773 }
774
a3448feb
WB
775 let mut data = access.open(userid)?;
776 let data_access = data.get_mut();
777 data_access.totp_failures += 1;
778 data.save()?;
779
313d0a6b
WB
780 bail!("totp verification failed");
781 }
782
783 /// Generate a generic TFA challenge. See the [`TfaChallenge`] description for details.
5349ae20 784 fn challenge<A: ?Sized + OpenUserChallengeData>(
313d0a6b 785 &mut self,
5349ae20 786 access: &A,
313d0a6b 787 userid: &str,
b6840e95 788 webauthn: Option<Webauthn<WebauthnConfigInstance>>,
313d0a6b
WB
789 u2f: Option<&u2f::U2f>,
790 ) -> Result<Option<TfaChallenge>, Error> {
791 if self.is_empty() {
792 return Ok(None);
793 }
794
b6840e95
WB
795 // Since we don't bail out when failing to generate WA or U2F challenges, we keep track of
796 // whether we tried here, otherwise `challenge.check()` would consider these to be not
797 // configured by the user and might allow logging in without them on error.
798 let mut not_empty = false;
799
4b3d171b 800 let challenge = TfaChallenge {
313d0a6b 801 totp: self.totp.iter().any(|e| e.info.enable),
ea1d023a 802 recovery: self.recovery_state(),
313d0a6b 803 webauthn: match webauthn {
b6840e95
WB
804 Some(webauthn) => match self.webauthn_challenge(access, userid, webauthn) {
805 Ok(wa) => wa,
806 Err(err) => {
807 not_empty = true;
808 log::error!("failed to generate webauthn challenge: {err}");
809 None
810 }
811 },
313d0a6b
WB
812 None => None,
813 },
814 u2f: match u2f {
b6840e95
WB
815 Some(u2f) => match self.u2f_challenge(access, userid, u2f) {
816 Ok(u2f) => u2f,
817 Err(err) => {
818 not_empty = true;
819 log::error!("failed to generate u2f challenge: {err}");
820 None
821 }
822 },
313d0a6b
WB
823 None => None,
824 },
825 yubico: self.yubico.iter().any(|e| e.info.enable),
4b3d171b
WB
826 };
827
828 // This happens if 2nd factors exist but are all disabled.
b6840e95 829 if challenge.is_empty() && !not_empty {
4b3d171b
WB
830 return Ok(None);
831 }
832
833 Ok(Some(challenge))
313d0a6b
WB
834 }
835
836 /// Get the recovery state.
ea1d023a
WB
837 pub fn recovery_state(&self) -> Option<RecoveryState> {
838 self.recovery.as_ref().map(RecoveryState::from)
313d0a6b
WB
839 }
840
841 /// Generate an optional webauthn challenge.
5349ae20 842 fn webauthn_challenge<A: ?Sized + OpenUserChallengeData>(
313d0a6b 843 &mut self,
5349ae20 844 access: &A,
313d0a6b 845 userid: &str,
637188d4 846 webauthn: Webauthn<WebauthnConfigInstance>,
313d0a6b
WB
847 ) -> Result<Option<webauthn_rs::proto::RequestChallengeResponse>, Error> {
848 if self.webauthn.is_empty() {
849 return Ok(None);
850 }
851
148950fd
FG
852 let creds: Vec<_> = self
853 .enabled_webauthn_entries()
854 .map(|cred| cred.clone().into())
855 .collect();
313d0a6b
WB
856
857 if creds.is_empty() {
858 return Ok(None);
859 }
860
91932da1
FG
861 let (challenge, state) = webauthn.generate_challenge_authenticate(creds)?;
862
313d0a6b
WB
863 let challenge_string = challenge.public_key.challenge.to_string();
864 let mut data = access.open(userid)?;
865 data.get_mut()
866 .webauthn_auths
867 .push(WebauthnAuthChallenge::new(state, challenge_string));
868 data.save()?;
869
870 Ok(Some(challenge))
871 }
872
873 /// Generate an optional u2f challenge.
5349ae20 874 fn u2f_challenge<A: ?Sized + OpenUserChallengeData>(
313d0a6b 875 &self,
5349ae20 876 access: &A,
313d0a6b
WB
877 userid: &str,
878 u2f: &u2f::U2f,
879 ) -> Result<Option<U2fChallenge>, Error> {
880 if self.u2f.is_empty() {
881 return Ok(None);
882 }
883
884 let keys: Vec<crate::u2f::RegisteredKey> = self
885 .enabled_u2f_entries()
886 .map(|registration| registration.key.clone())
887 .collect();
888
889 if keys.is_empty() {
890 return Ok(None);
891 }
892
893 let challenge = U2fChallenge {
894 challenge: u2f.auth_challenge()?,
895 keys,
896 };
897
898 let mut data = access.open(userid)?;
899 data.get_mut()
900 .u2f_auths
901 .push(U2fChallengeEntry::new(&challenge));
902 data.save()?;
903
904 Ok(Some(challenge))
905 }
906
907 /// Verify a u2f response.
5349ae20 908 fn verify_u2f<A: ?Sized + OpenUserChallengeData>(
313d0a6b 909 &self,
5349ae20 910 access: &A,
313d0a6b 911 userid: &str,
a3448feb 912 u2f: &Option<U2fConfig>,
313d0a6b
WB
913 challenge: &crate::u2f::AuthChallenge,
914 response: Value,
915 ) -> Result<(), Error> {
a3448feb
WB
916 let u2f = check_u2f(u2f)?;
917
313d0a6b
WB
918 let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
919
920 let response: crate::u2f::AuthResponse = serde_json::from_value(response)
921 .map_err(|err| format_err!("invalid u2f response: {}", err))?;
922
923 if let Some(entry) = self
924 .enabled_u2f_entries()
925 .find(|e| e.key.key_handle == response.key_handle())
926 {
927 if u2f
928 .auth_verify_obj(&entry.public_key, &challenge.challenge, response)?
929 .is_some()
930 {
931 let mut data = match access.open_no_create(userid)? {
932 Some(data) => data,
933 None => bail!("no such challenge"),
934 };
935 let index = data
936 .get_mut()
937 .u2f_auths
938 .iter()
939 .position(|r| r == challenge)
940 .ok_or_else(|| format_err!("no such challenge"))?;
941 let entry = data.get_mut().u2f_auths.remove(index);
942 if entry.is_expired(expire_before) {
943 bail!("no such challenge");
944 }
945 data.save()
946 .map_err(|err| format_err!("failed to save challenge file: {}", err))?;
947
948 return Ok(());
949 }
950 }
951
952 bail!("u2f verification failed");
953 }
954
955 /// Verify a webauthn response.
5349ae20 956 fn verify_webauthn<A: ?Sized + OpenUserChallengeData>(
313d0a6b 957 &mut self,
5349ae20 958 access: &A,
313d0a6b 959 userid: &str,
a3448feb
WB
960 webauthn: &Option<WebauthnConfig>,
961 origin: Option<&Url>,
313d0a6b
WB
962 mut response: Value,
963 ) -> Result<(), Error> {
a3448feb
WB
964 let webauthn = check_webauthn(webauthn, origin)?;
965
313d0a6b
WB
966 let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
967
968 let challenge = match response
969 .as_object_mut()
970 .ok_or_else(|| format_err!("invalid response, must be a json object"))?
971 .remove("challenge")
972 .ok_or_else(|| format_err!("missing challenge data in response"))?
973 {
974 Value::String(s) => s,
975 _ => bail!("invalid challenge data in response"),
976 };
977
978 let response: webauthn_rs::proto::PublicKeyCredential = serde_json::from_value(response)
979 .map_err(|err| format_err!("invalid webauthn response: {}", err))?;
980
981 let mut data = match access.open_no_create(userid)? {
982 Some(data) => data,
983 None => bail!("no such challenge"),
984 };
985
986 let index = data
987 .get_mut()
988 .webauthn_auths
989 .iter()
990 .position(|r| r.challenge == challenge)
991 .ok_or_else(|| format_err!("no such challenge"))?;
992
993 let challenge = data.get_mut().webauthn_auths.remove(index);
994 if challenge.is_expired(expire_before) {
995 bail!("no such challenge");
996 }
997
998 // we don't allow re-trying the challenge, so make the removal persistent now:
999 data.save()
1000 .map_err(|err| format_err!("failed to save challenge file: {}", err))?;
1001
91932da1
FG
1002 webauthn.authenticate_credential(&response, &challenge.state)?;
1003
1004 Ok(())
313d0a6b
WB
1005 }
1006
1007 /// Verify a recovery key.
1008 ///
1009 /// NOTE: If successful, the key will automatically be removed from the list of available
1010 /// recovery keys, so the configuration needs to be saved afterwards!
a3448feb
WB
1011 fn verify_recovery<A: ?Sized + OpenUserChallengeData>(
1012 &mut self,
1013 access: &A,
1014 userid: &str,
1015 value: &str,
1016 ) -> Result<(), Error> {
313d0a6b
WB
1017 if let Some(r) = &mut self.recovery {
1018 if r.verify(value)? {
a3448feb
WB
1019 // On success we reset the failure state.
1020 self.totp_locked = false;
1021 self.tfa_locked_until = None;
1022
1023 let mut data = access.open(userid)?;
1024 let access = data.get_mut();
1025 if access.totp_failures != 0 {
1026 access.totp_failures = 0;
1027 data.save()?;
1028 }
313d0a6b
WB
1029 return Ok(());
1030 }
1031 }
1032 bail!("recovery verification failed");
1033 }
a3448feb
WB
1034
1035 fn tfa_is_locked(&self) -> bool {
1036 match self.tfa_locked_until {
1037 Some(locked_until) => proxmox_time::epoch_i64() < locked_until,
1038 None => false,
1039 }
1040 }
313d0a6b
WB
1041}
1042
1043/// A TFA entry for a user.
1044///
1045/// This simply connects a raw registration to a non optional descriptive text chosen by the user.
1046#[derive(Clone, Deserialize, Serialize)]
1047#[serde(deny_unknown_fields)]
1048pub struct TfaEntry<T> {
1049 #[serde(flatten)]
1050 pub info: TfaInfo,
1051
1052 /// The actual entry.
1053 pub entry: T,
1054}
1055
1056impl<T> TfaEntry<T> {
1057 /// Create an entry with a description. The id will be autogenerated.
1058 fn new(description: String, entry: T) -> Self {
1059 Self {
1060 info: TfaInfo {
1061 id: Uuid::generate().to_string(),
1062 enable: true,
1063 description,
1064 created: proxmox_time::epoch_i64(),
1065 },
1066 entry,
1067 }
1068 }
1069
1070 /// Create a raw entry from a `TfaInfo` and the corresponding entry data.
1071 pub fn from_parts(info: TfaInfo, entry: T) -> Self {
1072 Self { info, entry }
1073 }
1074}
1075
a3448feb
WB
1076#[derive(Clone)]
1077pub struct TotpEntry {
1078 pub totp: Totp,
1079 pub last_count: i64,
1080}
1081
1082impl TotpEntry {
1083 pub fn new(totp: Totp) -> Self {
1084 Self {
1085 totp,
1086 last_count: i64::MIN,
1087 }
1088 }
1089}
1090
1091impl std::ops::Deref for TotpEntry {
1092 type Target = Totp;
1093
1094 fn deref(&self) -> &Totp {
1095 &self.totp
1096 }
1097}
1098
1099impl Serialize for TotpEntry {
1100 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1101 where
1102 S: serde::Serializer,
1103 {
1104 use serde::ser::SerializeStruct;
1105
1106 if self.last_count == i64::MIN {
1107 return self.totp.serialize(serializer);
1108 }
1109
1110 let mut map = serializer.serialize_struct("TotpEntry", 2)?;
1111 map.serialize_field("totp", &self.totp)?;
1112 map.serialize_field("last-count", &self.last_count)?;
1113 map.end()
1114 }
1115}
1116
1117impl<'de> Deserialize<'de> for TotpEntry {
1118 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1119 where
1120 D: serde::Deserializer<'de>,
1121 {
1122 use serde::de::Error;
1123
1124 struct V;
1125
1126 impl<'de> serde::de::Visitor<'de> for V {
1127 type Value = TotpEntry;
1128
1129 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
1130 write!(f, "a totp string or a TotpEntry struct")
1131 }
1132
1133 fn visit_str<E: Error>(self, s: &str) -> Result<TotpEntry, E> {
1134 Ok(TotpEntry::new(s.parse().map_err(|err| E::custom(err))?))
1135 }
1136
1137 fn visit_map<A>(self, mut map: A) -> Result<TotpEntry, A::Error>
1138 where
1139 A: serde::de::MapAccess<'de>,
1140 {
1141 use std::borrow::Cow;
1142
1143 let mut totp = None;
1144 let mut last_count = None;
1145
1146 loop {
1147 let key: Cow<'de, str> = match map.next_key()? {
1148 Some(k) => k,
1149 None => break,
1150 };
1151
1152 match key.as_ref() {
1153 "totp" if totp.is_some() => return Err(A::Error::duplicate_field("totp")),
1154 "totp" => totp = Some(map.next_value()?),
1155 "last-count" if last_count.is_some() => {
1156 return Err(A::Error::duplicate_field("last-count"))
1157 }
1158 "last-count" => last_count = Some(map.next_value()?),
1159 other => {
1160 return Err(A::Error::unknown_field(other, &["totp", "last-count"]))
1161 }
1162 }
1163 }
1164
1165 Ok(TotpEntry {
1166 totp: totp.ok_or_else(|| A::Error::missing_field("totp"))?,
1167 last_count: last_count.unwrap_or(i64::MIN),
1168 })
1169 }
1170 }
1171
1172 deserializer.deserialize_any(V)
1173 }
1174}
1175
313d0a6b
WB
1176/// When sending a TFA challenge to the user, we include information about what kind of challenge
1177/// the user may perform. If webauthn credentials are available, a webauthn challenge will be
1178/// included.
1179#[derive(Deserialize, Serialize)]
1180#[serde(rename_all = "kebab-case")]
1181pub struct TfaChallenge {
1182 /// True if the user has TOTP devices.
1183 #[serde(skip_serializing_if = "bool_is_false", default)]
e5a43afe 1184 pub totp: bool,
313d0a6b
WB
1185
1186 /// Whether there are recovery keys available.
ea1d023a
WB
1187 #[serde(skip_serializing_if = "Option::is_none", default)]
1188 pub recovery: Option<RecoveryState>,
313d0a6b
WB
1189
1190 /// If the user has any u2f tokens registered, this will contain the U2F challenge data.
1191 #[serde(skip_serializing_if = "Option::is_none")]
e5a43afe 1192 pub u2f: Option<U2fChallenge>,
313d0a6b
WB
1193
1194 /// If the user has any webauthn credentials registered, this will contain the corresponding
1195 /// challenge data.
86f3c907 1196 #[serde(skip_serializing_if = "Option::is_none")]
e5a43afe 1197 pub webauthn: Option<webauthn_rs::proto::RequestChallengeResponse>,
313d0a6b
WB
1198
1199 /// True if the user has yubico keys configured.
1200 #[serde(skip_serializing_if = "bool_is_false", default)]
e5a43afe 1201 pub yubico: bool,
313d0a6b
WB
1202}
1203
4b3d171b
WB
1204impl TfaChallenge {
1205 pub fn is_empty(&self) -> bool {
1206 !self.totp
1207 && self.recovery.is_none()
1208 && self.u2f.is_none()
1209 && self.webauthn.is_none()
1210 && !self.yubico
1211 }
1212}
1213
313d0a6b
WB
1214fn bool_is_false(v: &bool) -> bool {
1215 !v
1216}
1217
1218/// A user's response to a TFA challenge.
1219pub enum TfaResponse {
1220 Totp(String),
1221 U2f(Value),
1222 Webauthn(Value),
1223 Recovery(String),
1224}
1225
1226/// This is part of the REST API:
1227impl std::str::FromStr for TfaResponse {
1228 type Err = Error;
1229
1230 fn from_str(s: &str) -> Result<Self, Error> {
1231 Ok(if let Some(totp) = s.strip_prefix("totp:") {
1232 TfaResponse::Totp(totp.to_string())
1233 } else if let Some(u2f) = s.strip_prefix("u2f:") {
1234 TfaResponse::U2f(serde_json::from_str(u2f)?)
1235 } else if let Some(webauthn) = s.strip_prefix("webauthn:") {
1236 TfaResponse::Webauthn(serde_json::from_str(webauthn)?)
1237 } else if let Some(recovery) = s.strip_prefix("recovery:") {
1238 TfaResponse::Recovery(recovery.to_string())
1239 } else {
1240 bail!("invalid tfa response");
1241 })
1242 }
1243}
1244
1245/// Active TFA challenges per user, stored in a restricted temporary file on the machine handling
1246/// the current user's authentication.
1247#[derive(Default, Deserialize, Serialize)]
1248pub struct TfaUserChallenges {
1249 /// Active u2f registration challenges for a user.
1250 ///
1251 /// Expired values are automatically filtered out while parsing the tfa configuration file.
1252 #[serde(skip_serializing_if = "Vec::is_empty", default)]
1253 #[serde(deserialize_with = "filter_expired_challenge")]
1254 u2f_registrations: Vec<U2fRegistrationChallenge>,
1255
1256 /// Active u2f authentication challenges for a user.
1257 ///
1258 /// Expired values are automatically filtered out while parsing the tfa configuration file.
1259 #[serde(skip_serializing_if = "Vec::is_empty", default)]
1260 #[serde(deserialize_with = "filter_expired_challenge")]
1261 u2f_auths: Vec<U2fChallengeEntry>,
1262
1263 /// Active webauthn registration challenges for a user.
1264 ///
1265 /// Expired values are automatically filtered out while parsing the tfa configuration file.
1266 #[serde(skip_serializing_if = "Vec::is_empty", default)]
1267 #[serde(deserialize_with = "filter_expired_challenge")]
1268 webauthn_registrations: Vec<WebauthnRegistrationChallenge>,
1269
1270 /// Active webauthn authentication challenges for a user.
1271 ///
1272 /// Expired values are automatically filtered out while parsing the tfa configuration file.
1273 #[serde(skip_serializing_if = "Vec::is_empty", default)]
1274 #[serde(deserialize_with = "filter_expired_challenge")]
1275 webauthn_auths: Vec<WebauthnAuthChallenge>,
50b793db
WB
1276
1277 /// Number of consecutive TOTP failures. Too many of those will lock out a user.
1278 #[serde(skip_serializing_if = "u32_is_zero", default)]
1279 totp_failures: u32,
1280
1281 /// Number of consecutive 2nd factor failures. When the limit is reached, the user is locked
1282 /// out for 12 hours.
1283 #[serde(skip_serializing_if = "u32_is_zero", default)]
1284 tfa_failures: u32,
1285}
1286
1287fn u32_is_zero(n: &u32) -> bool {
1288 *n == 0
313d0a6b
WB
1289}
1290
1291/// Serde helper using our `FilteredVecVisitor` to filter out expired entries directly at load
1292/// time.
1293fn filter_expired_challenge<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
1294where
1295 D: serde::Deserializer<'de>,
1296 T: Deserialize<'de> + IsExpired,
1297{
1298 let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
1299 deserializer.deserialize_seq(serde_tools::fold(
1300 "a challenge entry",
1301 |cap| cap.map(Vec::with_capacity).unwrap_or_else(Vec::new),
1302 move |out, reg: T| {
1303 if !reg.is_expired(expire_before) {
1304 out.push(reg);
1305 }
1306 },
1307 ))
1308}
1309
1310impl TfaUserChallenges {
1311 /// Finish a u2f registration. The challenge should correspond to an output of
1312 /// `u2f_registration_challenge` (which is a stringified `RegistrationChallenge`). The response
1313 /// should come directly from the client.
1314 fn u2f_registration_finish(
1315 &mut self,
1316 u2f: &u2f::U2f,
1317 challenge: &str,
1318 response: &str,
1319 ) -> Result<TfaEntry<u2f::Registration>, Error> {
1320 let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
1321
1322 let index = self
1323 .u2f_registrations
1324 .iter()
1325 .position(|r| r.challenge == challenge)
1326 .ok_or_else(|| format_err!("no such challenge"))?;
1327
1328 let reg = &self.u2f_registrations[index];
1329 if reg.is_expired(expire_before) {
1330 bail!("no such challenge");
1331 }
1332
1333 // the verify call only takes the actual challenge string, so we have to extract it
1334 // (u2f::RegistrationChallenge did not always implement Deserialize...)
1335 let chobj: Value = serde_json::from_str(challenge)
1336 .map_err(|err| format_err!("error parsing original registration challenge: {}", err))?;
1337 let challenge = chobj["challenge"]
1338 .as_str()
1339 .ok_or_else(|| format_err!("invalid registration challenge"))?;
1340
1341 let (mut reg, description) = match u2f.registration_verify(challenge, response)? {
1342 None => bail!("verification failed"),
1343 Some(reg) => {
1344 let entry = self.u2f_registrations.remove(index);
1345 (reg, entry.description)
1346 }
1347 };
1348
1349 // we do not care about the attestation certificates, so don't store them
1350 reg.certificate.clear();
1351
1352 Ok(TfaEntry::new(description, reg))
1353 }
1354
1355 /// Finish a webauthn registration. The challenge should correspond to an output of
1356 /// `webauthn_registration_challenge`. The response should come directly from the client.
1357 fn webauthn_registration_finish(
1358 &mut self,
637188d4 1359 webauthn: Webauthn<WebauthnConfigInstance>,
313d0a6b
WB
1360 challenge: &str,
1361 response: webauthn_rs::proto::RegisterPublicKeyCredential,
1362 existing_registrations: &[TfaEntry<WebauthnCredential>],
1363 ) -> Result<TfaEntry<WebauthnCredential>, Error> {
1364 let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
1365
1366 let index = self
1367 .webauthn_registrations
1368 .iter()
1369 .position(|r| r.challenge == challenge)
1370 .ok_or_else(|| format_err!("no such challenge"))?;
1371
1372 let reg = self.webauthn_registrations.remove(index);
1373 if reg.is_expired(expire_before) {
1374 bail!("no such challenge");
1375 }
1376
91932da1
FG
1377 let (credential, _authenticator) =
1378 webauthn.register_credential(&response, &reg.state, |id| -> Result<bool, ()> {
313d0a6b
WB
1379 Ok(existing_registrations
1380 .iter()
1381 .any(|cred| cred.entry.cred_id == *id))
1382 })?;
1383
148950fd 1384 Ok(TfaEntry::new(reg.description, credential.into()))
313d0a6b
WB
1385 }
1386}