gateryx 0.1.6

Secure HTTP gateway for IoT and Industrial applications
Documentation
use std::net::IpAddr;

use mini_moka::sync::{Cache, CacheBuilder};
use serde::Deserialize;
use tracing::{error, warn};
use url::Url;
use uuid::Uuid;
pub use webauthn_rs::prelude::{
    Base64UrlSafeData, CreationChallengeResponse, PublicKeyCredential, RegisterPublicKeyCredential,
    RequestChallengeResponse,
};
use webauthn_rs::{
    Webauthn, WebauthnBuilder,
    prelude::{DiscoverableAuthentication, PasskeyRegistration},
};
use zeroize::{Zeroize, ZeroizeOnDrop};

use crate::{
    Error, Result,
    authenticator::RandomSleeper,
    storage::Storage,
    util::{GDuration, Numeric},
};

fn default_timeout() -> GDuration {
    GDuration::from_secs(60)
}

#[derive(Deserialize, Zeroize, ZeroizeOnDrop)]
pub struct Config {
    #[zeroize(skip)]
    max_reg_challenges: Numeric,
    #[zeroize(skip)]
    max_auth_challenges: Numeric,
    #[zeroize(skip)]
    max_auth_challenges_per_ip: Option<Numeric>,
    #[serde(default = "default_timeout")]
    #[zeroize(skip)]
    timeout: GDuration,
    #[serde(default = "crate::util::default_true")]
    check_login_present: bool,
}

pub struct Factory {
    webauthn: Webauthn,
    reg_challenges: Cache<String, PasskeyRegistration>,
    login_challenges: Cache<Base64UrlSafeData, (IpAddr, DiscoverableAuthentication)>,
    check_login_present: bool,
    max_auth_challenges_per_ip: Option<usize>,
}

impl Factory {
    pub fn create<H>(system_hosts: &[H], config: &Config) -> Result<Self>
    where
        H: AsRef<str>,
    {
        let mut i = system_hosts.iter();
        let timeout = config.timeout.into();
        let primary_host = i
            .next()
            .ok_or(Error::invalid_data("no system hosts provided"))?
            .as_ref()
            .to_string();
        let rp_origin: Url =
            Url::parse(&format!("https://{}", primary_host)).map_err(Error::invalid_data)?;
        let webauthn = WebauthnBuilder::new(&primary_host, &rp_origin)
            .map_err(Error::invalid_data)?
            .rp_name(&primary_host)
            .timeout(timeout)
            .build()
            .map_err(Error::invalid_data)?;
        if i.next().is_some() {
            warn!(
                "Multiple system hosts are not supported for passkeys; Use {} only to manage/login with passkeys",
                primary_host
            );
        }
        Ok(Self {
            webauthn,
            reg_challenges: CacheBuilder::new(config.max_reg_challenges.into())
                .time_to_live(timeout)
                .build(),
            login_challenges: CacheBuilder::new(config.max_auth_challenges.into())
                .time_to_live(timeout)
                .build(),
            check_login_present: config.check_login_present,
            max_auth_challenges_per_ip: config.max_auth_challenges_per_ip.map(Into::into),
        })
    }
    pub fn need_check_login_present(&self) -> bool {
        self.check_login_present
    }
    pub fn start_authentication(&self, remote_ip: IpAddr) -> Result<RequestChallengeResponse> {
        if let Some(max_per_ip) = self.max_auth_challenges_per_ip {
            let count = self
                .login_challenges
                .iter()
                .filter(|entry| entry.value().0 == remote_ip)
                .count();
            if count >= max_per_ip {
                return Err(Error::access("Too many authentication attempts"));
            }
        }
        let (challenge_response, passkey_authentication) = self
            .webauthn
            .start_discoverable_authentication()
            .map_err(Error::failed)?;
        let challenge = challenge_response.public_key.challenge.clone();
        self.login_challenges
            .insert(challenge, (remote_ip, passkey_authentication));
        Ok(challenge_response)
    }
    pub async fn finish_authentication(
        &self,
        challenge: &Base64UrlSafeData,
        auth: PublicKeyCredential,
        storage: &dyn Storage,
    ) -> Result<String> {
        let random_sleeper = RandomSleeper::new(100..200);
        let (_, passkey_authentication) = self
            .login_challenges
            .get(challenge)
            .ok_or_else(|| Error::failed("no authentication in progress"))?;
        self.login_challenges.invalidate(challenge);
        let (_, cred_id) = self
            .webauthn
            .identify_discoverable_authentication(&auth)
            .map_err(Error::failed)?;
        let Some((user, stored_passkey)) = storage.lookup_passkey(cred_id).await? else {
            random_sleeper.sleep().await;
            return Err(Error::access("No such passkey registered"));
        };
        let res = self
            .webauthn
            .finish_discoverable_authentication(
                &auth,
                passkey_authentication,
                &[stored_passkey.into()],
            )
            .map_err(Error::failed)?;
        random_sleeper.sleep().await;
        if !res.user_verified() {
            return Err(Error::access("Passkey authentication not verified"));
        }
        Ok(user)
    }
    pub fn start_registration(&self, user: &str) -> Result<CreationChallengeResponse> {
        let user_id = Uuid::new_v4();
        let (challenge_response, passkey_registration) = self
            .webauthn
            .start_passkey_registration(user_id, user, user, None)
            .map_err(Error::failed)?;
        self.reg_challenges
            .insert(user.to_string(), passkey_registration);
        Ok(challenge_response)
    }
    pub async fn finish_registration(
        &self,
        user: String,
        reg: RegisterPublicKeyCredential,
        storage: &dyn Storage,
    ) -> Result<()> {
        let passkey_registration = self
            .reg_challenges
            .get(&user)
            .ok_or_else(|| Error::failed("no registration in progress"))?;
        self.reg_challenges.invalidate(&user);
        let passkey = self
            .webauthn
            .finish_passkey_registration(&reg, &passkey_registration)
            .map_err(Error::failed)?;
        if let Err(e) = storage.save_passkey(&user, passkey).await {
            error!(user, error = %e, "Failed to save passkey");
            return Err(e);
        }
        Ok(())
    }
}