use openssl::sign::Signer;
use serde::{de::Deserializer, Deserialize, Serialize};
use serde_json::Value;
-use webauthn_rs::Webauthn;
+use webauthn_rs::{proto::UserVerificationPolicy, Webauthn};
use webauthn_rs::proto::Credential as WebauthnCredential;
use proxmox::api::api;
+use proxmox::api::schema::{Updatable, Updater};
use proxmox::sys::error::SysError;
use proxmox::tools::fs::CreateOptions;
use proxmox::tools::tfa::totp::Totp;
}
#[api]
-#[derive(Clone, Deserialize, Serialize)]
+#[derive(Clone, Deserialize, Serialize, Updater)]
#[serde(deny_unknown_fields)]
/// Server side webauthn server configuration.
pub struct WebauthnConfig {
}
}
-// TODO: api macro should be able to generate this struct & impl automatically:
-#[api]
-#[derive(Default, Deserialize, Serialize)]
-#[serde(deny_unknown_fields)]
-/// Server side webauthn server configuration.
-pub struct WebauthnConfigUpdater {
- /// Relying party name. Any text identifier.
- ///
- /// Changing this *may* break existing credentials.
- rp: Option<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.
- origin: Option<String>,
-
- /// Relying part ID. Must be the domain name without protocol, port or location.
- ///
- /// Changing this *will* break existing credentials.
- id: Option<String>,
-}
-
-impl WebauthnConfigUpdater {
- pub fn apply_to(self, target: &mut WebauthnConfig) {
- if let Some(val) = self.rp {
- target.rp = val;
- }
-
- if let Some(val) = self.origin {
- target.origin = val;
- }
-
- if let Some(val) = self.id {
- target.id = val;
- }
- }
-
- pub fn build(self) -> Result<WebauthnConfig, Error> {
- Ok(WebauthnConfig {
- rp: self.rp.ok_or_else(|| format_err!("missing required field: `rp`"))?,
- origin: self.origin.ok_or_else(|| format_err!("missing required field: `origin`"))?,
- id: self.id.ok_or_else(|| format_err!("missing required field: `origin`"))?,
- })
- }
-}
-
/// 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
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")]
impl TfaInfo {
/// For recovery keys we have a fixed entry.
- pub(crate) fn recovery() -> Self {
+ pub(crate) fn recovery(created: i64) -> Self {
Self {
id: "recovery".to_string(),
- description: "recovery keys".to_string(),
+ description: String::new(),
enable: true,
+ created,
}
}
}
id: Uuid::generate().to_string(),
enable: true,
description,
+ created: proxmox::tools::time::epoch_i64(),
},
entry,
}
}
/// Save the current data. Note that we do not replace the file here since we lock the file
- /// itself, as it is in `/run`, and the typicall error case for this particular situation
+ /// itself, as it is in `/run`, and the typical error case for this particular situation
/// (machine loses power) simply prevents some login, but that'll probably fail anyway for
/// other reasons then...
///
}
impl TfaUserData {
- /// Shortcut for the option type.
- pub fn has_recovery(&self) -> bool {
- !Recovery::option_is_empty(&self.recovery)
+ /// 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
self.totp.is_empty()
&& self.u2f.is_empty()
&& self.webauthn.is_empty()
- && !self.has_recovery()
+ && self.recovery().is_none()
}
/// Find an entry by id, except for the "recovery" entry which we're currently treating
userid: &Userid,
description: String,
) -> Result<String, Error> {
+ let cred_ids: Vec<_> = self
+ .enabled_webauthn_entries()
+ .map(|cred| cred.cred_id.clone())
+ .collect();
+
let userid_str = userid.to_string();
- let (challenge, state) = webauthn.generate_challenge_register(&userid_str, None)?;
+ let (challenge, state) = webauthn.generate_challenge_register_options(
+ userid_str.as_bytes().to_vec(),
+ userid_str.clone(),
+ userid_str.clone(),
+ Some(cred_ids),
+ Some(UserVerificationPolicy::Discouraged),
+ )?;
+
let challenge_string = challenge.public_key.challenge.to_string();
let challenge = serde_json::to_string(&challenge)?;
return Ok(None);
}
- let (challenge, state) = webauthn.generate_challenge_authenticate(creds, None)?;
+ let (challenge, state) = webauthn
+ .generate_challenge_authenticate(creds, Some(UserVerificationPolicy::Discouraged))?;
let challenge_string = challenge.public_key.challenge.to_string();
let mut data = TfaUserChallengeData::open(userid)?;
data.inner
/// Recovery entries. We use HMAC-SHA256 with a random secret as a salted hash replacement.
#[derive(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 {
let mut this = Self {
secret: AsHex(&secret).to_string(),
entries: Vec::with_capacity(10),
+ created: proxmox::tools::time::epoch_i64(),
};
let mut original = Vec::new();
type Err = Error;
fn from_str(s: &str) -> Result<Self, Error> {
- Ok(if s.starts_with("totp:") {
- TfaResponse::Totp(s[5..].to_string())
- } else if s.starts_with("u2f:") {
- TfaResponse::U2f(serde_json::from_str(&s[4..])?)
- } else if s.starts_with("webauthn:") {
- TfaResponse::Webauthn(serde_json::from_str(&s[9..])?)
- } else if s.starts_with("recovery:") {
- TfaResponse::Recovery(s[9..].to_string())
+ 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");
})