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