]> git.proxmox.com Git - proxmox-backup.git/blob - src/config/tfa.rs
split out pbs-buildcfg module
[proxmox-backup.git] / src / config / tfa.rs
1 use std::collections::HashMap;
2 use std::fs::File;
3 use std::io::{self, Read, Seek, SeekFrom};
4 use std::os::unix::fs::OpenOptionsExt;
5 use std::os::unix::io::AsRawFd;
6 use std::path::PathBuf;
7 use std::time::Duration;
8
9 use anyhow::{bail, format_err, Error};
10 use nix::sys::stat::Mode;
11 use openssl::hash::MessageDigest;
12 use openssl::pkey::PKey;
13 use openssl::sign::Signer;
14 use serde::{de::Deserializer, Deserialize, Serialize};
15 use serde_json::Value;
16 use webauthn_rs::{proto::UserVerificationPolicy, Webauthn};
17
18 use webauthn_rs::proto::Credential as WebauthnCredential;
19
20 use proxmox::api::api;
21 use proxmox::api::schema::Updater;
22 use proxmox::sys::error::SysError;
23 use proxmox::tools::fs::CreateOptions;
24 use proxmox::tools::tfa::totp::Totp;
25 use proxmox::tools::tfa::u2f;
26 use proxmox::tools::uuid::Uuid;
27 use proxmox::tools::AsHex;
28
29 use pbs_buildcfg::configdir;
30
31 use crate::api2::types::Userid;
32
33 /// Mapping of userid to TFA entry.
34 pub type TfaUsers = HashMap<Userid, TfaUserData>;
35
36 const CONF_FILE: &str = configdir!("/tfa.json");
37 const LOCK_FILE: &str = configdir!("/tfa.json.lock");
38 const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
39
40 const CHALLENGE_DATA_PATH: &str = pbs_buildcfg::rundir!("/tfa/challenges");
41
42 /// U2F registration challenges time out after 2 minutes.
43 const CHALLENGE_TIMEOUT: i64 = 2 * 60;
44
45 pub fn read_lock() -> Result<File, Error> {
46 proxmox::tools::fs::open_file_locked(LOCK_FILE, LOCK_TIMEOUT, false)
47 }
48
49 pub fn write_lock() -> Result<File, Error> {
50 proxmox::tools::fs::open_file_locked(LOCK_FILE, LOCK_TIMEOUT, true)
51 }
52
53 /// Read the TFA entries.
54 pub fn read() -> Result<TfaConfig, Error> {
55 let file = match File::open(CONF_FILE) {
56 Ok(file) => file,
57 Err(ref err) if err.not_found() => return Ok(TfaConfig::default()),
58 Err(err) => return Err(err.into()),
59 };
60
61 Ok(serde_json::from_reader(file)?)
62 }
63
64 /// Get the webauthn config with a digest.
65 ///
66 /// This is meant only for configuration updates, which currently only means webauthn updates.
67 /// Since this is meant to be done only once (since changes will lock out users), this should be
68 /// used rarely, since the digest calculation is currently a bit more involved.
69 pub fn webauthn_config() -> Result<Option<(WebauthnConfig, [u8; 32])>, Error>{
70 Ok(match read()?.webauthn {
71 Some(wa) => {
72 let digest = wa.digest()?;
73 Some((wa, digest))
74 }
75 None => None,
76 })
77 }
78
79 /// Requires the write lock to be held.
80 pub fn write(data: &TfaConfig) -> Result<(), Error> {
81 let options = CreateOptions::new().perm(Mode::from_bits_truncate(0o0600));
82
83 let json = serde_json::to_vec(data)?;
84 proxmox::tools::fs::replace_file(CONF_FILE, &json, options)
85 }
86
87 #[derive(Deserialize, Serialize)]
88 pub struct U2fConfig {
89 appid: String,
90 }
91
92 #[api]
93 #[derive(Clone, Deserialize, Serialize, Updater)]
94 #[serde(deny_unknown_fields)]
95 /// Server side webauthn server configuration.
96 pub struct WebauthnConfig {
97 /// Relying party name. Any text identifier.
98 ///
99 /// Changing this *may* break existing credentials.
100 rp: String,
101
102 /// Site origin. Must be a `https://` URL (or `http://localhost`). Should contain the address
103 /// users type in their browsers to access the web interface.
104 ///
105 /// Changing this *may* break existing credentials.
106 origin: String,
107
108 /// Relying part ID. Must be the domain name without protocol, port or location.
109 ///
110 /// Changing this *will* break existing credentials.
111 id: String,
112 }
113
114 impl WebauthnConfig {
115 pub fn digest(&self) -> Result<[u8; 32], Error> {
116 let digest_data = crate::tools::json::to_canonical_json(&serde_json::to_value(self)?)?;
117 Ok(openssl::sha::sha256(&digest_data))
118 }
119 }
120
121 /// For now we just implement this on the configuration this way.
122 ///
123 /// Note that we may consider changing this so `get_origin` returns the `Host:` header provided by
124 /// the connecting client.
125 impl webauthn_rs::WebauthnConfig for WebauthnConfig {
126 fn get_relying_party_name(&self) -> String {
127 self.rp.clone()
128 }
129
130 fn get_origin(&self) -> &String {
131 &self.origin
132 }
133
134 fn get_relying_party_id(&self) -> String {
135 self.id.clone()
136 }
137 }
138
139 /// Helper to get a u2f instance from a u2f config, or `None` if there isn't one configured.
140 fn get_u2f(u2f: &Option<U2fConfig>) -> Option<u2f::U2f> {
141 u2f.as_ref()
142 .map(|cfg| u2f::U2f::new(cfg.appid.clone(), cfg.appid.clone()))
143 }
144
145 /// Helper to get a u2f instance from a u2f config.
146 ///
147 /// This is outside of `TfaConfig` to not borrow its `&self`.
148 fn check_u2f(u2f: &Option<U2fConfig>) -> Result<u2f::U2f, Error> {
149 get_u2f(u2f).ok_or_else(|| format_err!("no u2f configuration available"))
150 }
151
152 /// Helper to get a `Webauthn` instance from a `WebauthnConfig`, or `None` if there isn't one
153 /// configured.
154 fn get_webauthn(waconfig: &Option<WebauthnConfig>) -> Option<Webauthn<WebauthnConfig>> {
155 waconfig.clone().map(Webauthn::new)
156 }
157
158 /// Helper to get a u2f instance from a u2f config.
159 ///
160 /// This is outside of `TfaConfig` to not borrow its `&self`.
161 fn check_webauthn(waconfig: &Option<WebauthnConfig>) -> Result<Webauthn<WebauthnConfig>, Error> {
162 get_webauthn(waconfig).ok_or_else(|| format_err!("no webauthn configuration available"))
163 }
164
165 /// TFA Configuration for this instance.
166 #[derive(Default, Deserialize, Serialize)]
167 pub struct TfaConfig {
168 #[serde(skip_serializing_if = "Option::is_none")]
169 pub u2f: Option<U2fConfig>,
170
171 #[serde(skip_serializing_if = "Option::is_none")]
172 pub webauthn: Option<WebauthnConfig>,
173
174 #[serde(skip_serializing_if = "TfaUsers::is_empty", default)]
175 pub users: TfaUsers,
176 }
177
178 impl TfaConfig {
179 /// Get a two factor authentication challenge for a user, if the user has TFA set up.
180 pub fn login_challenge(&mut self, userid: &Userid) -> Result<Option<TfaChallenge>, Error> {
181 match self.users.get_mut(userid) {
182 Some(udata) => udata.challenge(
183 userid,
184 get_webauthn(&self.webauthn),
185 get_u2f(&self.u2f).as_ref(),
186 ),
187 None => Ok(None),
188 }
189 }
190
191 /// Get a u2f registration challenge.
192 fn u2f_registration_challenge(
193 &mut self,
194 userid: &Userid,
195 description: String,
196 ) -> Result<String, Error> {
197 let u2f = check_u2f(&self.u2f)?;
198
199 self.users
200 .entry(userid.clone())
201 .or_default()
202 .u2f_registration_challenge(userid, &u2f, description)
203 }
204
205 /// Finish a u2f registration challenge.
206 fn u2f_registration_finish(
207 &mut self,
208 userid: &Userid,
209 challenge: &str,
210 response: &str,
211 ) -> Result<String, Error> {
212 let u2f = check_u2f(&self.u2f)?;
213
214 match self.users.get_mut(userid) {
215 Some(user) => user.u2f_registration_finish(userid, &u2f, challenge, response),
216 None => bail!("no such challenge"),
217 }
218 }
219
220 /// Get a webauthn registration challenge.
221 fn webauthn_registration_challenge(
222 &mut self,
223 user: &Userid,
224 description: String,
225 ) -> Result<String, Error> {
226 let webauthn = check_webauthn(&self.webauthn)?;
227
228 self.users
229 .entry(user.clone())
230 .or_default()
231 .webauthn_registration_challenge(webauthn, user, description)
232 }
233
234 /// Finish a webauthn registration challenge.
235 fn webauthn_registration_finish(
236 &mut self,
237 userid: &Userid,
238 challenge: &str,
239 response: &str,
240 ) -> Result<String, Error> {
241 let webauthn = check_webauthn(&self.webauthn)?;
242
243 let response: webauthn_rs::proto::RegisterPublicKeyCredential =
244 serde_json::from_str(response)
245 .map_err(|err| format_err!("error parsing challenge response: {}", err))?;
246
247 match self.users.get_mut(userid) {
248 Some(user) => user.webauthn_registration_finish(webauthn, userid, challenge, response),
249 None => bail!("no such challenge"),
250 }
251 }
252
253 /// Verify a TFA response.
254 fn verify(
255 &mut self,
256 userid: &Userid,
257 challenge: &TfaChallenge,
258 response: TfaResponse,
259 ) -> Result<(), Error> {
260 match self.users.get_mut(userid) {
261 Some(user) => match response {
262 TfaResponse::Totp(value) => user.verify_totp(&value),
263 TfaResponse::U2f(value) => match &challenge.u2f {
264 Some(challenge) => {
265 let u2f = check_u2f(&self.u2f)?;
266 user.verify_u2f(u2f, &challenge.challenge, value)
267 }
268 None => bail!("no u2f factor available for user '{}'", userid),
269 },
270 TfaResponse::Webauthn(value) => {
271 let webauthn = check_webauthn(&self.webauthn)?;
272 user.verify_webauthn(userid, webauthn, value)
273 }
274 TfaResponse::Recovery(value) => user.verify_recovery(&value),
275 },
276 None => bail!("no 2nd factor available for user '{}'", userid),
277 }
278 }
279
280 /// Remove non-existent users.
281 pub fn cleanup_users(&mut self, config: &proxmox::api::section_config::SectionConfigData) {
282 use crate::config::user::User;
283 self.users
284 .retain(|user, _| config.lookup::<User>("user", user.as_str()).is_ok());
285 }
286
287 /// Remove a user. Returns `true` if the user actually existed.
288 pub fn remove_user(&mut self, user: &Userid) -> bool {
289 self.users.remove(user).is_some()
290 }
291 }
292
293 #[api]
294 /// Over the API we only provide this part when querying a user's second factor list.
295 #[derive(Deserialize, Serialize)]
296 #[serde(deny_unknown_fields)]
297 pub struct TfaInfo {
298 /// The id used to reference this entry.
299 pub id: String,
300
301 /// User chosen description for this entry.
302 #[serde(skip_serializing_if = "String::is_empty")]
303 pub description: String,
304
305 /// Creation time of this entry as unix epoch.
306 pub created: i64,
307
308 /// Whether this TFA entry is currently enabled.
309 #[serde(skip_serializing_if = "is_default_tfa_enable")]
310 #[serde(default = "default_tfa_enable")]
311 pub enable: bool,
312 }
313
314 impl TfaInfo {
315 /// For recovery keys we have a fixed entry.
316 pub(crate) fn recovery(created: i64) -> Self {
317 Self {
318 id: "recovery".to_string(),
319 description: String::new(),
320 enable: true,
321 created,
322 }
323 }
324 }
325
326 /// A TFA entry for a user.
327 ///
328 /// This simply connects a raw registration to a non optional descriptive text chosen by the user.
329 #[derive(Deserialize, Serialize)]
330 #[serde(deny_unknown_fields)]
331 pub struct TfaEntry<T> {
332 #[serde(flatten)]
333 pub info: TfaInfo,
334
335 /// The actual entry.
336 entry: T,
337 }
338
339 impl<T> TfaEntry<T> {
340 /// Create an entry with a description. The id will be autogenerated.
341 fn new(description: String, entry: T) -> Self {
342 Self {
343 info: TfaInfo {
344 id: Uuid::generate().to_string(),
345 enable: true,
346 description,
347 created: proxmox::tools::time::epoch_i64(),
348 },
349 entry,
350 }
351 }
352 }
353
354 trait IsExpired {
355 fn is_expired(&self, at_epoch: i64) -> bool;
356 }
357
358 /// A u2f registration challenge.
359 #[derive(Deserialize, Serialize)]
360 #[serde(deny_unknown_fields)]
361 pub struct U2fRegistrationChallenge {
362 /// JSON formatted challenge string.
363 challenge: String,
364
365 /// The description chosen by the user for this registration.
366 description: String,
367
368 /// When the challenge was created as unix epoch. They are supposed to be short-lived.
369 created: i64,
370 }
371
372 impl U2fRegistrationChallenge {
373 pub fn new(challenge: String, description: String) -> Self {
374 Self {
375 challenge,
376 description,
377 created: proxmox::tools::time::epoch_i64(),
378 }
379 }
380 }
381
382 impl IsExpired for U2fRegistrationChallenge {
383 fn is_expired(&self, at_epoch: i64) -> bool {
384 self.created < at_epoch
385 }
386 }
387
388 /// A webauthn registration challenge.
389 #[derive(Deserialize, Serialize)]
390 #[serde(deny_unknown_fields)]
391 pub struct WebauthnRegistrationChallenge {
392 /// Server side registration state data.
393 state: webauthn_rs::RegistrationState,
394
395 /// While this is basically the content of a `RegistrationState`, the webauthn-rs crate doesn't
396 /// make this public.
397 challenge: String,
398
399 /// The description chosen by the user for this registration.
400 description: String,
401
402 /// When the challenge was created as unix epoch. They are supposed to be short-lived.
403 created: i64,
404 }
405
406 impl WebauthnRegistrationChallenge {
407 pub fn new(
408 state: webauthn_rs::RegistrationState,
409 challenge: String,
410 description: String,
411 ) -> Self {
412 Self {
413 state,
414 challenge,
415 description,
416 created: proxmox::tools::time::epoch_i64(),
417 }
418 }
419 }
420
421 impl IsExpired for WebauthnRegistrationChallenge {
422 fn is_expired(&self, at_epoch: i64) -> bool {
423 self.created < at_epoch
424 }
425 }
426
427 /// A webauthn authentication challenge.
428 #[derive(Deserialize, Serialize)]
429 #[serde(deny_unknown_fields)]
430 pub struct WebauthnAuthChallenge {
431 /// Server side authentication state.
432 state: webauthn_rs::AuthenticationState,
433
434 /// While this is basically the content of a `AuthenticationState`, the webauthn-rs crate
435 /// doesn't make this public.
436 challenge: String,
437
438 /// When the challenge was created as unix epoch. They are supposed to be short-lived.
439 created: i64,
440 }
441
442 impl WebauthnAuthChallenge {
443 pub fn new(state: webauthn_rs::AuthenticationState, challenge: String) -> Self {
444 Self {
445 state,
446 challenge,
447 created: proxmox::tools::time::epoch_i64(),
448 }
449 }
450 }
451
452 impl IsExpired for WebauthnAuthChallenge {
453 fn is_expired(&self, at_epoch: i64) -> bool {
454 self.created < at_epoch
455 }
456 }
457
458 /// Active TFA challenges per user, stored in `CHALLENGE_DATA_PATH`.
459 #[derive(Default, Deserialize, Serialize)]
460 pub struct TfaUserChallenges {
461 /// Active u2f registration challenges for a user.
462 ///
463 /// Expired values are automatically filtered out while parsing the tfa configuration file.
464 #[serde(skip_serializing_if = "Vec::is_empty", default)]
465 #[serde(deserialize_with = "filter_expired_challenge")]
466 u2f_registrations: Vec<U2fRegistrationChallenge>,
467
468 /// Active webauthn registration challenges for a user.
469 ///
470 /// Expired values are automatically filtered out while parsing the tfa configuration file.
471 #[serde(skip_serializing_if = "Vec::is_empty", default)]
472 #[serde(deserialize_with = "filter_expired_challenge")]
473 webauthn_registrations: Vec<WebauthnRegistrationChallenge>,
474
475 /// Active webauthn registration challenges for a user.
476 ///
477 /// Expired values are automatically filtered out while parsing the tfa configuration file.
478 #[serde(skip_serializing_if = "Vec::is_empty", default)]
479 #[serde(deserialize_with = "filter_expired_challenge")]
480 webauthn_auths: Vec<WebauthnAuthChallenge>,
481 }
482
483 /// Container of `TfaUserChallenges` with the corresponding file lock guard.
484 ///
485 /// TODO: Implement a general file lock guarded struct container in the `proxmox` crate.
486 pub struct TfaUserChallengeData {
487 inner: TfaUserChallenges,
488 path: PathBuf,
489 lock: File,
490 }
491
492 impl TfaUserChallengeData {
493 /// Build the path to the challenge data file for a user.
494 fn challenge_data_path(userid: &Userid) -> PathBuf {
495 PathBuf::from(format!("{}/{}", CHALLENGE_DATA_PATH, userid))
496 }
497
498 /// Load the user's current challenges with the intent to create a challenge (create the file
499 /// if it does not exist), and keep a lock on the file.
500 fn open(userid: &Userid) -> Result<Self, Error> {
501 crate::tools::create_run_dir()?;
502 let options = CreateOptions::new().perm(Mode::from_bits_truncate(0o0600));
503 proxmox::tools::fs::create_path(CHALLENGE_DATA_PATH, Some(options.clone()), Some(options))
504 .map_err(|err| {
505 format_err!(
506 "failed to crate challenge data dir {:?}: {}",
507 CHALLENGE_DATA_PATH,
508 err
509 )
510 })?;
511
512 let path = Self::challenge_data_path(userid);
513
514 let mut file = std::fs::OpenOptions::new()
515 .create(true)
516 .read(true)
517 .write(true)
518 .truncate(false)
519 .mode(0o600)
520 .open(&path)
521 .map_err(|err| format_err!("failed to create challenge file {:?}: {}", path, err))?;
522
523 proxmox::tools::fs::lock_file(&mut file, true, None)?;
524
525 // the file may be empty, so read to a temporary buffer first:
526 let mut data = Vec::with_capacity(4096);
527
528 file.read_to_end(&mut data).map_err(|err| {
529 format_err!("failed to read challenge data for user {}: {}", userid, err)
530 })?;
531
532 let inner = if data.is_empty() {
533 Default::default()
534 } else {
535 serde_json::from_slice(&data).map_err(|err| {
536 format_err!(
537 "failed to parse challenge data for user {}: {}",
538 userid,
539 err
540 )
541 })?
542 };
543
544 Ok(Self {
545 inner,
546 path,
547 lock: file,
548 })
549 }
550
551 /// `open` without creating the file if it doesn't exist, to finish WA authentications.
552 fn open_no_create(userid: &Userid) -> Result<Option<Self>, Error> {
553 let path = Self::challenge_data_path(userid);
554 let mut file = match std::fs::OpenOptions::new()
555 .read(true)
556 .write(true)
557 .truncate(false)
558 .mode(0o600)
559 .open(&path)
560 {
561 Ok(file) => file,
562 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
563 Err(err) => return Err(err.into()),
564 };
565
566 proxmox::tools::fs::lock_file(&mut file, true, None)?;
567
568 let inner = serde_json::from_reader(&mut file).map_err(|err| {
569 format_err!("failed to read challenge data for user {}: {}", userid, err)
570 })?;
571
572 Ok(Some(Self {
573 inner,
574 path,
575 lock: file,
576 }))
577 }
578
579 /// Rewind & truncate the file for an update.
580 fn rewind(&mut self) -> Result<(), Error> {
581 let pos = self.lock.seek(SeekFrom::Start(0))?;
582 if pos != 0 {
583 bail!(
584 "unexpected result trying to rewind file, position is {}",
585 pos
586 );
587 }
588
589 proxmox::c_try!(unsafe { libc::ftruncate(self.lock.as_raw_fd(), 0) });
590
591 Ok(())
592 }
593
594 /// Save the current data. Note that we do not replace the file here since we lock the file
595 /// itself, as it is in `/run`, and the typical error case for this particular situation
596 /// (machine loses power) simply prevents some login, but that'll probably fail anyway for
597 /// other reasons then...
598 ///
599 /// This currently consumes selfe as we never perform more than 1 insertion/removal, and this
600 /// way also unlocks early.
601 fn save(mut self) -> Result<(), Error> {
602 self.rewind()?;
603
604 serde_json::to_writer(&mut &self.lock, &self.inner).map_err(|err| {
605 format_err!("failed to update challenge file {:?}: {}", self.path, err)
606 })?;
607
608 Ok(())
609 }
610
611 /// Finish a u2f registration. The challenge should correspond to an output of
612 /// `u2f_registration_challenge` (which is a stringified `RegistrationChallenge`). The response
613 /// should come directly from the client.
614 fn u2f_registration_finish(
615 &mut self,
616 u2f: &u2f::U2f,
617 challenge: &str,
618 response: &str,
619 ) -> Result<TfaEntry<u2f::Registration>, Error> {
620 let expire_before = proxmox::tools::time::epoch_i64() - CHALLENGE_TIMEOUT;
621
622 let index = self
623 .inner
624 .u2f_registrations
625 .iter()
626 .position(|r| r.challenge == challenge)
627 .ok_or_else(|| format_err!("no such challenge"))?;
628
629 let reg = &self.inner.u2f_registrations[index];
630 if reg.is_expired(expire_before) {
631 bail!("no such challenge");
632 }
633
634 // the verify call only takes the actual challenge string, so we have to extract it
635 // (u2f::RegistrationChallenge did not always implement Deserialize...)
636 let chobj: Value = serde_json::from_str(challenge)
637 .map_err(|err| format_err!("error parsing original registration challenge: {}", err))?;
638 let challenge = chobj["challenge"]
639 .as_str()
640 .ok_or_else(|| format_err!("invalid registration challenge"))?;
641
642 let (mut reg, description) = match u2f.registration_verify(challenge, response)? {
643 None => bail!("verification failed"),
644 Some(reg) => {
645 let entry = self.inner.u2f_registrations.remove(index);
646 (reg, entry.description)
647 }
648 };
649
650 // we do not care about the attestation certificates, so don't store them
651 reg.certificate.clear();
652
653 Ok(TfaEntry::new(description, reg))
654 }
655
656 /// Finish a webauthn registration. The challenge should correspond to an output of
657 /// `webauthn_registration_challenge`. The response should come directly from the client.
658 fn webauthn_registration_finish(
659 &mut self,
660 webauthn: Webauthn<WebauthnConfig>,
661 challenge: &str,
662 response: webauthn_rs::proto::RegisterPublicKeyCredential,
663 existing_registrations: &[TfaEntry<WebauthnCredential>],
664 ) -> Result<TfaEntry<WebauthnCredential>, Error> {
665 let expire_before = proxmox::tools::time::epoch_i64() - CHALLENGE_TIMEOUT;
666
667 let index = self
668 .inner
669 .webauthn_registrations
670 .iter()
671 .position(|r| r.challenge == challenge)
672 .ok_or_else(|| format_err!("no such challenge"))?;
673
674 let reg = self.inner.webauthn_registrations.remove(index);
675 if reg.is_expired(expire_before) {
676 bail!("no such challenge");
677 }
678
679 let credential =
680 webauthn.register_credential(response, reg.state, |id| -> Result<bool, ()> {
681 Ok(existing_registrations
682 .iter()
683 .any(|cred| cred.entry.cred_id == *id))
684 })?;
685
686 Ok(TfaEntry::new(reg.description, credential))
687 }
688 }
689
690 /// TFA data for a user.
691 #[derive(Default, Deserialize, Serialize)]
692 #[serde(deny_unknown_fields)]
693 #[serde(rename_all = "kebab-case")]
694 pub struct TfaUserData {
695 /// Totp keys for a user.
696 #[serde(skip_serializing_if = "Vec::is_empty", default)]
697 pub(crate) totp: Vec<TfaEntry<Totp>>,
698
699 /// Registered u2f tokens for a user.
700 #[serde(skip_serializing_if = "Vec::is_empty", default)]
701 pub(crate) u2f: Vec<TfaEntry<u2f::Registration>>,
702
703 /// Registered webauthn tokens for a user.
704 #[serde(skip_serializing_if = "Vec::is_empty", default)]
705 pub(crate) webauthn: Vec<TfaEntry<WebauthnCredential>>,
706
707 /// Recovery keys. (Unordered OTP values).
708 #[serde(skip_serializing_if = "Recovery::option_is_empty", default)]
709 pub(crate) recovery: Option<Recovery>,
710 }
711
712 impl TfaUserData {
713 /// Shortcut to get the recovery entry only if it is not empty!
714 pub fn recovery(&self) -> Option<&Recovery> {
715 if Recovery::option_is_empty(&self.recovery) {
716 None
717 } else {
718 self.recovery.as_ref()
719 }
720 }
721
722 /// `true` if no second factors exist
723 pub fn is_empty(&self) -> bool {
724 self.totp.is_empty()
725 && self.u2f.is_empty()
726 && self.webauthn.is_empty()
727 && self.recovery().is_none()
728 }
729
730 /// Find an entry by id, except for the "recovery" entry which we're currently treating
731 /// specially.
732 pub fn find_entry_mut<'a>(&'a mut self, id: &str) -> Option<&'a mut TfaInfo> {
733 for entry in &mut self.totp {
734 if entry.info.id == id {
735 return Some(&mut entry.info);
736 }
737 }
738
739 for entry in &mut self.webauthn {
740 if entry.info.id == id {
741 return Some(&mut entry.info);
742 }
743 }
744
745 for entry in &mut self.u2f {
746 if entry.info.id == id {
747 return Some(&mut entry.info);
748 }
749 }
750
751 None
752 }
753
754 /// Create a u2f registration challenge.
755 ///
756 /// The description is required at this point already mostly to better be able to identify such
757 /// challenges in the tfa config file if necessary. The user otherwise has no access to this
758 /// information at this point, as the challenge is identified by its actual challenge data
759 /// instead.
760 fn u2f_registration_challenge(
761 &mut self,
762 userid: &Userid,
763 u2f: &u2f::U2f,
764 description: String,
765 ) -> Result<String, Error> {
766 let challenge = serde_json::to_string(&u2f.registration_challenge()?)?;
767
768 let mut data = TfaUserChallengeData::open(userid)?;
769 data.inner
770 .u2f_registrations
771 .push(U2fRegistrationChallenge::new(
772 challenge.clone(),
773 description,
774 ));
775 data.save()?;
776
777 Ok(challenge)
778 }
779
780 fn u2f_registration_finish(
781 &mut self,
782 userid: &Userid,
783 u2f: &u2f::U2f,
784 challenge: &str,
785 response: &str,
786 ) -> Result<String, Error> {
787 let mut data = TfaUserChallengeData::open(userid)?;
788 let entry = data.u2f_registration_finish(u2f, challenge, response)?;
789 data.save()?;
790
791 let id = entry.info.id.clone();
792 self.u2f.push(entry);
793 Ok(id)
794 }
795
796 /// Create a webauthn registration challenge.
797 ///
798 /// The description is required at this point already mostly to better be able to identify such
799 /// challenges in the tfa config file if necessary. The user otherwise has no access to this
800 /// information at this point, as the challenge is identified by its actual challenge data
801 /// instead.
802 fn webauthn_registration_challenge(
803 &mut self,
804 mut webauthn: Webauthn<WebauthnConfig>,
805 userid: &Userid,
806 description: String,
807 ) -> Result<String, Error> {
808 let cred_ids: Vec<_> = self
809 .enabled_webauthn_entries()
810 .map(|cred| cred.cred_id.clone())
811 .collect();
812
813 let userid_str = userid.to_string();
814 let (challenge, state) = webauthn.generate_challenge_register_options(
815 userid_str.as_bytes().to_vec(),
816 userid_str.clone(),
817 userid_str.clone(),
818 Some(cred_ids),
819 Some(UserVerificationPolicy::Discouraged),
820 )?;
821
822 let challenge_string = challenge.public_key.challenge.to_string();
823 let challenge = serde_json::to_string(&challenge)?;
824
825 let mut data = TfaUserChallengeData::open(userid)?;
826 data.inner
827 .webauthn_registrations
828 .push(WebauthnRegistrationChallenge::new(
829 state,
830 challenge_string,
831 description,
832 ));
833 data.save()?;
834
835 Ok(challenge)
836 }
837
838 /// Finish a webauthn registration. The challenge should correspond to an output of
839 /// `webauthn_registration_challenge`. The response should come directly from the client.
840 fn webauthn_registration_finish(
841 &mut self,
842 webauthn: Webauthn<WebauthnConfig>,
843 userid: &Userid,
844 challenge: &str,
845 response: webauthn_rs::proto::RegisterPublicKeyCredential,
846 ) -> Result<String, Error> {
847 let mut data = TfaUserChallengeData::open(userid)?;
848 let entry =
849 data.webauthn_registration_finish(webauthn, challenge, response, &self.webauthn)?;
850 data.save()?;
851
852 let id = entry.info.id.clone();
853 self.webauthn.push(entry);
854 Ok(id)
855 }
856
857 /// Generate a generic TFA challenge. See the [`TfaChallenge`] description for details.
858 pub fn challenge(
859 &mut self,
860 userid: &Userid,
861 webauthn: Option<Webauthn<WebauthnConfig>>,
862 u2f: Option<&u2f::U2f>,
863 ) -> Result<Option<TfaChallenge>, Error> {
864 if self.is_empty() {
865 return Ok(None);
866 }
867
868 Ok(Some(TfaChallenge {
869 totp: self.totp.iter().any(|e| e.info.enable),
870 recovery: RecoveryState::from(&self.recovery),
871 webauthn: match webauthn {
872 Some(webauthn) => self.webauthn_challenge(userid, webauthn)?,
873 None => None,
874 },
875 u2f: match u2f {
876 Some(u2f) => self.u2f_challenge(u2f)?,
877 None => None,
878 },
879 }))
880 }
881
882 /// Helper to iterate over enabled totp entries.
883 fn enabled_totp_entries(&self) -> impl Iterator<Item = &Totp> {
884 self.totp
885 .iter()
886 .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None })
887 }
888
889 /// Helper to iterate over enabled u2f entries.
890 fn enabled_u2f_entries(&self) -> impl Iterator<Item = &u2f::Registration> {
891 self.u2f
892 .iter()
893 .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None })
894 }
895
896 /// Helper to iterate over enabled u2f entries.
897 fn enabled_webauthn_entries(&self) -> impl Iterator<Item = &WebauthnCredential> {
898 self.webauthn
899 .iter()
900 .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None })
901 }
902
903 /// Generate an optional u2f challenge.
904 fn u2f_challenge(&self, u2f: &u2f::U2f) -> Result<Option<U2fChallenge>, Error> {
905 if self.u2f.is_empty() {
906 return Ok(None);
907 }
908
909 let keys: Vec<u2f::RegisteredKey> = self
910 .enabled_u2f_entries()
911 .map(|registration| registration.key.clone())
912 .collect();
913
914 if keys.is_empty() {
915 return Ok(None);
916 }
917
918 Ok(Some(U2fChallenge {
919 challenge: u2f.auth_challenge()?,
920 keys,
921 }))
922 }
923
924 /// Generate an optional webauthn challenge.
925 fn webauthn_challenge(
926 &mut self,
927 userid: &Userid,
928 mut webauthn: Webauthn<WebauthnConfig>,
929 ) -> Result<Option<webauthn_rs::proto::RequestChallengeResponse>, Error> {
930 if self.webauthn.is_empty() {
931 return Ok(None);
932 }
933
934 let creds: Vec<_> = self.enabled_webauthn_entries().map(Clone::clone).collect();
935
936 if creds.is_empty() {
937 return Ok(None);
938 }
939
940 let (challenge, state) = webauthn
941 .generate_challenge_authenticate(creds, Some(UserVerificationPolicy::Discouraged))?;
942 let challenge_string = challenge.public_key.challenge.to_string();
943 let mut data = TfaUserChallengeData::open(userid)?;
944 data.inner
945 .webauthn_auths
946 .push(WebauthnAuthChallenge::new(state, challenge_string));
947 data.save()?;
948
949 Ok(Some(challenge))
950 }
951
952 /// Verify a totp challenge. The `value` should be the totp digits as plain text.
953 fn verify_totp(&self, value: &str) -> Result<(), Error> {
954 let now = std::time::SystemTime::now();
955
956 for entry in self.enabled_totp_entries() {
957 if entry.verify(value, now, -1..=1)?.is_some() {
958 return Ok(());
959 }
960 }
961
962 bail!("totp verification failed");
963 }
964
965 /// Verify a u2f response.
966 fn verify_u2f(
967 &self,
968 u2f: u2f::U2f,
969 challenge: &u2f::AuthChallenge,
970 response: Value,
971 ) -> Result<(), Error> {
972 let response: u2f::AuthResponse = serde_json::from_value(response)
973 .map_err(|err| format_err!("invalid u2f response: {}", err))?;
974
975 if let Some(entry) = self
976 .enabled_u2f_entries()
977 .find(|e| e.key.key_handle == response.key_handle())
978 {
979 if u2f
980 .auth_verify_obj(&entry.public_key, &challenge.challenge, response)?
981 .is_some()
982 {
983 return Ok(());
984 }
985 }
986
987 bail!("u2f verification failed");
988 }
989
990 /// Verify a webauthn response.
991 fn verify_webauthn(
992 &mut self,
993 userid: &Userid,
994 mut webauthn: Webauthn<WebauthnConfig>,
995 mut response: Value,
996 ) -> Result<(), Error> {
997 let expire_before = proxmox::tools::time::epoch_i64() - CHALLENGE_TIMEOUT;
998
999 let challenge = match response
1000 .as_object_mut()
1001 .ok_or_else(|| format_err!("invalid response, must be a json object"))?
1002 .remove("challenge")
1003 .ok_or_else(|| format_err!("missing challenge data in response"))?
1004 {
1005 Value::String(s) => s,
1006 _ => bail!("invalid challenge data in response"),
1007 };
1008
1009 let response: webauthn_rs::proto::PublicKeyCredential = serde_json::from_value(response)
1010 .map_err(|err| format_err!("invalid webauthn response: {}", err))?;
1011
1012 let mut data = match TfaUserChallengeData::open_no_create(userid)? {
1013 Some(data) => data,
1014 None => bail!("no such challenge"),
1015 };
1016
1017 let index = data
1018 .inner
1019 .webauthn_auths
1020 .iter()
1021 .position(|r| r.challenge == challenge)
1022 .ok_or_else(|| format_err!("no such challenge"))?;
1023
1024 let challenge = data.inner.webauthn_auths.remove(index);
1025 if challenge.is_expired(expire_before) {
1026 bail!("no such challenge");
1027 }
1028
1029 // we don't allow re-trying the challenge, so make the removal persistent now:
1030 data.save()
1031 .map_err(|err| format_err!("failed to save challenge file: {}", err))?;
1032
1033 match webauthn.authenticate_credential(response, challenge.state)? {
1034 Some((_cred, _counter)) => Ok(()),
1035 None => bail!("webauthn authentication failed"),
1036 }
1037 }
1038
1039 /// Verify a recovery key.
1040 ///
1041 /// NOTE: If successful, the key will automatically be removed from the list of available
1042 /// recovery keys, so the configuration needs to be saved afterwards!
1043 fn verify_recovery(&mut self, value: &str) -> Result<(), Error> {
1044 if let Some(r) = &mut self.recovery {
1045 if r.verify(value)? {
1046 return Ok(());
1047 }
1048 }
1049 bail!("recovery verification failed");
1050 }
1051
1052 /// Add a new set of recovery keys. There can only be 1 set of keys at a time.
1053 fn add_recovery(&mut self) -> Result<Vec<String>, Error> {
1054 if self.recovery.is_some() {
1055 bail!("user already has recovery keys");
1056 }
1057
1058 let (recovery, original) = Recovery::generate()?;
1059
1060 self.recovery = Some(recovery);
1061
1062 Ok(original)
1063 }
1064 }
1065
1066 /// Recovery entries. We use HMAC-SHA256 with a random secret as a salted hash replacement.
1067 #[derive(Deserialize, Serialize)]
1068 pub struct Recovery {
1069 /// "Salt" used for the key HMAC.
1070 secret: String,
1071
1072 /// Recovery key entries are HMACs of the original data. When used up they will become `None`
1073 /// since the user is presented an enumerated list of codes, so we know the indices of used and
1074 /// unused codes.
1075 entries: Vec<Option<String>>,
1076
1077 /// Creation timestamp as a unix epoch.
1078 pub created: i64,
1079 }
1080
1081 impl Recovery {
1082 /// Generate recovery keys and return the recovery entry along with the original string
1083 /// entries.
1084 fn generate() -> Result<(Self, Vec<String>), Error> {
1085 let mut secret = [0u8; 8];
1086 proxmox::sys::linux::fill_with_random_data(&mut secret)?;
1087
1088 let mut this = Self {
1089 secret: AsHex(&secret).to_string(),
1090 entries: Vec::with_capacity(10),
1091 created: proxmox::tools::time::epoch_i64(),
1092 };
1093
1094 let mut original = Vec::new();
1095
1096 let mut key_data = [0u8; 80]; // 10 keys of 12 bytes
1097 proxmox::sys::linux::fill_with_random_data(&mut key_data)?;
1098 for b in key_data.chunks(8) {
1099 let entry = format!(
1100 "{}-{}-{}-{}",
1101 AsHex(&b[0..2]),
1102 AsHex(&b[2..4]),
1103 AsHex(&b[4..6]),
1104 AsHex(&b[6..8]),
1105 );
1106
1107 this.entries.push(Some(this.hash(entry.as_bytes())?));
1108 original.push(entry);
1109 }
1110
1111 Ok((this, original))
1112 }
1113
1114 /// Perform HMAC-SHA256 on the data and return the result as a hex string.
1115 fn hash(&self, data: &[u8]) -> Result<String, Error> {
1116 let secret = PKey::hmac(self.secret.as_bytes())
1117 .map_err(|err| format_err!("error instantiating hmac key: {}", err))?;
1118
1119 let mut signer = Signer::new(MessageDigest::sha256(), &secret)
1120 .map_err(|err| format_err!("error instantiating hmac signer: {}", err))?;
1121
1122 let hmac = signer
1123 .sign_oneshot_to_vec(data)
1124 .map_err(|err| format_err!("error calculating hmac: {}", err))?;
1125
1126 Ok(AsHex(&hmac).to_string())
1127 }
1128
1129 /// Iterator over available keys.
1130 fn available(&self) -> impl Iterator<Item = &str> {
1131 self.entries.iter().filter_map(Option::as_deref)
1132 }
1133
1134 /// Count the available keys.
1135 fn count_available(&self) -> usize {
1136 self.available().count()
1137 }
1138
1139 /// Convenience serde method to check if either the option is `None` or the content `is_empty`.
1140 fn option_is_empty(this: &Option<Self>) -> bool {
1141 this.as_ref()
1142 .map_or(true, |this| this.count_available() == 0)
1143 }
1144
1145 /// Verify a key and remove it. Returns whether the key was valid. Errors on openssl errors.
1146 fn verify(&mut self, key: &str) -> Result<bool, Error> {
1147 let hash = self.hash(key.as_bytes())?;
1148 for entry in &mut self.entries {
1149 if entry.as_ref() == Some(&hash) {
1150 *entry = None;
1151 return Ok(true);
1152 }
1153 }
1154 Ok(false)
1155 }
1156 }
1157
1158 /// Serde helper using our `FilteredVecVisitor` to filter out expired entries directly at load
1159 /// time.
1160 fn filter_expired_challenge<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
1161 where
1162 D: Deserializer<'de>,
1163 T: Deserialize<'de> + IsExpired,
1164 {
1165 let expire_before = proxmox::tools::time::epoch_i64() - CHALLENGE_TIMEOUT;
1166 Ok(
1167 deserializer.deserialize_seq(crate::tools::serde_filter::FilteredVecVisitor::new(
1168 "a challenge entry",
1169 move |reg: &T| !reg.is_expired(expire_before),
1170 ))?,
1171 )
1172 }
1173
1174 /// Get an optional TFA challenge for a user.
1175 pub fn login_challenge(userid: &Userid) -> Result<Option<TfaChallenge>, Error> {
1176 let _lock = write_lock()?;
1177
1178 let mut data = read()?;
1179 Ok(match data.login_challenge(userid)? {
1180 Some(challenge) => {
1181 write(&data)?;
1182 Some(challenge)
1183 }
1184 None => None,
1185 })
1186 }
1187
1188 /// Add a TOTP entry for a user. Returns the ID.
1189 pub fn add_totp(userid: &Userid, description: String, value: Totp) -> Result<String, Error> {
1190 let _lock = write_lock();
1191 let mut data = read()?;
1192 let entry = TfaEntry::new(description, value);
1193 let id = entry.info.id.clone();
1194 data.users
1195 .entry(userid.clone())
1196 .or_default()
1197 .totp
1198 .push(entry);
1199 write(&data)?;
1200 Ok(id)
1201 }
1202
1203 /// Add recovery tokens for the user. Returns the token list.
1204 pub fn add_recovery(userid: &Userid) -> Result<Vec<String>, Error> {
1205 let _lock = write_lock();
1206
1207 let mut data = read()?;
1208 let out = data
1209 .users
1210 .entry(userid.clone())
1211 .or_default()
1212 .add_recovery()?;
1213 write(&data)?;
1214 Ok(out)
1215 }
1216
1217 /// Add a u2f registration challenge for a user.
1218 pub fn add_u2f_registration(userid: &Userid, description: String) -> Result<String, Error> {
1219 let _lock = crate::config::tfa::write_lock();
1220 let mut data = read()?;
1221 let challenge = data.u2f_registration_challenge(userid, description)?;
1222 write(&data)?;
1223 Ok(challenge)
1224 }
1225
1226 /// Finish a u2f registration challenge for a user.
1227 pub fn finish_u2f_registration(
1228 userid: &Userid,
1229 challenge: &str,
1230 response: &str,
1231 ) -> Result<String, Error> {
1232 let _lock = crate::config::tfa::write_lock();
1233 let mut data = read()?;
1234 let id = data.u2f_registration_finish(userid, challenge, response)?;
1235 write(&data)?;
1236 Ok(id)
1237 }
1238
1239 /// Add a webauthn registration challenge for a user.
1240 pub fn add_webauthn_registration(userid: &Userid, description: String) -> Result<String, Error> {
1241 let _lock = crate::config::tfa::write_lock();
1242 let mut data = read()?;
1243 let challenge = data.webauthn_registration_challenge(userid, description)?;
1244 write(&data)?;
1245 Ok(challenge)
1246 }
1247
1248 /// Finish a webauthn registration challenge for a user.
1249 pub fn finish_webauthn_registration(
1250 userid: &Userid,
1251 challenge: &str,
1252 response: &str,
1253 ) -> Result<String, Error> {
1254 let _lock = crate::config::tfa::write_lock();
1255 let mut data = read()?;
1256 let id = data.webauthn_registration_finish(userid, challenge, response)?;
1257 write(&data)?;
1258 Ok(id)
1259 }
1260
1261 /// Verify a TFA challenge.
1262 pub fn verify_challenge(
1263 userid: &Userid,
1264 challenge: &TfaChallenge,
1265 response: TfaResponse,
1266 ) -> Result<(), Error> {
1267 let _lock = crate::config::tfa::write_lock();
1268 let mut data = read()?;
1269 data.verify(userid, challenge, response)?;
1270 write(&data)?;
1271 Ok(())
1272 }
1273
1274 /// Used to inform the user about the recovery code status.
1275 ///
1276 /// This contains the available key indices.
1277 #[derive(Clone, Default, Eq, PartialEq, Deserialize, Serialize)]
1278 pub struct RecoveryState(Vec<usize>);
1279
1280 impl RecoveryState {
1281 fn is_unavailable(&self) -> bool {
1282 self.0.is_empty()
1283 }
1284 }
1285
1286 impl From<&Option<Recovery>> for RecoveryState {
1287 fn from(r: &Option<Recovery>) -> Self {
1288 match r {
1289 Some(r) => Self::from(r),
1290 None => Self::default(),
1291 }
1292 }
1293 }
1294
1295 impl From<&Recovery> for RecoveryState {
1296 fn from(r: &Recovery) -> Self {
1297 Self(
1298 r.entries
1299 .iter()
1300 .enumerate()
1301 .filter_map(|(idx, key)| if key.is_some() { Some(idx) } else { None })
1302 .collect(),
1303 )
1304 }
1305 }
1306
1307 /// When sending a TFA challenge to the user, we include information about what kind of challenge
1308 /// the user may perform. If webauthn credentials are available, a webauthn challenge will be
1309 /// included.
1310 #[derive(Deserialize, Serialize)]
1311 #[serde(rename_all = "kebab-case")]
1312 pub struct TfaChallenge {
1313 /// True if the user has TOTP devices.
1314 totp: bool,
1315
1316 /// Whether there are recovery keys available.
1317 #[serde(skip_serializing_if = "RecoveryState::is_unavailable", default)]
1318 recovery: RecoveryState,
1319
1320 /// If the user has any u2f tokens registered, this will contain the U2F challenge data.
1321 #[serde(skip_serializing_if = "Option::is_none")]
1322 u2f: Option<U2fChallenge>,
1323
1324 /// If the user has any webauthn credentials registered, this will contain the corresponding
1325 /// challenge data.
1326 #[serde(skip_serializing_if = "Option::is_none", skip_deserializing)]
1327 webauthn: Option<webauthn_rs::proto::RequestChallengeResponse>,
1328 }
1329
1330 /// Data used for u2f challenges.
1331 #[derive(Deserialize, Serialize)]
1332 pub struct U2fChallenge {
1333 /// AppID and challenge data.
1334 challenge: u2f::AuthChallenge,
1335
1336 /// Available tokens/keys.
1337 keys: Vec<u2f::RegisteredKey>,
1338 }
1339
1340 /// A user's response to a TFA challenge.
1341 pub enum TfaResponse {
1342 Totp(String),
1343 U2f(Value),
1344 Webauthn(Value),
1345 Recovery(String),
1346 }
1347
1348 impl std::str::FromStr for TfaResponse {
1349 type Err = Error;
1350
1351 fn from_str(s: &str) -> Result<Self, Error> {
1352 Ok(if let Some(totp) = s.strip_prefix("totp:") {
1353 TfaResponse::Totp(totp.to_string())
1354 } else if let Some(u2f) = s.strip_prefix("u2f:") {
1355 TfaResponse::U2f(serde_json::from_str(u2f)?)
1356 } else if let Some(webauthn) = s.strip_prefix("webauthn:") {
1357 TfaResponse::Webauthn(serde_json::from_str(webauthn)?)
1358 } else if let Some(recovery) = s.strip_prefix("recovery:") {
1359 TfaResponse::Recovery(recovery.to_string())
1360 } else {
1361 bail!("invalid tfa response");
1362 })
1363 }
1364 }
1365
1366 const fn default_tfa_enable() -> bool {
1367 true
1368 }
1369
1370 const fn is_default_tfa_enable(v: &bool) -> bool {
1371 *v
1372 }