]>
Commit | Line | Data |
---|---|---|
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 | ||
7 | use std::collections::HashMap; | |
8 | ||
9 | use anyhow::{bail, format_err, Error}; | |
10 | use serde::{Deserialize, Serialize}; | |
11 | use serde_json::Value; | |
12 | ||
313d0a6b WB |
13 | use webauthn_rs::{proto::UserVerificationPolicy, Webauthn}; |
14 | ||
15 | use crate::totp::Totp; | |
16 | use proxmox_uuid::Uuid; | |
17 | ||
18 | #[cfg(feature = "api-types")] | |
19 | use proxmox_schema::api; | |
20 | ||
21 | mod serde_tools; | |
22 | ||
23 | mod recovery; | |
24 | mod u2f; | |
25 | mod webauthn; | |
26 | ||
27 | pub mod methods; | |
28 | ||
29 | pub use recovery::RecoveryState; | |
30 | pub use u2f::U2fConfig; | |
148950fd | 31 | pub use webauthn::{WebauthnConfig, WebauthnCredential}; |
313d0a6b WB |
32 | |
33 | #[cfg(feature = "api-types")] | |
34 | pub use webauthn::WebauthnConfigUpdater; | |
35 | ||
36 | use recovery::Recovery; | |
37 | use u2f::{U2fChallenge, U2fChallengeEntry, U2fRegistrationChallenge}; | |
38 | use webauthn::{WebauthnAuthChallenge, WebauthnRegistrationChallenge}; | |
39 | ||
40 | trait IsExpired { | |
41 | fn is_expired(&self, at_epoch: i64) -> bool; | |
42 | } | |
43 | ||
44 | pub 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 | ||
55 | pub 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 | ||
62 | const CHALLENGE_TIMEOUT_SECS: i64 = 2 * 60; | |
63 | ||
64 | /// TFA Configuration for this instance. | |
65 | #[derive(Clone, Default, Deserialize, Serialize)] | |
66 | pub 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. | |
78 | fn 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`. | |
86 | fn 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. | |
92 | fn 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`. | |
99 | fn check_webauthn(waconfig: &Option<WebauthnConfig>) -> Result<Webauthn<WebauthnConfig>, Error> { | |
100 | get_webauthn(waconfig).ok_or_else(|| format_err!("no webauthn configuration available")) | |
101 | } | |
102 | ||
103 | impl 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)] | |
267 | pub enum NeedsSaving { | |
268 | No, | |
269 | Yes, | |
270 | } | |
271 | ||
272 | impl 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 | ||
279 | impl 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. | |
290 | pub 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 = ""))] | |
297 | pub 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 | ||
320 | impl 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)] | |
766 | pub struct TfaEntry<T> { | |
767 | #[serde(flatten)] | |
768 | pub info: TfaInfo, | |
769 | ||
770 | /// The actual entry. | |
771 | pub entry: T, | |
772 | } | |
773 | ||
774 | impl<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)] | |
798 | pub 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 | ||
815 | impl 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 | ||
827 | const fn default_tfa_enable() -> bool { | |
828 | true | |
829 | } | |
830 | ||
831 | const 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")] | |
840 | pub 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 | ||
863 | fn bool_is_false(v: &bool) -> bool { | |
864 | !v | |
865 | } | |
866 | ||
867 | /// A user's response to a TFA challenge. | |
868 | pub enum TfaResponse { | |
869 | Totp(String), | |
870 | U2f(Value), | |
871 | Webauthn(Value), | |
872 | Recovery(String), | |
873 | } | |
874 | ||
875 | /// This is part of the REST API: | |
876 | impl 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)] | |
897 | pub 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. | |
929 | fn filter_expired_challenge<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error> | |
930 | where | |
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 | ||
946 | impl 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 | } |