1 use std
::collections
::HashMap
;
3 use std
::time
::Duration
;
5 use anyhow
::{bail, format_err, Error}
;
6 use serde
::{de::Deserializer, Deserialize, Serialize}
;
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
;
15 use crate::api2
::types
::Userid
;
17 /// Mapping of userid to TFA entry.
18 pub type TfaUsers
= HashMap
<Userid
, TfaUserData
>;
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);
24 /// U2F registration challenges time out after 2 minutes.
25 const CHALLENGE_TIMEOUT
: i64 = 2 * 60;
27 #[derive(Deserialize, Serialize)]
28 pub struct U2fConfig
{
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)]
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()))
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"))
52 fn u2f(&self) -> Option
<u2f
::U2f
> {
56 fn need_u2f(&self) -> Result
<u2f
::U2f
, Error
> {
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()),
68 /// Get a u2f registration challenge.
69 fn u2f_registration_challenge(
73 ) -> Result
<String
, Error
> {
74 let u2f
= self.need_u2f()?
;
79 .u2f_registration_challenge(&u2f
, description
)
82 /// Finish a u2f registration challenge.
83 fn u2f_registration_finish(
88 ) -> Result
<String
, Error
> {
89 let u2f
= self.need_u2f()?
;
91 match self.users
.get_mut(user
) {
92 Some(user
) => user
.u2f_registration_finish(&u2f
, challenge
, response
),
93 None
=> bail
!("no such challenge"),
97 /// Verify a TFA response.
101 challenge
: &TfaChallenge
,
102 response
: TfaResponse
,
103 ) -> Result
<(), Error
> {
104 match self.users
.get_mut(userid
) {
107 TfaResponse
::Totp(value
) => user
.verify_totp(&value
),
108 TfaResponse
::U2f(value
) => match &challenge
.u2f
{
110 let u2f
= need_u2f(&self.u2f
)?
;
111 user
.verify_u2f(u2f
, &challenge
.challenge
, value
)
113 None
=> bail
!("no u2f factor available for user '{}'", userid
),
115 TfaResponse
::Recovery(value
) => user
.verify_recovery(&value
),
118 None
=> bail
!("no 2nd factor available for user '{}'", userid
),
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)]
128 /// The id used to reference this entry.
131 /// User chosen description for this entry.
132 pub description
: String
,
134 /// Whether this TFA entry is currently enabled.
135 #[serde(skip_serializing_if = "is_default_tfa_enable")]
136 #[serde(default = "default_tfa_enable")]
141 /// For recovery keys we have a fixed entry.
142 pub(crate) fn recovery() -> Self {
144 id
: "recovery".to_string(),
145 description
: "recovery keys".to_string(),
151 /// A TFA entry for a user.
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
> {
160 /// The actual entry.
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 {
169 id
: Uuid
::generate().to_string(),
178 /// A u2f registration challenge.
179 #[derive(Deserialize, Serialize)]
180 #[serde(deny_unknown_fields)]
181 pub struct U2fRegistrationChallenge
{
182 /// JSON formatted challenge string.
185 /// The description chosen by the user for this registration.
188 /// When the challenge was created as unix epoch. They are supposed to be short-lived.
192 impl U2fRegistrationChallenge
{
193 pub fn new(challenge
: String
, description
: String
) -> Self {
197 created
: proxmox
::tools
::time
::epoch_i64(),
201 fn is_expired(&self, at_epoch
: i64) -> bool
{
202 self.created
< at_epoch
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
>>,
215 /// Registered u2f tokens for a user.
216 #[serde(skip_serializing_if = "Vec::is_empty", default)]
217 pub(crate) u2f
: Vec
<TfaEntry
<u2f
::Registration
>>,
219 /// Recovery keys. (Unordered OTP values).
220 #[serde(skip_serializing_if = "Vec::is_empty", default)]
221 pub(crate) recovery
: Vec
<String
>,
223 /// Active u2f registration challenges for a user.
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
>,
231 /// Serde helper using our `FilteredVecVisitor` to filter out expired entries directly at load
233 fn filter_expired_registrations
<'de
, D
>(
235 ) -> Result
<Vec
<U2fRegistrationChallenge
>, D
::Error
>
237 D
: Deserializer
<'de
>,
239 let expire_before
= proxmox
::tools
::time
::epoch_i64() - CHALLENGE_TIMEOUT
;
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
),
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()
254 /// Find an entry by id, except for the "recovery" entry which we're currently treating
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
);
263 for entry
in &mut self.u2f
{
264 if entry
.info
.id
== id
{
265 return Some(&mut entry
.info
);
272 /// Create a u2f registration challenge.
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
278 fn u2f_registration_challenge(
282 ) -> Result
<String
, Error
> {
283 let challenge
= serde_json
::to_string(&u2f
.registration_challenge()?
)?
;
285 self.u2f_registrations
.push(U2fRegistrationChallenge
::new(
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(
301 ) -> Result
<String
, Error
> {
302 let expire_before
= proxmox
::tools
::time
::epoch_i64() - CHALLENGE_TIMEOUT
;
307 .position(|r
| r
.challenge
== challenge
)
308 .ok_or_else(|| format_err
!("no such challenge"))?
;
310 let reg
= &self.u2f_registrations
[index
];
311 if reg
.is_expired(expire_before
) {
312 bail
!("no such challenge");
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"]
321 .ok_or_else(|| format_err
!("invalid registration challenge"))?
;
323 let (mut reg
, description
) = match u2f
.registration_verify(challenge
, response
)?
{
324 None
=> bail
!("verification failed"),
326 let entry
= self.u2f_registrations
.remove(index
);
327 (reg
, entry
.description
)
331 // we do not care about the attestation certificates, so don't store them
332 reg
.certificate
.clear();
334 let entry
= TfaEntry
::new(description
, reg
);
335 let id
= entry
.info
.id
.clone();
336 self.u2f
.push(entry
);
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
> {
346 Ok(Some(TfaChallenge
{
347 totp
: self.totp
.iter().any(|e
| e
.info
.enable
),
348 recovery
: RecoveryState
::from_count(self.recovery
.len()),
350 Some(u2f
) => self.u2f_challenge(u2f
)?
,
356 /// Helper to iterate over enabled totp entries.
357 fn enabled_totp_entries(&self) -> impl Iterator
<Item
= &Totp
> {
369 /// Helper to iterate over enabled u2f entries.
370 fn enabled_u2f_entries(&self) -> impl Iterator
<Item
= &u2f
::Registration
> {
382 /// Generate an optional u2f challenge.
383 fn u2f_challenge(&self, u2f
: &u2f
::U2f
) -> Result
<Option
<U2fChallenge
>, Error
> {
384 if self.u2f
.is_empty() {
388 let keys
: Vec
<u2f
::RegisteredKey
> = self
389 .enabled_u2f_entries()
390 .map(|registration
| registration
.key
.clone())
397 Ok(Some(U2fChallenge
{
398 challenge
: u2f
.auth_challenge()?
,
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();
407 for entry
in self.enabled_totp_entries() {
408 if entry
.verify(value
, now
, -1..=1)?
.is_some() {
413 bail
!("totp verification failed");
416 /// Verify a u2f response.
420 challenge
: &u2f
::AuthChallenge
,
422 ) -> Result
<(), Error
> {
423 let response
: u2f
::AuthResponse
= serde_json
::from_value(response
)
424 .map_err(|err
| format_err
!("invalid u2f response: {}", err
))?
;
426 if let Some(entry
) = self
427 .enabled_u2f_entries()
428 .find(|e
| e
.key
.key_handle
== response
.key_handle
)
430 if u2f
.auth_verify_obj(&entry
.public_key
, &challenge
.challenge
, response
)?
.is_some() {
435 bail
!("u2f verification failed");
438 /// Verify a recovery key.
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
) {
445 self.recovery
.remove(idx
);
448 None
=> bail
!("recovery verification failed"),
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");
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]));
464 Ok(self.recovery
.clone())
468 /// Read the TFA entries.
469 pub fn read() -> Result
<TfaConfig
, Error
> {
470 let file
= match File
::open(CONF_FILE
) {
472 Err(ref err
) if err
.not_found() => return Ok(TfaConfig
::default()),
473 Err(err
) => return Err(err
.into()),
476 Ok(serde_json
::from_reader(file
)?
)
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));
484 let json
= serde_json
::to_vec(data
)?
;
485 proxmox
::tools
::fs
::replace_file(CONF_FILE
, &json
, options
)
488 pub fn read_lock() -> Result
<File
, Error
> {
489 proxmox
::tools
::fs
::open_file_locked(LOCK_FILE
, LOCK_TIMEOUT
, false)
492 pub fn write_lock() -> Result
<File
, Error
> {
493 proxmox
::tools
::fs
::open_file_locked(LOCK_FILE
, LOCK_TIMEOUT
, true)
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();
503 .entry(userid
.clone())
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();
515 let mut data
= read()?
;
516 let out
= data
.users
.entry(userid
.clone()).or_default().add_recovery()?
;
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
)?
;
530 /// Finish a u2f registration challenge for a user.
531 pub fn finish_u2f_registration(
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
)?
;
543 /// Verify a TFA challenge.
544 pub fn verify_challenge(
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
)?
;
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
{
566 fn from_count(count
: usize) -> Self {
568 0 => RecoveryState
::Unavailable
,
569 1..=3 => RecoveryState
::Low
,
570 _
=> RecoveryState
::Available
,
574 // serde needs `&self` but this is a tiny Copy type, so we mark this as inline
576 fn is_unavailable(&self) -> bool
{
577 *self == RecoveryState
::Unavailable
581 impl Default
for RecoveryState
{
582 fn default() -> Self {
583 RecoveryState
::Unavailable
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.
595 /// Whether there are recovery keys available.
596 #[serde(skip_serializing_if = "RecoveryState::is_unavailable", default)]
597 recovery
: RecoveryState
,
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
>,
604 /// Data used for u2f challenges.
605 #[derive(Deserialize, Serialize)]
606 pub struct U2fChallenge
{
607 /// AppID and challenge data.
608 challenge
: u2f
::AuthChallenge
,
610 /// Available tokens/keys.
611 keys
: Vec
<u2f
::RegisteredKey
>,
614 /// A user's response to a TFA challenge.
615 pub enum TfaResponse
{
621 impl std
::str::FromStr
for TfaResponse
{
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())
632 bail
!("invalid tfa response");
637 const fn default_tfa_enable() -> bool
{
641 const fn is_default_tfa_enable(v
: &bool
) -> bool
{