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