]> git.proxmox.com Git - proxmox-backup.git/blob - src/config/tfa.rs
config: add tfa configuration
[proxmox-backup.git] / src / config / tfa.rs
1 use std::collections::HashMap;
2 use std::fs::File;
3 use std::time::Duration;
4
5 use anyhow::{bail, format_err, Error};
6 use serde::{de::Deserializer, Deserialize, Serialize};
7 use serde_json::Value;
8
9 use proxmox::api::api;
10 use proxmox::sys::error::SysError;
11 use proxmox::tools::tfa::totp::Totp;
12 use proxmox::tools::tfa::u2f;
13 use proxmox::tools::uuid::Uuid;
14
15 use crate::api2::types::Userid;
16
17 /// Mapping of userid to TFA entry.
18 pub type TfaUsers = HashMap<Userid, TfaUserData>;
19
20 const CONF_FILE: &str = configdir!("/tfa.json");
21 const LOCK_FILE: &str = configdir!("/tfa.json.lock");
22 const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
23
24 /// U2F registration challenges time out after 2 minutes.
25 const CHALLENGE_TIMEOUT: i64 = 2 * 60;
26
27 #[derive(Deserialize, Serialize)]
28 pub struct U2fConfig {
29 appid: String,
30 }
31
32 #[derive(Default, Deserialize, Serialize)]
33 pub struct TfaConfig {
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub u2f: Option<U2fConfig>,
36 #[serde(skip_serializing_if = "TfaUsers::is_empty", default)]
37 pub users: TfaUsers,
38 }
39
40 /// Heper to get a u2f instance from a u2f config, or `None` if there isn't one configured.
41 fn get_u2f(u2f: &Option<U2fConfig>) -> Option<u2f::U2f> {
42 u2f.as_ref().map(|cfg| u2f::U2f::new(cfg.appid.clone(), cfg.appid.clone()))
43 }
44
45 /// Heper to get a u2f instance from a u2f config.
46 // deduplicate error message while working around self-borrow issue
47 fn need_u2f(u2f: &Option<U2fConfig>) -> Result<u2f::U2f, Error> {
48 get_u2f(u2f).ok_or_else(|| format_err!("no u2f configuration available"))
49 }
50
51 impl TfaConfig {
52 fn u2f(&self) -> Option<u2f::U2f> {
53 get_u2f(&self.u2f)
54 }
55
56 fn need_u2f(&self) -> Result<u2f::U2f, Error> {
57 need_u2f(&self.u2f)
58 }
59
60 /// Get a two factor authentication challenge for a user, if the user has TFA set up.
61 pub fn login_challenge(&self, userid: &Userid) -> Result<Option<TfaChallenge>, Error> {
62 match self.users.get(userid) {
63 Some(udata) => udata.challenge(self.u2f().as_ref()),
64 None => Ok(None),
65 }
66 }
67
68 /// Get a u2f registration challenge.
69 fn u2f_registration_challenge(
70 &mut self,
71 user: &Userid,
72 description: String,
73 ) -> Result<String, Error> {
74 let u2f = self.need_u2f()?;
75
76 self.users
77 .entry(user.clone())
78 .or_default()
79 .u2f_registration_challenge(&u2f, description)
80 }
81
82 /// Finish a u2f registration challenge.
83 fn u2f_registration_finish(
84 &mut self,
85 user: &Userid,
86 challenge: &str,
87 response: &str,
88 ) -> Result<String, Error> {
89 let u2f = self.need_u2f()?;
90
91 match self.users.get_mut(user) {
92 Some(user) => user.u2f_registration_finish(&u2f, challenge, response),
93 None => bail!("no such challenge"),
94 }
95 }
96
97 /// Verify a TFA response.
98 fn verify(
99 &mut self,
100 userid: &Userid,
101 challenge: &TfaChallenge,
102 response: TfaResponse,
103 ) -> Result<(), Error> {
104 match self.users.get_mut(userid) {
105 Some(user) => {
106 match response {
107 TfaResponse::Totp(value) => user.verify_totp(&value),
108 TfaResponse::U2f(value) => match &challenge.u2f {
109 Some(challenge) => {
110 let u2f = need_u2f(&self.u2f)?;
111 user.verify_u2f(u2f, &challenge.challenge, value)
112 }
113 None => bail!("no u2f factor available for user '{}'", userid),
114 }
115 TfaResponse::Recovery(value) => user.verify_recovery(&value),
116 }
117 }
118 None => bail!("no 2nd factor available for user '{}'", userid),
119 }
120 }
121 }
122
123 #[api]
124 /// Over the API we only provide this part when querying a user's second factor list.
125 #[derive(Deserialize, Serialize)]
126 #[serde(deny_unknown_fields)]
127 pub struct TfaInfo {
128 /// The id used to reference this entry.
129 pub id: String,
130
131 /// User chosen description for this entry.
132 pub description: String,
133
134 /// Whether this TFA entry is currently enabled.
135 #[serde(skip_serializing_if = "is_default_tfa_enable")]
136 #[serde(default = "default_tfa_enable")]
137 pub enable: bool,
138 }
139
140 impl TfaInfo {
141 /// For recovery keys we have a fixed entry.
142 pub(crate) fn recovery() -> Self {
143 Self {
144 id: "recovery".to_string(),
145 description: "recovery keys".to_string(),
146 enable: true,
147 }
148 }
149 }
150
151 /// A TFA entry for a user.
152 ///
153 /// This simply connects a raw registration to a non optional descriptive text chosen by the user.
154 #[derive(Deserialize, Serialize)]
155 #[serde(deny_unknown_fields)]
156 pub struct TfaEntry<T> {
157 #[serde(flatten)]
158 pub info: TfaInfo,
159
160 /// The actual entry.
161 entry: T,
162 }
163
164 impl<T> TfaEntry<T> {
165 /// Create an entry with a description. The id will be autogenerated.
166 fn new(description: String, entry: T) -> Self {
167 Self {
168 info: TfaInfo {
169 id: Uuid::generate().to_string(),
170 enable: true,
171 description,
172 },
173 entry,
174 }
175 }
176 }
177
178 /// A u2f registration challenge.
179 #[derive(Deserialize, Serialize)]
180 #[serde(deny_unknown_fields)]
181 pub struct U2fRegistrationChallenge {
182 /// JSON formatted challenge string.
183 challenge: String,
184
185 /// The description chosen by the user for this registration.
186 description: String,
187
188 /// When the challenge was created as unix epoch. They are supposed to be short-lived.
189 created: i64,
190 }
191
192 impl U2fRegistrationChallenge {
193 pub fn new(challenge: String, description: String) -> Self {
194 Self {
195 challenge,
196 description,
197 created: proxmox::tools::time::epoch_i64(),
198 }
199 }
200
201 fn is_expired(&self, at_epoch: i64) -> bool {
202 self.created < at_epoch
203 }
204 }
205
206 /// TFA data for a user.
207 #[derive(Default, Deserialize, Serialize)]
208 #[serde(deny_unknown_fields)]
209 #[serde(rename_all = "kebab-case")]
210 pub struct TfaUserData {
211 /// Totp keys for a user.
212 #[serde(skip_serializing_if = "Vec::is_empty", default)]
213 pub(crate) totp: Vec<TfaEntry<Totp>>,
214
215 /// Registered u2f tokens for a user.
216 #[serde(skip_serializing_if = "Vec::is_empty", default)]
217 pub(crate) u2f: Vec<TfaEntry<u2f::Registration>>,
218
219 /// Recovery keys. (Unordered OTP values).
220 #[serde(skip_serializing_if = "Vec::is_empty", default)]
221 pub(crate) recovery: Vec<String>,
222
223 /// Active u2f registration challenges for a user.
224 ///
225 /// Expired values are automatically filtered out while parsing the tfa configuration file.
226 #[serde(skip_serializing_if = "Vec::is_empty", default)]
227 #[serde(deserialize_with = "filter_expired_registrations")]
228 u2f_registrations: Vec<U2fRegistrationChallenge>,
229 }
230
231 /// Serde helper using our `FilteredVecVisitor` to filter out expired entries directly at load
232 /// time.
233 fn filter_expired_registrations<'de, D>(
234 deserializer: D,
235 ) -> Result<Vec<U2fRegistrationChallenge>, D::Error>
236 where
237 D: Deserializer<'de>,
238 {
239 let expire_before = proxmox::tools::time::epoch_i64() - CHALLENGE_TIMEOUT;
240 Ok(
241 deserializer.deserialize_seq(crate::tools::serde_filter::FilteredVecVisitor::new(
242 "a u2f registration challenge entry",
243 move |reg: &U2fRegistrationChallenge| !reg.is_expired(expire_before),
244 ))?,
245 )
246 }
247
248 impl TfaUserData {
249 /// `true` if no second factors exist
250 pub fn is_empty(&self) -> bool {
251 self.totp.is_empty() && self.u2f.is_empty() && self.recovery.is_empty()
252 }
253
254 /// Find an entry by id, except for the "recovery" entry which we're currently treating
255 /// specially.
256 pub fn find_entry_mut<'a>(&'a mut self, id: &str) -> Option<&'a mut TfaInfo> {
257 for entry in &mut self.totp {
258 if entry.info.id == id {
259 return Some(&mut entry.info);
260 }
261 }
262
263 for entry in &mut self.u2f {
264 if entry.info.id == id {
265 return Some(&mut entry.info);
266 }
267 }
268
269 None
270 }
271
272 /// Create a u2f registration challenge.
273 ///
274 /// The description is required at this point already mostly to better be able to identify such
275 /// challenges in the tfa config file if necessary. The user otherwise has no access to this
276 /// information at this point, as the challenge is identified by its actual challenge data
277 /// instead.
278 fn u2f_registration_challenge(
279 &mut self,
280 u2f: &u2f::U2f,
281 description: String,
282 ) -> Result<String, Error> {
283 let challenge = serde_json::to_string(&u2f.registration_challenge()?)?;
284
285 self.u2f_registrations.push(U2fRegistrationChallenge::new(
286 challenge.clone(),
287 description,
288 ));
289
290 Ok(challenge)
291 }
292
293 /// Finish a u2f registration. The challenge should correspond to an output of
294 /// `u2f_registration_challenge` (which is a stringified `RegistrationChallenge`). The response
295 /// should come directly from the client.
296 fn u2f_registration_finish(
297 &mut self,
298 u2f: &u2f::U2f,
299 challenge: &str,
300 response: &str,
301 ) -> Result<String, Error> {
302 let expire_before = proxmox::tools::time::epoch_i64() - CHALLENGE_TIMEOUT;
303
304 let index = self
305 .u2f_registrations
306 .iter()
307 .position(|r| r.challenge == challenge)
308 .ok_or_else(|| format_err!("no such challenge"))?;
309
310 let reg = &self.u2f_registrations[index];
311 if reg.is_expired(expire_before) {
312 bail!("no such challenge");
313 }
314
315 // the verify call only takes the actual challenge string, so we have to extract it
316 // (u2f::RegistrationChallenge did not always implement Deserialize...)
317 let chobj: Value = serde_json::from_str(challenge)
318 .map_err(|err| format_err!("error parsing original registration challenge: {}", err))?;
319 let challenge = chobj["challenge"]
320 .as_str()
321 .ok_or_else(|| format_err!("invalid registration challenge"))?;
322
323 let (mut reg, description) = match u2f.registration_verify(challenge, response)? {
324 None => bail!("verification failed"),
325 Some(reg) => {
326 let entry = self.u2f_registrations.remove(index);
327 (reg, entry.description)
328 }
329 };
330
331 // we do not care about the attestation certificates, so don't store them
332 reg.certificate.clear();
333
334 let entry = TfaEntry::new(description, reg);
335 let id = entry.info.id.clone();
336 self.u2f.push(entry);
337 Ok(id)
338 }
339
340 /// Generate a generic TFA challenge. See the [`TfaChallenge`] description for details.
341 pub fn challenge(&self, u2f: Option<&u2f::U2f>) -> Result<Option<TfaChallenge>, Error> {
342 if self.is_empty() {
343 return Ok(None);
344 }
345
346 Ok(Some(TfaChallenge {
347 totp: self.totp.iter().any(|e| e.info.enable),
348 recovery: RecoveryState::from_count(self.recovery.len()),
349 u2f: match u2f {
350 Some(u2f) => self.u2f_challenge(u2f)?,
351 None => None,
352 },
353 }))
354 }
355
356 /// Helper to iterate over enabled totp entries.
357 fn enabled_totp_entries(&self) -> impl Iterator<Item = &Totp> {
358 self.totp
359 .iter()
360 .filter_map(|e| {
361 if e.info.enable {
362 Some(&e.entry)
363 } else {
364 None
365 }
366 })
367 }
368
369 /// Helper to iterate over enabled u2f entries.
370 fn enabled_u2f_entries(&self) -> impl Iterator<Item = &u2f::Registration> {
371 self.u2f
372 .iter()
373 .filter_map(|e| {
374 if e.info.enable {
375 Some(&e.entry)
376 } else {
377 None
378 }
379 })
380 }
381
382 /// Generate an optional u2f challenge.
383 fn u2f_challenge(&self, u2f: &u2f::U2f) -> Result<Option<U2fChallenge>, Error> {
384 if self.u2f.is_empty() {
385 return Ok(None);
386 }
387
388 let keys: Vec<u2f::RegisteredKey> = self
389 .enabled_u2f_entries()
390 .map(|registration| registration.key.clone())
391 .collect();
392
393 if keys.is_empty() {
394 return Ok(None);
395 }
396
397 Ok(Some(U2fChallenge {
398 challenge: u2f.auth_challenge()?,
399 keys,
400 }))
401 }
402
403 /// Verify a totp challenge. The `value` should be the totp digits as plain text.
404 fn verify_totp(&self, value: &str) -> Result<(), Error> {
405 let now = std::time::SystemTime::now();
406
407 for entry in self.enabled_totp_entries() {
408 if entry.verify(value, now, -1..=1)?.is_some() {
409 return Ok(());
410 }
411 }
412
413 bail!("totp verification failed");
414 }
415
416 /// Verify a u2f response.
417 fn verify_u2f(
418 &self,
419 u2f: u2f::U2f,
420 challenge: &u2f::AuthChallenge,
421 response: Value,
422 ) -> Result<(), Error> {
423 let response: u2f::AuthResponse = serde_json::from_value(response)
424 .map_err(|err| format_err!("invalid u2f response: {}", err))?;
425
426 if let Some(entry) = self
427 .enabled_u2f_entries()
428 .find(|e| e.key.key_handle == response.key_handle)
429 {
430 if u2f.auth_verify_obj(&entry.public_key, &challenge.challenge, response)?.is_some() {
431 return Ok(());
432 }
433 }
434
435 bail!("u2f verification failed");
436 }
437
438 /// Verify a recovery key.
439 ///
440 /// NOTE: If successful, the key will automatically be removed from the list of available
441 /// recovery keys, so the configuration needs to be saved afterwards!
442 fn verify_recovery(&mut self, value: &str) -> Result<(), Error> {
443 match self.recovery.iter().position(|v| v == value) {
444 Some(idx) => {
445 self.recovery.remove(idx);
446 Ok(())
447 }
448 None => bail!("recovery verification failed"),
449 }
450 }
451
452 /// Add a new set of recovery keys. There can only be 1 set of keys at a time.
453 fn add_recovery(&mut self) -> Result<Vec<String>, Error> {
454 if !self.recovery.is_empty() {
455 bail!("user already has recovery keys");
456 }
457
458 let mut key_data = [0u8; 40]; // 10 keys of 32 bits
459 proxmox::sys::linux::fill_with_random_data(&mut key_data)?;
460 for b in key_data.chunks(4) {
461 self.recovery.push(format!("{:02x}{:02x}{:02x}{:02x}", b[0], b[1], b[2], b[3]));
462 }
463
464 Ok(self.recovery.clone())
465 }
466 }
467
468 /// Read the TFA entries.
469 pub fn read() -> Result<TfaConfig, Error> {
470 let file = match File::open(CONF_FILE) {
471 Ok(file) => file,
472 Err(ref err) if err.not_found() => return Ok(TfaConfig::default()),
473 Err(err) => return Err(err.into()),
474 };
475
476 Ok(serde_json::from_reader(file)?)
477 }
478
479 /// Requires the write lock to be held.
480 pub fn write(data: &TfaConfig) -> Result<(), Error> {
481 let options = proxmox::tools::fs::CreateOptions::new()
482 .perm(nix::sys::stat::Mode::from_bits_truncate(0o0600));
483
484 let json = serde_json::to_vec(data)?;
485 proxmox::tools::fs::replace_file(CONF_FILE, &json, options)
486 }
487
488 pub fn read_lock() -> Result<File, Error> {
489 proxmox::tools::fs::open_file_locked(LOCK_FILE, LOCK_TIMEOUT, false)
490 }
491
492 pub fn write_lock() -> Result<File, Error> {
493 proxmox::tools::fs::open_file_locked(LOCK_FILE, LOCK_TIMEOUT, true)
494 }
495
496 /// Add a TOTP entry for a user. Returns the ID.
497 pub fn add_totp(userid: &Userid, description: String, value: Totp) -> Result<String, Error> {
498 let _lock = crate::config::tfa::write_lock();
499 let mut data = read()?;
500 let entry = TfaEntry::new(description, value);
501 let id = entry.info.id.clone();
502 data.users
503 .entry(userid.clone())
504 .or_default()
505 .totp
506 .push(entry);
507 write(&data)?;
508 Ok(id)
509 }
510
511 /// Add recovery tokens for the user. Returns the token list.
512 pub fn add_recovery(userid: &Userid) -> Result<Vec<String>, Error> {
513 let _lock = crate::config::tfa::write_lock();
514
515 let mut data = read()?;
516 let out = data.users.entry(userid.clone()).or_default().add_recovery()?;
517 write(&data)?;
518 Ok(out)
519 }
520
521 /// Add a u2f registration challenge for a user.
522 pub fn add_u2f_registration(userid: &Userid, description: String) -> Result<String, Error> {
523 let _lock = crate::config::tfa::write_lock();
524 let mut data = read()?;
525 let challenge = data.u2f_registration_challenge(userid, description)?;
526 write(&data)?;
527 Ok(challenge)
528 }
529
530 /// Finish a u2f registration challenge for a user.
531 pub fn finish_u2f_registration(
532 userid: &Userid,
533 challenge: &str,
534 response: &str,
535 ) -> Result<String, Error> {
536 let _lock = crate::config::tfa::write_lock();
537 let mut data = read()?;
538 let challenge = data.u2f_registration_finish(userid, challenge, response)?;
539 write(&data)?;
540 Ok(challenge)
541 }
542
543 /// Verify a TFA challenge.
544 pub fn verify_challenge(
545 userid: &Userid,
546 challenge: &TfaChallenge,
547 response: TfaResponse,
548 ) -> Result<(), Error> {
549 let _lock = crate::config::tfa::write_lock();
550 let mut data = read()?;
551 data.verify(userid, challenge, response)?;
552 write(&data)?;
553 Ok(())
554 }
555
556 /// Used to inform the user about the recovery code status.
557 #[derive(Clone, Copy, Eq, PartialEq, Deserialize, Serialize)]
558 #[serde(rename_all = "kebab-case")]
559 pub enum RecoveryState {
560 Unavailable,
561 Low,
562 Available,
563 }
564
565 impl RecoveryState {
566 fn from_count(count: usize) -> Self {
567 match count {
568 0 => RecoveryState::Unavailable,
569 1..=3 => RecoveryState::Low,
570 _ => RecoveryState::Available,
571 }
572 }
573
574 // serde needs `&self` but this is a tiny Copy type, so we mark this as inline
575 #[inline]
576 fn is_unavailable(&self) -> bool {
577 *self == RecoveryState::Unavailable
578 }
579 }
580
581 impl Default for RecoveryState {
582 fn default() -> Self {
583 RecoveryState::Unavailable
584 }
585 }
586
587 /// When sending a TFA challenge to the user, we include information about what kind of challenge
588 /// the user may perform. If u2f devices are available, a u2f challenge will be included.
589 #[derive(Deserialize, Serialize)]
590 #[serde(rename_all = "kebab-case")]
591 pub struct TfaChallenge {
592 /// True if the user has TOTP devices.
593 totp: bool,
594
595 /// Whether there are recovery keys available.
596 #[serde(skip_serializing_if = "RecoveryState::is_unavailable", default)]
597 recovery: RecoveryState,
598
599 /// If the user has any u2f tokens registered, this will contain the U2F challenge data.
600 #[serde(skip_serializing_if = "Option::is_none")]
601 u2f: Option<U2fChallenge>,
602 }
603
604 /// Data used for u2f challenges.
605 #[derive(Deserialize, Serialize)]
606 pub struct U2fChallenge {
607 /// AppID and challenge data.
608 challenge: u2f::AuthChallenge,
609
610 /// Available tokens/keys.
611 keys: Vec<u2f::RegisteredKey>,
612 }
613
614 /// A user's response to a TFA challenge.
615 pub enum TfaResponse {
616 Totp(String),
617 U2f(Value),
618 Recovery(String),
619 }
620
621 impl std::str::FromStr for TfaResponse {
622 type Err = Error;
623
624 fn from_str(s: &str) -> Result<Self, Error> {
625 Ok(if s.starts_with("totp:") {
626 TfaResponse::Totp(s[5..].to_string())
627 } else if s.starts_with("u2f:") {
628 TfaResponse::U2f(serde_json::from_str(&s[4..])?)
629 } else if s.starts_with("recovery:") {
630 TfaResponse::Recovery(s[9..].to_string())
631 } else {
632 bail!("invalid tfa response");
633 })
634 }
635 }
636
637 const fn default_tfa_enable() -> bool {
638 true
639 }
640
641 const fn is_default_tfa_enable(v: &bool) -> bool {
642 *v
643 }