mirror of
https://git.proxmox.com/git/proxmox
synced 2025-07-25 19:38:42 +00:00
proxmox-tfa: import tfa api from proxmox-perl-rs as api feature
Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
This commit is contained in:
parent
41d0cef377
commit
313d0a6b88
@ -20,6 +20,13 @@ serde_plain = "1.0"
|
|||||||
serde_json = { version = "1.0", optional = true }
|
serde_json = { version = "1.0", optional = true }
|
||||||
libc = { version = "0.2", optional = true }
|
libc = { version = "0.2", optional = true }
|
||||||
|
|
||||||
|
proxmox-schema = { path = "../proxmox-schema", features = [ "api-macro" ], optional = true }
|
||||||
|
proxmox-time = { path = "../proxmox-time", optional = true }
|
||||||
|
proxmox-uuid = { path = "../proxmox-uuid", optional = true }
|
||||||
|
webauthn-rs = { version = "0.2.5", optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
u2f = [ "libc", "serde_json", "serde/derive" ]
|
u2f = [ "libc", "serde_json", "serde/derive" ]
|
||||||
|
api = [ "u2f", "webauthn-rs", "proxmox-uuid", "proxmox-time" ]
|
||||||
|
api-types = [ "proxmox-schema" ]
|
||||||
|
485
proxmox-tfa/src/api/methods.rs
Normal file
485
proxmox-tfa/src/api/methods.rs
Normal file
@ -0,0 +1,485 @@
|
|||||||
|
//! 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};
|
||||||
|
|
||||||
|
#[cfg(feature = "api-types")]
|
||||||
|
use proxmox_schema::api;
|
||||||
|
|
||||||
|
use super::{OpenUserChallengeData, TfaConfig, TfaInfo, TfaUserData};
|
||||||
|
use crate::totp::Totp;
|
||||||
|
|
||||||
|
#[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) -> Option<TypedTfaInfo> {
|
||||||
|
let user_data = match config.users.get(userid) {
|
||||||
|
Some(u) => u,
|
||||||
|
None => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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 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: &str) -> 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: {
|
||||||
|
"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(())
|
||||||
|
}
|
1020
proxmox-tfa/src/api/mod.rs
Normal file
1020
proxmox-tfa/src/api/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
153
proxmox-tfa/src/api/recovery.rs
Normal file
153
proxmox-tfa/src/api/recovery.rs
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
111
proxmox-tfa/src/api/serde_tools.rs
Normal file
111
proxmox-tfa/src/api/serde_tools.rs
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
//! 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)
|
||||||
|
}
|
89
proxmox-tfa/src/api/u2f.rs
Normal file
89
proxmox-tfa/src/api/u2f.rs
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
//! u2f configuration and challenge data
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::u2f;
|
||||||
|
|
||||||
|
pub use crate::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
|
||||||
|
}
|
||||||
|
}
|
119
proxmox-tfa/src/api/webauthn.rs
Normal file
119
proxmox-tfa/src/api/webauthn.rs
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
//! Webauthn configuration and challenge data.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[cfg(feature = "api-types")]
|
||||||
|
use proxmox_schema::{api, Updater};
|
||||||
|
|
||||||
|
use super::IsExpired;
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "api-types", api)]
|
||||||
|
#[cfg_attr(feature = "api-types", derive(Updater))]
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
@ -2,3 +2,6 @@
|
|||||||
pub mod u2f;
|
pub mod u2f;
|
||||||
|
|
||||||
pub mod totp;
|
pub mod totp;
|
||||||
|
|
||||||
|
#[cfg(feature = "api")]
|
||||||
|
pub mod api;
|
||||||
|
Loading…
Reference in New Issue
Block a user