mirror of
https://git.proxmox.com/git/proxmox-perl-rs
synced 2025-05-23 01:55:18 +00:00
move proxmox_tfa_api module to proxmox-tfa as api feature
Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
This commit is contained in:
parent
7cb0164e4a
commit
84417400ed
@ -30,11 +30,4 @@ perlmod = { version = "0.8.1", features = [ "exporter" ] }
|
||||
|
||||
proxmox-apt = "0.8"
|
||||
proxmox-openid = "0.8"
|
||||
|
||||
#proxmox-tfa-api = { path = "../proxmox-tfa-api", version = "0.1" }
|
||||
|
||||
# Dependencies purely in proxmox-tfa-api:
|
||||
webauthn-rs = "0.2.5"
|
||||
proxmox-time = "1"
|
||||
proxmox-uuid = "1"
|
||||
proxmox-tfa = { version = "1.2", features = ["u2f"] }
|
||||
proxmox-tfa = { version = "1.3", features = ["api"] }
|
||||
|
@ -20,8 +20,7 @@ use nix::errno::Errno;
|
||||
use nix::sys::stat::Mode;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
mod proxmox_tfa_api;
|
||||
pub(self) use proxmox_tfa_api::{
|
||||
pub(self) use proxmox_tfa::api::{
|
||||
RecoveryState, TfaChallenge, TfaConfig, TfaResponse, TfaUserData, U2fConfig, WebauthnConfig,
|
||||
};
|
||||
|
||||
@ -34,8 +33,8 @@ mod export {
|
||||
use serde_bytes::ByteBuf;
|
||||
|
||||
use perlmod::Value;
|
||||
use proxmox_tfa::api::methods;
|
||||
|
||||
use super::proxmox_tfa_api::api;
|
||||
use super::{TfaConfig, UserAccess};
|
||||
|
||||
perlmod::declare_magic!(Box<Tfa> : &Tfa as "PVE::RS::TFA");
|
||||
@ -300,8 +299,8 @@ mod export {
|
||||
fn api_list_user_tfa(
|
||||
#[try_from_ref] this: &Tfa,
|
||||
userid: &str,
|
||||
) -> Result<Vec<api::TypedTfaInfo>, Error> {
|
||||
api::list_user_tfa(&this.inner.lock().unwrap(), userid)
|
||||
) -> Result<Vec<methods::TypedTfaInfo>, Error> {
|
||||
methods::list_user_tfa(&this.inner.lock().unwrap(), userid)
|
||||
}
|
||||
|
||||
#[export]
|
||||
@ -309,8 +308,8 @@ mod export {
|
||||
#[try_from_ref] this: &Tfa,
|
||||
userid: &str,
|
||||
id: &str,
|
||||
) -> Result<Option<api::TypedTfaInfo>, Error> {
|
||||
api::get_tfa_entry(&this.inner.lock().unwrap(), userid, id)
|
||||
) -> Option<methods::TypedTfaInfo> {
|
||||
methods::get_tfa_entry(&this.inner.lock().unwrap(), userid, id)
|
||||
}
|
||||
|
||||
/// Returns `true` if the user still has other TFA entries left, `false` if the user has *no*
|
||||
@ -318,9 +317,9 @@ mod export {
|
||||
#[export]
|
||||
fn api_delete_tfa(#[try_from_ref] this: &Tfa, userid: &str, id: String) -> Result<bool, Error> {
|
||||
let mut this = this.inner.lock().unwrap();
|
||||
match api::delete_tfa(&mut this, userid, id) {
|
||||
match methods::delete_tfa(&mut this, userid, &id) {
|
||||
Ok(has_entries_left) => Ok(has_entries_left),
|
||||
Err(api::EntryNotFound) => bail!("no such entry"),
|
||||
Err(methods::EntryNotFound) => bail!("no such entry"),
|
||||
}
|
||||
}
|
||||
|
||||
@ -329,8 +328,8 @@ mod export {
|
||||
#[try_from_ref] this: &Tfa,
|
||||
authid: &str,
|
||||
top_level_allowed: bool,
|
||||
) -> Result<Vec<api::TfaUser>, Error> {
|
||||
api::list_tfa(&this.inner.lock().unwrap(), authid, top_level_allowed)
|
||||
) -> Result<Vec<methods::TfaUser>, Error> {
|
||||
methods::list_tfa(&this.inner.lock().unwrap(), authid, top_level_allowed)
|
||||
}
|
||||
|
||||
#[export]
|
||||
@ -342,10 +341,10 @@ mod export {
|
||||
totp: Option<String>,
|
||||
value: Option<String>,
|
||||
challenge: Option<String>,
|
||||
ty: api::TfaType,
|
||||
) -> Result<api::TfaUpdateInfo, Error> {
|
||||
ty: methods::TfaType,
|
||||
) -> Result<methods::TfaUpdateInfo, Error> {
|
||||
let this: &Tfa = (&raw_this).try_into()?;
|
||||
api::add_tfa_entry(
|
||||
methods::add_tfa_entry(
|
||||
&mut this.inner.lock().unwrap(),
|
||||
UserAccess::new(&raw_this)?,
|
||||
userid,
|
||||
@ -396,7 +395,7 @@ mod export {
|
||||
description: Option<String>,
|
||||
enable: Option<bool>,
|
||||
) -> Result<(), Error> {
|
||||
match api::update_tfa_entry(
|
||||
match methods::update_tfa_entry(
|
||||
&mut this.inner.lock().unwrap(),
|
||||
userid,
|
||||
id,
|
||||
@ -404,7 +403,7 @@ mod export {
|
||||
enable,
|
||||
) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(api::EntryNotFound) => bail!("no such entry"),
|
||||
Err(methods::EntryNotFound) => bail!("no such entry"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -479,7 +478,7 @@ fn parse_old_config(data: &[u8]) -> Result<TfaConfig, Error> {
|
||||
fn decode_old_entry(ty: &[u8], data: &[u8], user: &str) -> Result<TfaUserData, Error> {
|
||||
let mut user_data = TfaUserData::default();
|
||||
|
||||
let info = proxmox_tfa_api::TfaInfo {
|
||||
let info = proxmox_tfa::api::TfaInfo {
|
||||
id: "v1-entry".to_string(),
|
||||
description: "<old version 1 entry>".to_string(),
|
||||
created: 0,
|
||||
@ -494,18 +493,18 @@ fn decode_old_entry(ty: &[u8], data: &[u8], user: &str) -> Result<TfaUserData, E
|
||||
if let Some(entry) = decode_old_u2f_entry(value)? {
|
||||
user_data
|
||||
.u2f
|
||||
.push(proxmox_tfa_api::TfaEntry::from_parts(info, entry))
|
||||
.push(proxmox_tfa::api::TfaEntry::from_parts(info, entry))
|
||||
}
|
||||
}
|
||||
b"oath" => user_data.totp.extend(
|
||||
decode_old_oath_entry(value, user)?
|
||||
.into_iter()
|
||||
.map(move |entry| proxmox_tfa_api::TfaEntry::from_parts(info.clone(), entry)),
|
||||
.map(move |entry| proxmox_tfa::api::TfaEntry::from_parts(info.clone(), entry)),
|
||||
),
|
||||
b"yubico" => user_data.yubico.extend(
|
||||
decode_old_yubico_entry(value)?
|
||||
.into_iter()
|
||||
.map(move |entry| proxmox_tfa_api::TfaEntry::from_parts(info.clone(), entry)),
|
||||
.map(move |entry| proxmox_tfa::api::TfaEntry::from_parts(info.clone(), entry)),
|
||||
),
|
||||
other => match std::str::from_utf8(other) {
|
||||
Ok(s) => bail!("unknown tfa.cfg entry type: {:?}", s),
|
||||
@ -852,7 +851,7 @@ fn challenge_data_path(userid: &str, debug: bool) -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
impl proxmox_tfa_api::OpenUserChallengeData for UserAccess {
|
||||
impl proxmox_tfa::api::OpenUserChallengeData for UserAccess {
|
||||
type Data = UserChallengeData;
|
||||
|
||||
fn open(&self, userid: &str) -> Result<UserChallengeData, Error> {
|
||||
@ -930,6 +929,15 @@ impl proxmox_tfa_api::OpenUserChallengeData for UserAccess {
|
||||
lock: file,
|
||||
}))
|
||||
}
|
||||
|
||||
fn remove(&self, userid: &str) -> Result<bool, Error> {
|
||||
let path = challenge_data_path(userid, self.is_debug());
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Container of `TfaUserChallenges` with the corresponding file lock guard.
|
||||
@ -937,13 +945,13 @@ impl proxmox_tfa_api::OpenUserChallengeData for UserAccess {
|
||||
/// Basically provides the TFA API to the REST server by persisting, updating and verifying active
|
||||
/// challenges.
|
||||
pub struct UserChallengeData {
|
||||
inner: proxmox_tfa_api::TfaUserChallenges,
|
||||
inner: proxmox_tfa::api::TfaUserChallenges,
|
||||
path: PathBuf,
|
||||
lock: File,
|
||||
}
|
||||
|
||||
impl proxmox_tfa_api::UserChallengeAccess for UserChallengeData {
|
||||
fn get_mut(&mut self) -> &mut proxmox_tfa_api::TfaUserChallenges {
|
||||
impl proxmox_tfa::api::UserChallengeAccess for UserChallengeData {
|
||||
fn get_mut(&mut self) -> &mut proxmox_tfa::api::TfaUserChallenges {
|
||||
&mut self.inner
|
||||
}
|
||||
|
@ -1,491 +0,0 @@
|
||||
//! API interaction module.
|
||||
//!
|
||||
//! This defines the methods & types used in the authentication and TFA configuration API between
|
||||
//! PBS, PVE, PMG.
|
||||
|
||||
use anyhow::{bail, format_err, Error};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use proxmox_tfa::totp::Totp;
|
||||
|
||||
#[cfg(feature = "api-types")]
|
||||
use proxmox_schema::api;
|
||||
|
||||
use super::{OpenUserChallengeData, TfaConfig, TfaInfo, TfaUserData};
|
||||
|
||||
#[cfg_attr(feature = "api-types", api)]
|
||||
/// A TFA entry type.
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TfaType {
|
||||
/// A TOTP entry type.
|
||||
Totp,
|
||||
/// A U2F token entry.
|
||||
U2f,
|
||||
/// A Webauthn token entry.
|
||||
Webauthn,
|
||||
/// Recovery tokens.
|
||||
Recovery,
|
||||
/// Yubico authentication entry.
|
||||
Yubico,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "api-types", api(
|
||||
properties: {
|
||||
type: { type: TfaType },
|
||||
info: { type: TfaInfo },
|
||||
},
|
||||
))]
|
||||
/// A TFA entry for a user.
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TypedTfaInfo {
|
||||
#[serde(rename = "type")]
|
||||
pub ty: TfaType,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub info: TfaInfo,
|
||||
}
|
||||
|
||||
fn to_data(data: &TfaUserData) -> Vec<TypedTfaInfo> {
|
||||
let mut out = Vec::with_capacity(
|
||||
data.totp.len()
|
||||
+ data.u2f.len()
|
||||
+ data.webauthn.len()
|
||||
+ data.yubico.len()
|
||||
+ if data.recovery().is_some() { 1 } else { 0 },
|
||||
);
|
||||
if let Some(recovery) = data.recovery() {
|
||||
out.push(TypedTfaInfo {
|
||||
ty: TfaType::Recovery,
|
||||
info: TfaInfo::recovery(recovery.created),
|
||||
})
|
||||
}
|
||||
for entry in &data.totp {
|
||||
out.push(TypedTfaInfo {
|
||||
ty: TfaType::Totp,
|
||||
info: entry.info.clone(),
|
||||
});
|
||||
}
|
||||
for entry in &data.webauthn {
|
||||
out.push(TypedTfaInfo {
|
||||
ty: TfaType::Webauthn,
|
||||
info: entry.info.clone(),
|
||||
});
|
||||
}
|
||||
for entry in &data.u2f {
|
||||
out.push(TypedTfaInfo {
|
||||
ty: TfaType::U2f,
|
||||
info: entry.info.clone(),
|
||||
});
|
||||
}
|
||||
for entry in &data.yubico {
|
||||
out.push(TypedTfaInfo {
|
||||
ty: TfaType::Yubico,
|
||||
info: entry.info.clone(),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Iterate through tuples of `(type, index, id)`.
|
||||
fn tfa_id_iter(data: &TfaUserData) -> impl Iterator<Item = (TfaType, usize, &str)> {
|
||||
data.totp
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, entry)| (TfaType::Totp, i, entry.info.id.as_str()))
|
||||
.chain(
|
||||
data.webauthn
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, entry)| (TfaType::Webauthn, i, entry.info.id.as_str())),
|
||||
)
|
||||
.chain(
|
||||
data.u2f
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, entry)| (TfaType::U2f, i, entry.info.id.as_str())),
|
||||
)
|
||||
.chain(
|
||||
data.yubico
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, entry)| (TfaType::Yubico, i, entry.info.id.as_str())),
|
||||
)
|
||||
.chain(
|
||||
data.recovery
|
||||
.iter()
|
||||
.map(|_| (TfaType::Recovery, 0, "recovery")),
|
||||
)
|
||||
}
|
||||
|
||||
/// API call implementation for `GET /access/tfa/{userid}`
|
||||
///
|
||||
/// Permissions for accessing `userid` must have been verified by the caller.
|
||||
pub fn list_user_tfa(config: &TfaConfig, userid: &str) -> Result<Vec<TypedTfaInfo>, Error> {
|
||||
Ok(match config.users.get(userid) {
|
||||
Some(data) => to_data(data),
|
||||
None => Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// API call implementation for `GET /access/tfa/{userid}/{ID}`.
|
||||
///
|
||||
/// Permissions for accessing `userid` must have been verified by the caller.
|
||||
///
|
||||
/// In case this returns `None` a `NOT_FOUND` http error should be returned.
|
||||
pub fn get_tfa_entry(
|
||||
config: &TfaConfig,
|
||||
userid: &str,
|
||||
id: &str,
|
||||
) -> Result<Option<TypedTfaInfo>, Error> {
|
||||
let user_data = match config.users.get(userid) {
|
||||
Some(u) => u,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
Ok(Some(
|
||||
match {
|
||||
// scope to prevent the temporary iter from borrowing across the whole match
|
||||
let entry = tfa_id_iter(&user_data).find(|(_ty, _index, entry_id)| id == *entry_id);
|
||||
entry.map(|(ty, index, _)| (ty, index))
|
||||
} {
|
||||
Some((TfaType::Recovery, _)) => match user_data.recovery() {
|
||||
Some(recovery) => TypedTfaInfo {
|
||||
ty: TfaType::Recovery,
|
||||
info: TfaInfo::recovery(recovery.created),
|
||||
},
|
||||
None => return Ok(None),
|
||||
},
|
||||
Some((TfaType::Totp, index)) => {
|
||||
TypedTfaInfo {
|
||||
ty: TfaType::Totp,
|
||||
// `into_iter().nth()` to *move* out of it
|
||||
info: user_data.totp.iter().nth(index).unwrap().info.clone(),
|
||||
}
|
||||
}
|
||||
Some((TfaType::Webauthn, index)) => TypedTfaInfo {
|
||||
ty: TfaType::Webauthn,
|
||||
info: user_data.webauthn.iter().nth(index).unwrap().info.clone(),
|
||||
},
|
||||
Some((TfaType::U2f, index)) => TypedTfaInfo {
|
||||
ty: TfaType::U2f,
|
||||
info: user_data.u2f.iter().nth(index).unwrap().info.clone(),
|
||||
},
|
||||
Some((TfaType::Yubico, index)) => TypedTfaInfo {
|
||||
ty: TfaType::Yubico,
|
||||
info: user_data.yubico.iter().nth(index).unwrap().info.clone(),
|
||||
},
|
||||
None => return Ok(None),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub struct EntryNotFound;
|
||||
|
||||
/// API call implementation for `DELETE /access/tfa/{userid}/{ID}`.
|
||||
///
|
||||
/// The caller must have already verified the user's password.
|
||||
///
|
||||
/// The TFA config must be WRITE locked.
|
||||
///
|
||||
/// The caller must *save* the config afterwards!
|
||||
///
|
||||
/// Errors only if the entry was not found.
|
||||
///
|
||||
/// Returns `true` if the user still has other TFA entries left, `false` if the user has *no* more
|
||||
/// tfa entries.
|
||||
pub fn delete_tfa(config: &mut TfaConfig, userid: &str, id: String) -> Result<bool, EntryNotFound> {
|
||||
let user_data = config.users.get_mut(userid).ok_or(EntryNotFound)?;
|
||||
|
||||
match {
|
||||
// scope to prevent the temporary iter from borrowing across the whole match
|
||||
let entry = tfa_id_iter(&user_data).find(|(_, _, entry_id)| id == *entry_id);
|
||||
entry.map(|(ty, index, _)| (ty, index))
|
||||
} {
|
||||
Some((TfaType::Recovery, _)) => user_data.recovery = None,
|
||||
Some((TfaType::Totp, index)) => drop(user_data.totp.remove(index)),
|
||||
Some((TfaType::Webauthn, index)) => drop(user_data.webauthn.remove(index)),
|
||||
Some((TfaType::U2f, index)) => drop(user_data.u2f.remove(index)),
|
||||
Some((TfaType::Yubico, index)) => drop(user_data.yubico.remove(index)),
|
||||
None => return Err(EntryNotFound),
|
||||
}
|
||||
|
||||
if user_data.is_empty() {
|
||||
config.users.remove(userid);
|
||||
Ok(false)
|
||||
} else {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "api-types", api(
|
||||
properties: {
|
||||
"userid": { type: Userid },
|
||||
"entries": {
|
||||
type: Array,
|
||||
items: { type: TypedTfaInfo },
|
||||
},
|
||||
},
|
||||
))]
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
/// Over the API we only provide the descriptions for TFA data.
|
||||
pub struct TfaUser {
|
||||
/// The user this entry belongs to.
|
||||
userid: String,
|
||||
|
||||
/// TFA entries.
|
||||
entries: Vec<TypedTfaInfo>,
|
||||
}
|
||||
|
||||
/// API call implementation for `GET /access/tfa`.
|
||||
///
|
||||
/// Caller needs to have performed the required privilege checks already.
|
||||
pub fn list_tfa(
|
||||
config: &TfaConfig,
|
||||
authid: &str,
|
||||
top_level_allowed: bool,
|
||||
) -> Result<Vec<TfaUser>, Error> {
|
||||
let tfa_data = &config.users;
|
||||
|
||||
let mut out = Vec::<TfaUser>::new();
|
||||
if top_level_allowed {
|
||||
for (user, data) in tfa_data {
|
||||
out.push(TfaUser {
|
||||
userid: user.clone(),
|
||||
entries: to_data(data),
|
||||
});
|
||||
}
|
||||
} else if let Some(data) = { tfa_data }.get(authid) {
|
||||
out.push(TfaUser {
|
||||
userid: authid.into(),
|
||||
entries: to_data(data),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "api-types", api(
|
||||
properties: {
|
||||
recovery: {
|
||||
description: "A list of recovery codes as integers.",
|
||||
type: Array,
|
||||
items: {
|
||||
type: Integer,
|
||||
description: "A one-time usable recovery code entry.",
|
||||
},
|
||||
},
|
||||
},
|
||||
))]
|
||||
/// The result returned when adding TFA entries to a user.
|
||||
#[derive(Default, Serialize)]
|
||||
pub struct TfaUpdateInfo {
|
||||
/// The id if a newly added TFA entry.
|
||||
id: Option<String>,
|
||||
|
||||
/// When adding u2f entries, this contains a challenge the user must respond to in order to
|
||||
/// finish the registration.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
challenge: Option<String>,
|
||||
|
||||
/// When adding recovery codes, this contains the list of codes to be displayed to the user
|
||||
/// this one time.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
recovery: Vec<String>,
|
||||
}
|
||||
|
||||
impl TfaUpdateInfo {
|
||||
fn id(id: String) -> Self {
|
||||
Self {
|
||||
id: Some(id),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn need_description(description: Option<String>) -> Result<String, Error> {
|
||||
description.ok_or_else(|| format_err!("'description' is required for new entries"))
|
||||
}
|
||||
|
||||
/// API call implementation for `POST /access/tfa/{userid}`.
|
||||
///
|
||||
/// Permissions for accessing `userid` must have been verified by the caller.
|
||||
///
|
||||
/// The caller must have already verified the user's password!
|
||||
pub fn add_tfa_entry<A: OpenUserChallengeData>(
|
||||
config: &mut TfaConfig,
|
||||
access: A,
|
||||
userid: &str,
|
||||
description: Option<String>,
|
||||
totp: Option<String>,
|
||||
value: Option<String>,
|
||||
challenge: Option<String>,
|
||||
r#type: TfaType,
|
||||
) -> Result<TfaUpdateInfo, Error> {
|
||||
match r#type {
|
||||
TfaType::Totp => {
|
||||
if challenge.is_some() {
|
||||
bail!("'challenge' parameter is invalid for 'totp' entries");
|
||||
}
|
||||
|
||||
add_totp(config, userid, need_description(description)?, totp, value)
|
||||
}
|
||||
TfaType::Webauthn => {
|
||||
if totp.is_some() {
|
||||
bail!("'totp' parameter is invalid for 'webauthn' entries");
|
||||
}
|
||||
|
||||
add_webauthn(config, access, userid, description, challenge, value)
|
||||
}
|
||||
TfaType::U2f => {
|
||||
if totp.is_some() {
|
||||
bail!("'totp' parameter is invalid for 'u2f' entries");
|
||||
}
|
||||
|
||||
add_u2f(config, access, userid, description, challenge, value)
|
||||
}
|
||||
TfaType::Recovery => {
|
||||
if totp.or(value).or(challenge).is_some() {
|
||||
bail!("generating recovery tokens does not allow additional parameters");
|
||||
}
|
||||
|
||||
let recovery = config.add_recovery(&userid)?;
|
||||
|
||||
Ok(TfaUpdateInfo {
|
||||
id: Some("recovery".to_string()),
|
||||
recovery,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
TfaType::Yubico => {
|
||||
if totp.or(challenge).is_some() {
|
||||
bail!("'totp' and 'challenge' parameters are invalid for 'yubico' entries");
|
||||
}
|
||||
|
||||
add_yubico(config, userid, need_description(description)?, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_totp(
|
||||
config: &mut TfaConfig,
|
||||
userid: &str,
|
||||
description: String,
|
||||
totp: Option<String>,
|
||||
value: Option<String>,
|
||||
) -> Result<TfaUpdateInfo, Error> {
|
||||
let (totp, value) = match (totp, value) {
|
||||
(Some(totp), Some(value)) => (totp, value),
|
||||
_ => bail!("'totp' type requires both 'totp' and 'value' parameters"),
|
||||
};
|
||||
|
||||
let totp: Totp = totp.parse()?;
|
||||
if totp
|
||||
.verify(&value, std::time::SystemTime::now(), -1..=1)?
|
||||
.is_none()
|
||||
{
|
||||
bail!("failed to verify TOTP challenge");
|
||||
}
|
||||
Ok(TfaUpdateInfo::id(config.add_totp(
|
||||
userid,
|
||||
description,
|
||||
totp,
|
||||
)))
|
||||
}
|
||||
|
||||
fn add_yubico(
|
||||
config: &mut TfaConfig,
|
||||
userid: &str,
|
||||
description: String,
|
||||
value: Option<String>,
|
||||
) -> Result<TfaUpdateInfo, Error> {
|
||||
let key = value.ok_or_else(|| format_err!("missing 'value' parameter for 'yubico' entry"))?;
|
||||
Ok(TfaUpdateInfo::id(config.add_yubico(
|
||||
userid,
|
||||
description,
|
||||
key,
|
||||
)))
|
||||
}
|
||||
|
||||
fn add_u2f<A: OpenUserChallengeData>(
|
||||
config: &mut TfaConfig,
|
||||
access: A,
|
||||
userid: &str,
|
||||
description: Option<String>,
|
||||
challenge: Option<String>,
|
||||
value: Option<String>,
|
||||
) -> Result<TfaUpdateInfo, Error> {
|
||||
match challenge {
|
||||
None => config
|
||||
.u2f_registration_challenge(access, userid, need_description(description)?)
|
||||
.map(|c| TfaUpdateInfo {
|
||||
challenge: Some(c),
|
||||
..Default::default()
|
||||
}),
|
||||
Some(challenge) => {
|
||||
let value = value.ok_or_else(|| {
|
||||
format_err!("missing 'value' parameter (u2f challenge response missing)")
|
||||
})?;
|
||||
config
|
||||
.u2f_registration_finish(access, userid, &challenge, &value)
|
||||
.map(TfaUpdateInfo::id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_webauthn<A: OpenUserChallengeData>(
|
||||
config: &mut TfaConfig,
|
||||
access: A,
|
||||
userid: &str,
|
||||
description: Option<String>,
|
||||
challenge: Option<String>,
|
||||
value: Option<String>,
|
||||
) -> Result<TfaUpdateInfo, Error> {
|
||||
match challenge {
|
||||
None => config
|
||||
.webauthn_registration_challenge(access, &userid, need_description(description)?)
|
||||
.map(|c| TfaUpdateInfo {
|
||||
challenge: Some(c),
|
||||
..Default::default()
|
||||
}),
|
||||
Some(challenge) => {
|
||||
let value = value.ok_or_else(|| {
|
||||
format_err!("missing 'value' parameter (webauthn challenge response missing)")
|
||||
})?;
|
||||
config
|
||||
.webauthn_registration_finish(access, &userid, &challenge, &value)
|
||||
.map(TfaUpdateInfo::id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// API call implementation for `PUT /access/tfa/{userid}/{id}`.
|
||||
///
|
||||
/// The caller must have already verified the user's password.
|
||||
///
|
||||
/// Errors only if the entry was not found.
|
||||
pub fn update_tfa_entry(
|
||||
config: &mut TfaConfig,
|
||||
userid: &str,
|
||||
id: &str,
|
||||
description: Option<String>,
|
||||
enable: Option<bool>,
|
||||
) -> Result<(), EntryNotFound> {
|
||||
let mut entry = config
|
||||
.users
|
||||
.get_mut(userid)
|
||||
.and_then(|user| user.find_entry_mut(id))
|
||||
.ok_or(EntryNotFound)?;
|
||||
|
||||
if let Some(description) = description {
|
||||
entry.description = description;
|
||||
}
|
||||
|
||||
if let Some(enable) = enable {
|
||||
entry.enable = enable;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
@ -1,991 +0,0 @@
|
||||
//! TFA configuration and user data.
|
||||
//!
|
||||
//! This is the same as used in PBS but without the `#[api]` type.
|
||||
//!
|
||||
//! We may want to move this into a shared crate making the `#[api]` macro feature-gated!
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{bail, format_err, Error};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use webauthn_rs::proto::Credential as WebauthnCredential;
|
||||
use webauthn_rs::{proto::UserVerificationPolicy, Webauthn};
|
||||
|
||||
use proxmox_tfa::totp::Totp;
|
||||
use proxmox_uuid::Uuid;
|
||||
|
||||
#[cfg(feature = "api-types")]
|
||||
use proxmox_schema::api;
|
||||
|
||||
mod serde_tools;
|
||||
|
||||
mod recovery;
|
||||
mod u2f;
|
||||
mod webauthn;
|
||||
|
||||
pub mod api;
|
||||
|
||||
pub use recovery::RecoveryState;
|
||||
pub use u2f::U2fConfig;
|
||||
pub use webauthn::WebauthnConfig;
|
||||
|
||||
use recovery::Recovery;
|
||||
use u2f::{U2fChallenge, U2fChallengeEntry, U2fRegistrationChallenge};
|
||||
use webauthn::{WebauthnAuthChallenge, WebauthnRegistrationChallenge};
|
||||
|
||||
trait IsExpired {
|
||||
fn is_expired(&self, at_epoch: i64) -> bool;
|
||||
}
|
||||
|
||||
pub trait OpenUserChallengeData: Clone {
|
||||
type Data: UserChallengeAccess;
|
||||
|
||||
fn open(&self, userid: &str) -> Result<Self::Data, Error>;
|
||||
fn open_no_create(&self, userid: &str) -> Result<Option<Self::Data>, Error>;
|
||||
}
|
||||
|
||||
pub trait UserChallengeAccess: Sized {
|
||||
//fn open(userid: &str) -> Result<Self, Error>;
|
||||
//fn open_no_create(userid: &str) -> Result<Option<Self>, Error>;
|
||||
fn get_mut(&mut self) -> &mut TfaUserChallenges;
|
||||
fn save(self) -> Result<(), Error>;
|
||||
}
|
||||
|
||||
const CHALLENGE_TIMEOUT_SECS: i64 = 2 * 60;
|
||||
|
||||
/// TFA Configuration for this instance.
|
||||
#[derive(Clone, Default, Deserialize, Serialize)]
|
||||
pub struct TfaConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub u2f: Option<U2fConfig>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub webauthn: Option<WebauthnConfig>,
|
||||
|
||||
#[serde(skip_serializing_if = "TfaUsers::is_empty", default)]
|
||||
pub users: TfaUsers,
|
||||
}
|
||||
|
||||
/// Helper to get a u2f instance from a u2f config, or `None` if there isn't one configured.
|
||||
fn get_u2f(u2f: &Option<U2fConfig>) -> Option<u2f::U2f> {
|
||||
u2f.as_ref()
|
||||
.map(|cfg| u2f::U2f::new(cfg.appid.clone(), cfg.appid.clone()))
|
||||
}
|
||||
|
||||
/// Helper to get a u2f instance from a u2f config.
|
||||
///
|
||||
/// This is outside of `TfaConfig` to not borrow its `&self`.
|
||||
fn check_u2f(u2f: &Option<U2fConfig>) -> Result<u2f::U2f, Error> {
|
||||
get_u2f(u2f).ok_or_else(|| format_err!("no u2f configuration available"))
|
||||
}
|
||||
|
||||
/// Helper to get a `Webauthn` instance from a `WebauthnConfig`, or `None` if there isn't one
|
||||
/// configured.
|
||||
fn get_webauthn(waconfig: &Option<WebauthnConfig>) -> Option<Webauthn<WebauthnConfig>> {
|
||||
waconfig.clone().map(Webauthn::new)
|
||||
}
|
||||
|
||||
/// Helper to get a u2f instance from a u2f config.
|
||||
///
|
||||
/// This is outside of `TfaConfig` to not borrow its `&self`.
|
||||
fn check_webauthn(waconfig: &Option<WebauthnConfig>) -> Result<Webauthn<WebauthnConfig>, Error> {
|
||||
get_webauthn(waconfig).ok_or_else(|| format_err!("no webauthn configuration available"))
|
||||
}
|
||||
|
||||
impl TfaConfig {
|
||||
// Get a u2f registration challenge.
|
||||
pub fn u2f_registration_challenge<A: OpenUserChallengeData>(
|
||||
&mut self,
|
||||
access: A,
|
||||
userid: &str,
|
||||
description: String,
|
||||
) -> Result<String, Error> {
|
||||
let u2f = check_u2f(&self.u2f)?;
|
||||
|
||||
self.users
|
||||
.entry(userid.to_owned())
|
||||
.or_default()
|
||||
.u2f_registration_challenge(access, userid, &u2f, description)
|
||||
}
|
||||
|
||||
/// Finish a u2f registration challenge.
|
||||
pub fn u2f_registration_finish<A: OpenUserChallengeData>(
|
||||
&mut self,
|
||||
access: A,
|
||||
userid: &str,
|
||||
challenge: &str,
|
||||
response: &str,
|
||||
) -> Result<String, Error> {
|
||||
let u2f = check_u2f(&self.u2f)?;
|
||||
|
||||
match self.users.get_mut(userid) {
|
||||
Some(user) => user.u2f_registration_finish(access, userid, &u2f, challenge, response),
|
||||
None => bail!("no such challenge"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a webauthn registration challenge.
|
||||
fn webauthn_registration_challenge<A: OpenUserChallengeData>(
|
||||
&mut self,
|
||||
access: A,
|
||||
user: &str,
|
||||
description: String,
|
||||
) -> Result<String, Error> {
|
||||
let webauthn = check_webauthn(&self.webauthn)?;
|
||||
|
||||
self.users
|
||||
.entry(user.to_owned())
|
||||
.or_default()
|
||||
.webauthn_registration_challenge(access, webauthn, user, description)
|
||||
}
|
||||
|
||||
/// Finish a webauthn registration challenge.
|
||||
fn webauthn_registration_finish<A: OpenUserChallengeData>(
|
||||
&mut self,
|
||||
access: A,
|
||||
userid: &str,
|
||||
challenge: &str,
|
||||
response: &str,
|
||||
) -> Result<String, Error> {
|
||||
let webauthn = check_webauthn(&self.webauthn)?;
|
||||
|
||||
let response: webauthn_rs::proto::RegisterPublicKeyCredential =
|
||||
serde_json::from_str(response)
|
||||
.map_err(|err| format_err!("error parsing challenge response: {}", err))?;
|
||||
|
||||
match self.users.get_mut(userid) {
|
||||
Some(user) => {
|
||||
user.webauthn_registration_finish(access, webauthn, userid, challenge, response)
|
||||
}
|
||||
None => bail!("no such challenge"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a TOTP entry for a user.
|
||||
///
|
||||
/// Unlike U2F/WA, this does not require a challenge/response. The user can choose their secret
|
||||
/// themselves.
|
||||
pub fn add_totp(&mut self, userid: &str, description: String, value: Totp) -> String {
|
||||
self.users
|
||||
.entry(userid.to_owned())
|
||||
.or_default()
|
||||
.add_totp(description, value)
|
||||
}
|
||||
|
||||
/// Add a Yubico key to a user.
|
||||
///
|
||||
/// Unlike U2F/WA, this does not require a challenge/response. The user can choose their secret
|
||||
/// themselves.
|
||||
pub fn add_yubico(&mut self, userid: &str, description: String, key: String) -> String {
|
||||
self.users
|
||||
.entry(userid.to_owned())
|
||||
.or_default()
|
||||
.add_yubico(description, key)
|
||||
}
|
||||
|
||||
/// Add a new set of recovery keys. There can only be 1 set of keys at a time.
|
||||
fn add_recovery(&mut self, userid: &str) -> Result<Vec<String>, Error> {
|
||||
self.users
|
||||
.entry(userid.to_owned())
|
||||
.or_default()
|
||||
.add_recovery()
|
||||
}
|
||||
|
||||
/// Get a two factor authentication challenge for a user, if the user has TFA set up.
|
||||
pub fn authentication_challenge<A: OpenUserChallengeData>(
|
||||
&mut self,
|
||||
access: A,
|
||||
userid: &str,
|
||||
) -> Result<Option<TfaChallenge>, Error> {
|
||||
match self.users.get_mut(userid) {
|
||||
Some(udata) => udata.challenge(
|
||||
access,
|
||||
userid,
|
||||
get_webauthn(&self.webauthn),
|
||||
get_u2f(&self.u2f).as_ref(),
|
||||
),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a TFA challenge.
|
||||
pub fn verify<A: OpenUserChallengeData>(
|
||||
&mut self,
|
||||
access: A,
|
||||
userid: &str,
|
||||
challenge: &TfaChallenge,
|
||||
response: TfaResponse,
|
||||
) -> Result<NeedsSaving, Error> {
|
||||
match self.users.get_mut(userid) {
|
||||
Some(user) => match response {
|
||||
TfaResponse::Totp(value) => user.verify_totp(&value),
|
||||
TfaResponse::U2f(value) => match &challenge.u2f {
|
||||
Some(challenge) => {
|
||||
let u2f = check_u2f(&self.u2f)?;
|
||||
user.verify_u2f(access.clone(), userid, u2f, &challenge.challenge, value)
|
||||
}
|
||||
None => bail!("no u2f factor available for user '{}'", userid),
|
||||
},
|
||||
TfaResponse::Webauthn(value) => {
|
||||
let webauthn = check_webauthn(&self.webauthn)?;
|
||||
user.verify_webauthn(access.clone(), userid, webauthn, value)
|
||||
}
|
||||
TfaResponse::Recovery(value) => {
|
||||
user.verify_recovery(&value)?;
|
||||
return Ok(NeedsSaving::Yes);
|
||||
}
|
||||
},
|
||||
None => bail!("no 2nd factor available for user '{}'", userid),
|
||||
}?;
|
||||
|
||||
Ok(NeedsSaving::No)
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use = "must save the config in order to ensure one-time use of recovery keys"]
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum NeedsSaving {
|
||||
No,
|
||||
Yes,
|
||||
}
|
||||
|
||||
impl NeedsSaving {
|
||||
/// Convenience method so we don't need to import the type name.
|
||||
pub fn needs_saving(self) -> bool {
|
||||
matches!(self, NeedsSaving::Yes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mapping of userid to TFA entry.
|
||||
pub type TfaUsers = HashMap<String, TfaUserData>;
|
||||
|
||||
/// TFA data for a user.
|
||||
#[derive(Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
#[serde(bound(deserialize = "", serialize = ""))]
|
||||
pub struct TfaUserData {
|
||||
/// Totp keys for a user.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub totp: Vec<TfaEntry<Totp>>,
|
||||
|
||||
/// Registered u2f tokens for a user.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub u2f: Vec<TfaEntry<u2f::Registration>>,
|
||||
|
||||
/// Registered webauthn tokens for a user.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub webauthn: Vec<TfaEntry<WebauthnCredential>>,
|
||||
|
||||
/// Recovery keys. (Unordered OTP values).
|
||||
#[serde(skip_serializing_if = "Recovery::option_is_empty", default)]
|
||||
pub recovery: Option<Recovery>,
|
||||
|
||||
/// Yubico keys for a user. NOTE: This is not directly supported currently, we just need this
|
||||
/// available for PVE, where the yubico API server configuration is part if the realm.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub yubico: Vec<TfaEntry<String>>,
|
||||
}
|
||||
|
||||
impl TfaUserData {
|
||||
/// Shortcut to get the recovery entry only if it is not empty!
|
||||
pub fn recovery(&self) -> Option<&Recovery> {
|
||||
if Recovery::option_is_empty(&self.recovery) {
|
||||
None
|
||||
} else {
|
||||
self.recovery.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` if no second factors exist
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.totp.is_empty()
|
||||
&& self.u2f.is_empty()
|
||||
&& self.webauthn.is_empty()
|
||||
&& self.yubico.is_empty()
|
||||
&& self.recovery().is_none()
|
||||
}
|
||||
|
||||
/// Find an entry by id, except for the "recovery" entry which we're currently treating
|
||||
/// specially.
|
||||
pub fn find_entry_mut<'a>(&'a mut self, id: &str) -> Option<&'a mut TfaInfo> {
|
||||
for entry in &mut self.totp {
|
||||
if entry.info.id == id {
|
||||
return Some(&mut entry.info);
|
||||
}
|
||||
}
|
||||
|
||||
for entry in &mut self.webauthn {
|
||||
if entry.info.id == id {
|
||||
return Some(&mut entry.info);
|
||||
}
|
||||
}
|
||||
|
||||
for entry in &mut self.u2f {
|
||||
if entry.info.id == id {
|
||||
return Some(&mut entry.info);
|
||||
}
|
||||
}
|
||||
|
||||
for entry in &mut self.yubico {
|
||||
if entry.info.id == id {
|
||||
return Some(&mut entry.info);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Create a u2f registration challenge.
|
||||
///
|
||||
/// The description is required at this point already mostly to better be able to identify such
|
||||
/// challenges in the tfa config file if necessary. The user otherwise has no access to this
|
||||
/// information at this point, as the challenge is identified by its actual challenge data
|
||||
/// instead.
|
||||
fn u2f_registration_challenge<A: OpenUserChallengeData>(
|
||||
&mut self,
|
||||
access: A,
|
||||
userid: &str,
|
||||
u2f: &u2f::U2f,
|
||||
description: String,
|
||||
) -> Result<String, Error> {
|
||||
let challenge = serde_json::to_string(&u2f.registration_challenge()?)?;
|
||||
|
||||
let mut data = access.open(userid)?;
|
||||
data.get_mut()
|
||||
.u2f_registrations
|
||||
.push(U2fRegistrationChallenge::new(
|
||||
challenge.clone(),
|
||||
description,
|
||||
));
|
||||
data.save()?;
|
||||
|
||||
Ok(challenge)
|
||||
}
|
||||
|
||||
fn u2f_registration_finish<A: OpenUserChallengeData>(
|
||||
&mut self,
|
||||
access: A,
|
||||
userid: &str,
|
||||
u2f: &u2f::U2f,
|
||||
challenge: &str,
|
||||
response: &str,
|
||||
) -> Result<String, Error> {
|
||||
let mut data = access.open(userid)?;
|
||||
let entry = data
|
||||
.get_mut()
|
||||
.u2f_registration_finish(u2f, challenge, response)?;
|
||||
data.save()?;
|
||||
|
||||
let id = entry.info.id.clone();
|
||||
self.u2f.push(entry);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Create a webauthn registration challenge.
|
||||
///
|
||||
/// The description is required at this point already mostly to better be able to identify such
|
||||
/// challenges in the tfa config file if necessary. The user otherwise has no access to this
|
||||
/// information at this point, as the challenge is identified by its actual challenge data
|
||||
/// instead.
|
||||
fn webauthn_registration_challenge<A: OpenUserChallengeData>(
|
||||
&mut self,
|
||||
access: A,
|
||||
mut webauthn: Webauthn<WebauthnConfig>,
|
||||
userid: &str,
|
||||
description: String,
|
||||
) -> Result<String, Error> {
|
||||
let cred_ids: Vec<_> = self
|
||||
.enabled_webauthn_entries()
|
||||
.map(|cred| cred.cred_id.clone())
|
||||
.collect();
|
||||
|
||||
let (challenge, state) = webauthn.generate_challenge_register_options(
|
||||
userid.as_bytes().to_vec(),
|
||||
userid.to_owned(),
|
||||
userid.to_owned(),
|
||||
Some(cred_ids),
|
||||
Some(UserVerificationPolicy::Discouraged),
|
||||
)?;
|
||||
|
||||
let challenge_string = challenge.public_key.challenge.to_string();
|
||||
let challenge = serde_json::to_string(&challenge)?;
|
||||
|
||||
let mut data = access.open(userid)?;
|
||||
data.get_mut()
|
||||
.webauthn_registrations
|
||||
.push(WebauthnRegistrationChallenge::new(
|
||||
state,
|
||||
challenge_string,
|
||||
description,
|
||||
));
|
||||
data.save()?;
|
||||
|
||||
Ok(challenge)
|
||||
}
|
||||
|
||||
/// Finish a webauthn registration. The challenge should correspond to an output of
|
||||
/// `webauthn_registration_challenge`. The response should come directly from the client.
|
||||
fn webauthn_registration_finish<A: OpenUserChallengeData>(
|
||||
&mut self,
|
||||
access: A,
|
||||
webauthn: Webauthn<WebauthnConfig>,
|
||||
userid: &str,
|
||||
challenge: &str,
|
||||
response: webauthn_rs::proto::RegisterPublicKeyCredential,
|
||||
) -> Result<String, Error> {
|
||||
let mut data = access.open(userid)?;
|
||||
let entry = data.get_mut().webauthn_registration_finish(
|
||||
webauthn,
|
||||
challenge,
|
||||
response,
|
||||
&self.webauthn,
|
||||
)?;
|
||||
data.save()?;
|
||||
|
||||
let id = entry.info.id.clone();
|
||||
self.webauthn.push(entry);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn add_totp(&mut self, description: String, totp: Totp) -> String {
|
||||
let entry = TfaEntry::new(description, totp);
|
||||
let id = entry.info.id.clone();
|
||||
self.totp.push(entry);
|
||||
id
|
||||
}
|
||||
|
||||
fn add_yubico(&mut self, description: String, key: String) -> String {
|
||||
let entry = TfaEntry::new(description, key);
|
||||
let id = entry.info.id.clone();
|
||||
self.yubico.push(entry);
|
||||
id
|
||||
}
|
||||
|
||||
/// Add a new set of recovery keys. There can only be 1 set of keys at a time.
|
||||
fn add_recovery(&mut self) -> Result<Vec<String>, Error> {
|
||||
if self.recovery.is_some() {
|
||||
bail!("user already has recovery keys");
|
||||
}
|
||||
|
||||
let (recovery, original) = Recovery::generate()?;
|
||||
|
||||
self.recovery = Some(recovery);
|
||||
|
||||
Ok(original)
|
||||
}
|
||||
|
||||
/// Helper to iterate over enabled totp entries.
|
||||
fn enabled_totp_entries(&self) -> impl Iterator<Item = &Totp> {
|
||||
self.totp
|
||||
.iter()
|
||||
.filter_map(|e| if e.info.enable { Some(&e.entry) } else { None })
|
||||
}
|
||||
|
||||
/// Helper to iterate over enabled u2f entries.
|
||||
fn enabled_u2f_entries(&self) -> impl Iterator<Item = &u2f::Registration> {
|
||||
self.u2f
|
||||
.iter()
|
||||
.filter_map(|e| if e.info.enable { Some(&e.entry) } else { None })
|
||||
}
|
||||
|
||||
/// Helper to iterate over enabled u2f entries.
|
||||
fn enabled_webauthn_entries(&self) -> impl Iterator<Item = &WebauthnCredential> {
|
||||
self.webauthn
|
||||
.iter()
|
||||
.filter_map(|e| if e.info.enable { Some(&e.entry) } else { None })
|
||||
}
|
||||
|
||||
/// Helper to iterate over enabled yubico entries.
|
||||
pub fn enabled_yubico_entries(&self) -> impl Iterator<Item = &str> {
|
||||
self.yubico.iter().filter_map(|e| {
|
||||
if e.info.enable {
|
||||
Some(e.entry.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify a totp challenge. The `value` should be the totp digits as plain text.
|
||||
fn verify_totp(&self, value: &str) -> Result<(), Error> {
|
||||
let now = std::time::SystemTime::now();
|
||||
|
||||
for entry in self.enabled_totp_entries() {
|
||||
if entry.verify(value, now, -1..=1)?.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
bail!("totp verification failed");
|
||||
}
|
||||
|
||||
/// Generate a generic TFA challenge. See the [`TfaChallenge`] description for details.
|
||||
pub fn challenge<A: OpenUserChallengeData>(
|
||||
&mut self,
|
||||
access: A,
|
||||
userid: &str,
|
||||
webauthn: Option<Webauthn<WebauthnConfig>>,
|
||||
u2f: Option<&u2f::U2f>,
|
||||
) -> Result<Option<TfaChallenge>, Error> {
|
||||
if self.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(TfaChallenge {
|
||||
totp: self.totp.iter().any(|e| e.info.enable),
|
||||
recovery: RecoveryState::from(&self.recovery),
|
||||
webauthn: match webauthn {
|
||||
Some(webauthn) => self.webauthn_challenge(access.clone(), userid, webauthn)?,
|
||||
None => None,
|
||||
},
|
||||
u2f: match u2f {
|
||||
Some(u2f) => self.u2f_challenge(access.clone(), userid, u2f)?,
|
||||
None => None,
|
||||
},
|
||||
yubico: self.yubico.iter().any(|e| e.info.enable),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get the recovery state.
|
||||
pub fn recovery_state(&self) -> RecoveryState {
|
||||
RecoveryState::from(&self.recovery)
|
||||
}
|
||||
|
||||
/// Generate an optional webauthn challenge.
|
||||
fn webauthn_challenge<A: OpenUserChallengeData>(
|
||||
&mut self,
|
||||
access: A,
|
||||
userid: &str,
|
||||
mut webauthn: Webauthn<WebauthnConfig>,
|
||||
) -> Result<Option<webauthn_rs::proto::RequestChallengeResponse>, Error> {
|
||||
if self.webauthn.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let creds: Vec<_> = self.enabled_webauthn_entries().map(Clone::clone).collect();
|
||||
|
||||
if creds.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let (challenge, state) = webauthn
|
||||
.generate_challenge_authenticate(creds, Some(UserVerificationPolicy::Discouraged))?;
|
||||
let challenge_string = challenge.public_key.challenge.to_string();
|
||||
let mut data = access.open(userid)?;
|
||||
data.get_mut()
|
||||
.webauthn_auths
|
||||
.push(WebauthnAuthChallenge::new(state, challenge_string));
|
||||
data.save()?;
|
||||
|
||||
Ok(Some(challenge))
|
||||
}
|
||||
|
||||
/// Generate an optional u2f challenge.
|
||||
fn u2f_challenge<A: OpenUserChallengeData>(
|
||||
&self,
|
||||
access: A,
|
||||
userid: &str,
|
||||
u2f: &u2f::U2f,
|
||||
) -> Result<Option<U2fChallenge>, Error> {
|
||||
if self.u2f.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let keys: Vec<proxmox_tfa::u2f::RegisteredKey> = self
|
||||
.enabled_u2f_entries()
|
||||
.map(|registration| registration.key.clone())
|
||||
.collect();
|
||||
|
||||
if keys.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let challenge = U2fChallenge {
|
||||
challenge: u2f.auth_challenge()?,
|
||||
keys,
|
||||
};
|
||||
|
||||
let mut data = access.open(userid)?;
|
||||
data.get_mut()
|
||||
.u2f_auths
|
||||
.push(U2fChallengeEntry::new(&challenge));
|
||||
data.save()?;
|
||||
|
||||
Ok(Some(challenge))
|
||||
}
|
||||
|
||||
/// Verify a u2f response.
|
||||
fn verify_u2f<A: OpenUserChallengeData>(
|
||||
&self,
|
||||
access: A,
|
||||
userid: &str,
|
||||
u2f: u2f::U2f,
|
||||
challenge: &proxmox_tfa::u2f::AuthChallenge,
|
||||
response: Value,
|
||||
) -> Result<(), Error> {
|
||||
let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
|
||||
|
||||
let response: proxmox_tfa::u2f::AuthResponse = serde_json::from_value(response)
|
||||
.map_err(|err| format_err!("invalid u2f response: {}", err))?;
|
||||
|
||||
if let Some(entry) = self
|
||||
.enabled_u2f_entries()
|
||||
.find(|e| e.key.key_handle == response.key_handle())
|
||||
{
|
||||
if u2f
|
||||
.auth_verify_obj(&entry.public_key, &challenge.challenge, response)?
|
||||
.is_some()
|
||||
{
|
||||
let mut data = match access.open_no_create(userid)? {
|
||||
Some(data) => data,
|
||||
None => bail!("no such challenge"),
|
||||
};
|
||||
let index = data
|
||||
.get_mut()
|
||||
.u2f_auths
|
||||
.iter()
|
||||
.position(|r| r == challenge)
|
||||
.ok_or_else(|| format_err!("no such challenge"))?;
|
||||
let entry = data.get_mut().u2f_auths.remove(index);
|
||||
if entry.is_expired(expire_before) {
|
||||
bail!("no such challenge");
|
||||
}
|
||||
data.save()
|
||||
.map_err(|err| format_err!("failed to save challenge file: {}", err))?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
bail!("u2f verification failed");
|
||||
}
|
||||
|
||||
/// Verify a webauthn response.
|
||||
fn verify_webauthn<A: OpenUserChallengeData>(
|
||||
&mut self,
|
||||
access: A,
|
||||
userid: &str,
|
||||
mut webauthn: Webauthn<WebauthnConfig>,
|
||||
mut response: Value,
|
||||
) -> Result<(), Error> {
|
||||
let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
|
||||
|
||||
let challenge = match response
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| format_err!("invalid response, must be a json object"))?
|
||||
.remove("challenge")
|
||||
.ok_or_else(|| format_err!("missing challenge data in response"))?
|
||||
{
|
||||
Value::String(s) => s,
|
||||
_ => bail!("invalid challenge data in response"),
|
||||
};
|
||||
|
||||
let response: webauthn_rs::proto::PublicKeyCredential = serde_json::from_value(response)
|
||||
.map_err(|err| format_err!("invalid webauthn response: {}", err))?;
|
||||
|
||||
let mut data = match access.open_no_create(userid)? {
|
||||
Some(data) => data,
|
||||
None => bail!("no such challenge"),
|
||||
};
|
||||
|
||||
let index = data
|
||||
.get_mut()
|
||||
.webauthn_auths
|
||||
.iter()
|
||||
.position(|r| r.challenge == challenge)
|
||||
.ok_or_else(|| format_err!("no such challenge"))?;
|
||||
|
||||
let challenge = data.get_mut().webauthn_auths.remove(index);
|
||||
if challenge.is_expired(expire_before) {
|
||||
bail!("no such challenge");
|
||||
}
|
||||
|
||||
// we don't allow re-trying the challenge, so make the removal persistent now:
|
||||
data.save()
|
||||
.map_err(|err| format_err!("failed to save challenge file: {}", err))?;
|
||||
|
||||
match webauthn.authenticate_credential(response, challenge.state)? {
|
||||
Some((_cred, _counter)) => Ok(()),
|
||||
None => bail!("webauthn authentication failed"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a recovery key.
|
||||
///
|
||||
/// NOTE: If successful, the key will automatically be removed from the list of available
|
||||
/// recovery keys, so the configuration needs to be saved afterwards!
|
||||
fn verify_recovery(&mut self, value: &str) -> Result<(), Error> {
|
||||
if let Some(r) = &mut self.recovery {
|
||||
if r.verify(value)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
bail!("recovery verification failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// A TFA entry for a user.
|
||||
///
|
||||
/// This simply connects a raw registration to a non optional descriptive text chosen by the user.
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TfaEntry<T> {
|
||||
#[serde(flatten)]
|
||||
pub info: TfaInfo,
|
||||
|
||||
/// The actual entry.
|
||||
pub entry: T,
|
||||
}
|
||||
|
||||
impl<T> TfaEntry<T> {
|
||||
/// Create an entry with a description. The id will be autogenerated.
|
||||
fn new(description: String, entry: T) -> Self {
|
||||
Self {
|
||||
info: TfaInfo {
|
||||
id: Uuid::generate().to_string(),
|
||||
enable: true,
|
||||
description,
|
||||
created: proxmox_time::epoch_i64(),
|
||||
},
|
||||
entry,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a raw entry from a `TfaInfo` and the corresponding entry data.
|
||||
pub fn from_parts(info: TfaInfo, entry: T) -> Self {
|
||||
Self { info, entry }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "api-types", api)]
|
||||
/// Over the API we only provide this part when querying a user's second factor list.
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TfaInfo {
|
||||
/// The id used to reference this entry.
|
||||
pub id: String,
|
||||
|
||||
/// User chosen description for this entry.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub description: String,
|
||||
|
||||
/// Creation time of this entry as unix epoch.
|
||||
pub created: i64,
|
||||
|
||||
/// Whether this TFA entry is currently enabled.
|
||||
#[serde(skip_serializing_if = "is_default_tfa_enable")]
|
||||
#[serde(default = "default_tfa_enable")]
|
||||
pub enable: bool,
|
||||
}
|
||||
|
||||
impl TfaInfo {
|
||||
/// For recovery keys we have a fixed entry.
|
||||
pub fn recovery(created: i64) -> Self {
|
||||
Self {
|
||||
id: "recovery".to_string(),
|
||||
description: String::new(),
|
||||
enable: true,
|
||||
created,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fn default_tfa_enable() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
const fn is_default_tfa_enable(v: &bool) -> bool {
|
||||
*v
|
||||
}
|
||||
|
||||
/// When sending a TFA challenge to the user, we include information about what kind of challenge
|
||||
/// the user may perform. If webauthn credentials are available, a webauthn challenge will be
|
||||
/// included.
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct TfaChallenge {
|
||||
/// True if the user has TOTP devices.
|
||||
#[serde(skip_serializing_if = "bool_is_false", default)]
|
||||
totp: bool,
|
||||
|
||||
/// Whether there are recovery keys available.
|
||||
#[serde(skip_serializing_if = "RecoveryState::is_unavailable", default)]
|
||||
recovery: RecoveryState,
|
||||
|
||||
/// If the user has any u2f tokens registered, this will contain the U2F challenge data.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
u2f: Option<U2fChallenge>,
|
||||
|
||||
/// If the user has any webauthn credentials registered, this will contain the corresponding
|
||||
/// challenge data.
|
||||
#[serde(skip_serializing_if = "Option::is_none", skip_deserializing)]
|
||||
webauthn: Option<webauthn_rs::proto::RequestChallengeResponse>,
|
||||
|
||||
/// True if the user has yubico keys configured.
|
||||
#[serde(skip_serializing_if = "bool_is_false", default)]
|
||||
yubico: bool,
|
||||
}
|
||||
|
||||
fn bool_is_false(v: &bool) -> bool {
|
||||
!v
|
||||
}
|
||||
|
||||
/// A user's response to a TFA challenge.
|
||||
pub enum TfaResponse {
|
||||
Totp(String),
|
||||
U2f(Value),
|
||||
Webauthn(Value),
|
||||
Recovery(String),
|
||||
}
|
||||
|
||||
/// This is part of the REST API:
|
||||
impl std::str::FromStr for TfaResponse {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Error> {
|
||||
Ok(if let Some(totp) = s.strip_prefix("totp:") {
|
||||
TfaResponse::Totp(totp.to_string())
|
||||
} else if let Some(u2f) = s.strip_prefix("u2f:") {
|
||||
TfaResponse::U2f(serde_json::from_str(u2f)?)
|
||||
} else if let Some(webauthn) = s.strip_prefix("webauthn:") {
|
||||
TfaResponse::Webauthn(serde_json::from_str(webauthn)?)
|
||||
} else if let Some(recovery) = s.strip_prefix("recovery:") {
|
||||
TfaResponse::Recovery(recovery.to_string())
|
||||
} else {
|
||||
bail!("invalid tfa response");
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Active TFA challenges per user, stored in a restricted temporary file on the machine handling
|
||||
/// the current user's authentication.
|
||||
#[derive(Default, Deserialize, Serialize)]
|
||||
pub struct TfaUserChallenges {
|
||||
/// Active u2f registration challenges for a user.
|
||||
///
|
||||
/// Expired values are automatically filtered out while parsing the tfa configuration file.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
#[serde(deserialize_with = "filter_expired_challenge")]
|
||||
u2f_registrations: Vec<U2fRegistrationChallenge>,
|
||||
|
||||
/// Active u2f authentication challenges for a user.
|
||||
///
|
||||
/// Expired values are automatically filtered out while parsing the tfa configuration file.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
#[serde(deserialize_with = "filter_expired_challenge")]
|
||||
u2f_auths: Vec<U2fChallengeEntry>,
|
||||
|
||||
/// Active webauthn registration challenges for a user.
|
||||
///
|
||||
/// Expired values are automatically filtered out while parsing the tfa configuration file.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
#[serde(deserialize_with = "filter_expired_challenge")]
|
||||
webauthn_registrations: Vec<WebauthnRegistrationChallenge>,
|
||||
|
||||
/// Active webauthn authentication challenges for a user.
|
||||
///
|
||||
/// Expired values are automatically filtered out while parsing the tfa configuration file.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
#[serde(deserialize_with = "filter_expired_challenge")]
|
||||
webauthn_auths: Vec<WebauthnAuthChallenge>,
|
||||
}
|
||||
|
||||
/// Serde helper using our `FilteredVecVisitor` to filter out expired entries directly at load
|
||||
/// time.
|
||||
fn filter_expired_challenge<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
T: Deserialize<'de> + IsExpired,
|
||||
{
|
||||
let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
|
||||
deserializer.deserialize_seq(serde_tools::fold(
|
||||
"a challenge entry",
|
||||
|cap| cap.map(Vec::with_capacity).unwrap_or_else(Vec::new),
|
||||
move |out, reg: T| {
|
||||
if !reg.is_expired(expire_before) {
|
||||
out.push(reg);
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
impl TfaUserChallenges {
|
||||
/// Finish a u2f registration. The challenge should correspond to an output of
|
||||
/// `u2f_registration_challenge` (which is a stringified `RegistrationChallenge`). The response
|
||||
/// should come directly from the client.
|
||||
fn u2f_registration_finish(
|
||||
&mut self,
|
||||
u2f: &u2f::U2f,
|
||||
challenge: &str,
|
||||
response: &str,
|
||||
) -> Result<TfaEntry<u2f::Registration>, Error> {
|
||||
let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
|
||||
|
||||
let index = self
|
||||
.u2f_registrations
|
||||
.iter()
|
||||
.position(|r| r.challenge == challenge)
|
||||
.ok_or_else(|| format_err!("no such challenge"))?;
|
||||
|
||||
let reg = &self.u2f_registrations[index];
|
||||
if reg.is_expired(expire_before) {
|
||||
bail!("no such challenge");
|
||||
}
|
||||
|
||||
// the verify call only takes the actual challenge string, so we have to extract it
|
||||
// (u2f::RegistrationChallenge did not always implement Deserialize...)
|
||||
let chobj: Value = serde_json::from_str(challenge)
|
||||
.map_err(|err| format_err!("error parsing original registration challenge: {}", err))?;
|
||||
let challenge = chobj["challenge"]
|
||||
.as_str()
|
||||
.ok_or_else(|| format_err!("invalid registration challenge"))?;
|
||||
|
||||
let (mut reg, description) = match u2f.registration_verify(challenge, response)? {
|
||||
None => bail!("verification failed"),
|
||||
Some(reg) => {
|
||||
let entry = self.u2f_registrations.remove(index);
|
||||
(reg, entry.description)
|
||||
}
|
||||
};
|
||||
|
||||
// we do not care about the attestation certificates, so don't store them
|
||||
reg.certificate.clear();
|
||||
|
||||
Ok(TfaEntry::new(description, reg))
|
||||
}
|
||||
|
||||
/// Finish a webauthn registration. The challenge should correspond to an output of
|
||||
/// `webauthn_registration_challenge`. The response should come directly from the client.
|
||||
fn webauthn_registration_finish(
|
||||
&mut self,
|
||||
webauthn: Webauthn<WebauthnConfig>,
|
||||
challenge: &str,
|
||||
response: webauthn_rs::proto::RegisterPublicKeyCredential,
|
||||
existing_registrations: &[TfaEntry<WebauthnCredential>],
|
||||
) -> Result<TfaEntry<WebauthnCredential>, Error> {
|
||||
let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
|
||||
|
||||
let index = self
|
||||
.webauthn_registrations
|
||||
.iter()
|
||||
.position(|r| r.challenge == challenge)
|
||||
.ok_or_else(|| format_err!("no such challenge"))?;
|
||||
|
||||
let reg = self.webauthn_registrations.remove(index);
|
||||
if reg.is_expired(expire_before) {
|
||||
bail!("no such challenge");
|
||||
}
|
||||
|
||||
let credential =
|
||||
webauthn.register_credential(response, reg.state, |id| -> Result<bool, ()> {
|
||||
Ok(existing_registrations
|
||||
.iter()
|
||||
.any(|cred| cred.entry.cred_id == *id))
|
||||
})?;
|
||||
|
||||
Ok(TfaEntry::new(reg.description, credential))
|
||||
}
|
||||
}
|
@ -1,153 +0,0 @@
|
||||
use std::io;
|
||||
|
||||
use anyhow::{format_err, Error};
|
||||
use openssl::hash::MessageDigest;
|
||||
use openssl::pkey::PKey;
|
||||
use openssl::sign::Signer;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn getrandom(mut buffer: &mut [u8]) -> Result<(), io::Error> {
|
||||
while !buffer.is_empty() {
|
||||
let res = unsafe {
|
||||
libc::getrandom(
|
||||
buffer.as_mut_ptr() as *mut libc::c_void,
|
||||
buffer.len() as libc::size_t,
|
||||
0 as libc::c_uint,
|
||||
)
|
||||
};
|
||||
|
||||
if res < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
buffer = &mut buffer[(res as usize)..];
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recovery entries. We use HMAC-SHA256 with a random secret as a salted hash replacement.
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct Recovery {
|
||||
/// "Salt" used for the key HMAC.
|
||||
secret: String,
|
||||
|
||||
/// Recovery key entries are HMACs of the original data. When used up they will become `None`
|
||||
/// since the user is presented an enumerated list of codes, so we know the indices of used and
|
||||
/// unused codes.
|
||||
entries: Vec<Option<String>>,
|
||||
|
||||
/// Creation timestamp as a unix epoch.
|
||||
pub created: i64,
|
||||
}
|
||||
|
||||
impl Recovery {
|
||||
/// Generate recovery keys and return the recovery entry along with the original string
|
||||
/// entries.
|
||||
pub(super) fn generate() -> Result<(Self, Vec<String>), Error> {
|
||||
let mut secret = [0u8; 8];
|
||||
getrandom(&mut secret)?;
|
||||
|
||||
let mut this = Self {
|
||||
secret: hex::encode(&secret).to_string(),
|
||||
entries: Vec::with_capacity(10),
|
||||
created: proxmox_time::epoch_i64(),
|
||||
};
|
||||
|
||||
let mut original = Vec::new();
|
||||
|
||||
let mut key_data = [0u8; 80]; // 10 keys of 12 bytes
|
||||
getrandom(&mut key_data)?;
|
||||
for b in key_data.chunks(8) {
|
||||
// unwrap: encoding hex bytes to fixed sized arrays
|
||||
let entry = format!(
|
||||
"{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}",
|
||||
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
|
||||
);
|
||||
this.entries.push(Some(this.hash(entry.as_bytes())?));
|
||||
original.push(entry);
|
||||
}
|
||||
|
||||
Ok((this, original))
|
||||
}
|
||||
|
||||
/// Perform HMAC-SHA256 on the data and return the result as a hex string.
|
||||
fn hash(&self, data: &[u8]) -> Result<String, Error> {
|
||||
let secret = PKey::hmac(self.secret.as_bytes())
|
||||
.map_err(|err| format_err!("error instantiating hmac key: {}", err))?;
|
||||
|
||||
let mut signer = Signer::new(MessageDigest::sha256(), &secret)
|
||||
.map_err(|err| format_err!("error instantiating hmac signer: {}", err))?;
|
||||
|
||||
let hmac = signer
|
||||
.sign_oneshot_to_vec(data)
|
||||
.map_err(|err| format_err!("error calculating hmac: {}", err))?;
|
||||
|
||||
Ok(hex::encode(&hmac))
|
||||
}
|
||||
|
||||
/// Iterator over available keys.
|
||||
fn available(&self) -> impl Iterator<Item = &str> {
|
||||
self.entries.iter().filter_map(Option::as_deref)
|
||||
}
|
||||
|
||||
/// Count the available keys.
|
||||
pub fn count_available(&self) -> usize {
|
||||
self.available().count()
|
||||
}
|
||||
|
||||
/// Convenience serde method to check if either the option is `None` or the content `is_empty`.
|
||||
pub(super) fn option_is_empty(this: &Option<Self>) -> bool {
|
||||
this.as_ref()
|
||||
.map_or(true, |this| this.count_available() == 0)
|
||||
}
|
||||
|
||||
/// Verify a key and remove it. Returns whether the key was valid. Errors on openssl errors.
|
||||
pub(super) fn verify(&mut self, key: &str) -> Result<bool, Error> {
|
||||
let hash = self.hash(key.as_bytes())?;
|
||||
for entry in &mut self.entries {
|
||||
if entry.as_ref() == Some(&hash) {
|
||||
*entry = None;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Used to inform the user about the recovery code status.
|
||||
///
|
||||
/// This contains the available key indices.
|
||||
#[derive(Clone, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||
pub struct RecoveryState(Vec<usize>);
|
||||
|
||||
impl RecoveryState {
|
||||
pub fn is_available(&self) -> bool {
|
||||
!self.is_unavailable()
|
||||
}
|
||||
|
||||
pub fn is_unavailable(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Option<Recovery>> for RecoveryState {
|
||||
fn from(r: &Option<Recovery>) -> Self {
|
||||
match r {
|
||||
Some(r) => Self::from(r),
|
||||
None => Self::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Recovery> for RecoveryState {
|
||||
fn from(r: &Recovery) -> Self {
|
||||
Self(
|
||||
r.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, key)| if key.is_some() { Some(idx) } else { None })
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
@ -1,111 +0,0 @@
|
||||
//! Submodule for generic serde helpers.
|
||||
//!
|
||||
//! FIXME: This should appear in `proxmox-serde`.
|
||||
|
||||
use std::fmt;
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Helper to abstract away serde details, see [`fold`](fold()).
|
||||
pub struct FoldSeqVisitor<T, Out, F, Init>
|
||||
where
|
||||
Init: FnOnce(Option<usize>) -> Out,
|
||||
F: Fn(&mut Out, T) -> (),
|
||||
{
|
||||
init: Option<Init>,
|
||||
closure: F,
|
||||
expecting: &'static str,
|
||||
_ty: PhantomData<T>,
|
||||
}
|
||||
|
||||
impl<T, Out, F, Init> FoldSeqVisitor<T, Out, F, Init>
|
||||
where
|
||||
Init: FnOnce(Option<usize>) -> Out,
|
||||
F: Fn(&mut Out, T) -> (),
|
||||
{
|
||||
pub fn new(expecting: &'static str, init: Init, closure: F) -> Self {
|
||||
Self {
|
||||
init: Some(init),
|
||||
closure,
|
||||
expecting,
|
||||
_ty: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, T, Out, F, Init> serde::de::Visitor<'de> for FoldSeqVisitor<T, Out, F, Init>
|
||||
where
|
||||
Init: FnOnce(Option<usize>) -> Out,
|
||||
F: Fn(&mut Out, T) -> (),
|
||||
T: Deserialize<'de>,
|
||||
{
|
||||
type Value = Out;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str(self.expecting)
|
||||
}
|
||||
|
||||
fn visit_seq<A>(mut self, mut seq: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: serde::de::SeqAccess<'de>,
|
||||
{
|
||||
// unwrap: this is the only place taking out init and we're consuming `self`
|
||||
let mut output = (self.init.take().unwrap())(seq.size_hint());
|
||||
|
||||
while let Some(entry) = seq.next_element::<T>()? {
|
||||
(self.closure)(&mut output, entry);
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a serde sequence visitor with simple callbacks.
|
||||
///
|
||||
/// This helps building things such as filters for arrays without having to worry about the serde
|
||||
/// implementation details.
|
||||
///
|
||||
/// Example:
|
||||
/// ```
|
||||
/// # use serde::Deserialize;
|
||||
///
|
||||
/// #[derive(Deserialize)]
|
||||
/// struct Test {
|
||||
/// #[serde(deserialize_with = "stringify_u64")]
|
||||
/// foo: Vec<String>,
|
||||
/// }
|
||||
///
|
||||
/// fn stringify_u64<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
||||
/// where
|
||||
/// D: serde::Deserializer<'de>,
|
||||
/// {
|
||||
/// deserializer.deserialize_seq(proxmox_serde::fold(
|
||||
/// "a sequence of integers",
|
||||
/// |cap| cap.map(Vec::with_capacity).unwrap_or_else(Vec::new),
|
||||
/// |out, num: u64| {
|
||||
/// if num != 4 {
|
||||
/// out.push(num.to_string());
|
||||
/// }
|
||||
/// },
|
||||
/// ))
|
||||
/// }
|
||||
///
|
||||
/// let test: Test =
|
||||
/// serde_json::from_str(r#"{"foo":[2, 4, 6]}"#).expect("failed to deserialize test");
|
||||
/// assert_eq!(test.foo.len(), 2);
|
||||
/// assert_eq!(test.foo[0], "2");
|
||||
/// assert_eq!(test.foo[1], "6");
|
||||
/// ```
|
||||
pub fn fold<'de, T, Out, Init, Fold>(
|
||||
expected: &'static str,
|
||||
init: Init,
|
||||
fold: Fold,
|
||||
) -> FoldSeqVisitor<T, Out, Fold, Init>
|
||||
where
|
||||
Init: FnOnce(Option<usize>) -> Out,
|
||||
Fold: Fn(&mut Out, T) -> (),
|
||||
T: Deserialize<'de>,
|
||||
{
|
||||
FoldSeqVisitor::new(expected, init, fold)
|
||||
}
|
@ -1,89 +0,0 @@
|
||||
//! u2f configuration and challenge data
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use proxmox_tfa::u2f;
|
||||
|
||||
pub use proxmox_tfa::u2f::{Registration, U2f};
|
||||
|
||||
/// The U2F authentication configuration.
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct U2fConfig {
|
||||
pub appid: String,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub origin: Option<String>,
|
||||
}
|
||||
|
||||
/// A u2f registration challenge.
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct U2fRegistrationChallenge {
|
||||
/// JSON formatted challenge string.
|
||||
pub challenge: String,
|
||||
|
||||
/// The description chosen by the user for this registration.
|
||||
pub description: String,
|
||||
|
||||
/// When the challenge was created as unix epoch. They are supposed to be short-lived.
|
||||
created: i64,
|
||||
}
|
||||
|
||||
impl super::IsExpired for U2fRegistrationChallenge {
|
||||
fn is_expired(&self, at_epoch: i64) -> bool {
|
||||
self.created < at_epoch
|
||||
}
|
||||
}
|
||||
|
||||
impl U2fRegistrationChallenge {
|
||||
pub fn new(challenge: String, description: String) -> Self {
|
||||
Self {
|
||||
challenge,
|
||||
description,
|
||||
created: proxmox_time::epoch_i64(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Data used for u2f authentication challenges.
|
||||
///
|
||||
/// This is sent to the client at login time.
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct U2fChallenge {
|
||||
/// AppID and challenge data.
|
||||
pub(super) challenge: u2f::AuthChallenge,
|
||||
|
||||
/// Available tokens/keys.
|
||||
pub(super) keys: Vec<u2f::RegisteredKey>,
|
||||
}
|
||||
|
||||
/// The challenge data we need on the server side to verify the challenge:
|
||||
/// * It can only be used once.
|
||||
/// * It can expire.
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct U2fChallengeEntry {
|
||||
challenge: u2f::AuthChallenge,
|
||||
created: i64,
|
||||
}
|
||||
|
||||
impl U2fChallengeEntry {
|
||||
pub fn new(challenge: &U2fChallenge) -> Self {
|
||||
Self {
|
||||
challenge: challenge.challenge.clone(),
|
||||
created: proxmox_time::epoch_i64(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl super::IsExpired for U2fChallengeEntry {
|
||||
fn is_expired(&self, at_epoch: i64) -> bool {
|
||||
self.created < at_epoch
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<u2f::AuthChallenge> for U2fChallengeEntry {
|
||||
fn eq(&self, other: &u2f::AuthChallenge) -> bool {
|
||||
self.challenge.challenge == other.challenge && self.challenge.app_id == other.app_id
|
||||
}
|
||||
}
|
@ -1,118 +0,0 @@
|
||||
//! Webauthn configuration and challenge data.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg(feature = "api-types")]
|
||||
use proxmox_schema::api;
|
||||
|
||||
use super::IsExpired;
|
||||
|
||||
#[cfg_attr(feature = "api-types", api)]
|
||||
/// Server side webauthn server configuration.
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WebauthnConfig {
|
||||
/// Relying party name. Any text identifier.
|
||||
///
|
||||
/// Changing this *may* break existing credentials.
|
||||
pub rp: String,
|
||||
|
||||
/// Site origin. Must be a `https://` URL (or `http://localhost`). Should contain the address
|
||||
/// users type in their browsers to access the web interface.
|
||||
///
|
||||
/// Changing this *may* break existing credentials.
|
||||
pub origin: String,
|
||||
|
||||
/// Relying part ID. Must be the domain name without protocol, port or location.
|
||||
///
|
||||
/// Changing this *will* break existing credentials.
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// For now we just implement this on the configuration this way.
|
||||
///
|
||||
/// Note that we may consider changing this so `get_origin` returns the `Host:` header provided by
|
||||
/// the connecting client.
|
||||
impl webauthn_rs::WebauthnConfig for WebauthnConfig {
|
||||
fn get_relying_party_name(&self) -> String {
|
||||
self.rp.clone()
|
||||
}
|
||||
|
||||
fn get_origin(&self) -> &String {
|
||||
&self.origin
|
||||
}
|
||||
|
||||
fn get_relying_party_id(&self) -> String {
|
||||
self.id.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// A webauthn registration challenge.
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WebauthnRegistrationChallenge {
|
||||
/// Server side registration state data.
|
||||
pub(super) state: webauthn_rs::RegistrationState,
|
||||
|
||||
/// While this is basically the content of a `RegistrationState`, the webauthn-rs crate doesn't
|
||||
/// make this public.
|
||||
pub(super) challenge: String,
|
||||
|
||||
/// The description chosen by the user for this registration.
|
||||
pub(super) description: String,
|
||||
|
||||
/// When the challenge was created as unix epoch. They are supposed to be short-lived.
|
||||
created: i64,
|
||||
}
|
||||
|
||||
impl WebauthnRegistrationChallenge {
|
||||
pub fn new(
|
||||
state: webauthn_rs::RegistrationState,
|
||||
challenge: String,
|
||||
description: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
state,
|
||||
challenge,
|
||||
description,
|
||||
created: proxmox_time::epoch_i64(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IsExpired for WebauthnRegistrationChallenge {
|
||||
fn is_expired(&self, at_epoch: i64) -> bool {
|
||||
self.created < at_epoch
|
||||
}
|
||||
}
|
||||
|
||||
/// A webauthn authentication challenge.
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WebauthnAuthChallenge {
|
||||
/// Server side authentication state.
|
||||
pub(super) state: webauthn_rs::AuthenticationState,
|
||||
|
||||
/// While this is basically the content of a `AuthenticationState`, the webauthn-rs crate
|
||||
/// doesn't make this public.
|
||||
pub(super) challenge: String,
|
||||
|
||||
/// When the challenge was created as unix epoch. They are supposed to be short-lived.
|
||||
created: i64,
|
||||
}
|
||||
|
||||
impl WebauthnAuthChallenge {
|
||||
pub fn new(state: webauthn_rs::AuthenticationState, challenge: String) -> Self {
|
||||
Self {
|
||||
state,
|
||||
challenge,
|
||||
created: proxmox_time::epoch_i64(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IsExpired for WebauthnAuthChallenge {
|
||||
fn is_expired(&self, at_epoch: i64) -> bool {
|
||||
self.created < at_epoch
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user