mellon 0.1.0

Library for adding contemporary authentication to rust-based websites.
Documentation
use std::{
    io::{
        Error as IoError,
        ErrorKind::{AlreadyExists, InvalidData, WouldBlock},
        Result as IoResult,
    },
    path::{Path, PathBuf},
};

use argon2::{Argon2, PasswordVerifier};
use otpauth::TOTP;
use rand::Rng;
use thiserror::Error as ThisError;
use tokio::fs::{DirBuilder, read, read_to_string, remove_file, write};
use uuid::Uuid;
use webauthn_rs::{
    Webauthn,
    prelude::{
        CreationChallengeResponse, CredentialID, Passkey, PublicKeyCredential,
        RegisterPublicKeyCredential, WebauthnError,
    },
};

use crate::{AuthData, Challenge, SaneName, data::auth_data::LoginChallenge};

const BY_NAME_DIR: &str = "user_name";
const BY_ID_DIR: &str = "user_id";
const NAME_FILE: &str = "name";
const CHALLENGES_FILE: &str = "challenges";
const AUTH_DATA_FILE: &str = "auth";

async fn mkdir(dir: &Path) -> IoResult<()> {
    DirBuilder::new().recursive(true).create(dir).await
}

// TODO consider moving checks out into a different module and only do IO
/// A directory containing the user's authentication data
///
/// In addition, there is a helper directory that makes it possible to map `SaneName`s to UUIDs of users.
pub struct UserDirectory {
    prefix: PathBuf,
}

impl UserDirectory {
    pub fn new(path_prefix: PathBuf) -> Self {
        Self {
            prefix: path_prefix,
        }
    }

    fn id_path(&self, id: &Uuid) -> PathBuf {
        let mut path = self.prefix.clone();
        path.push(BY_ID_DIR);
        path.push(id.to_string());
        path
    }

    fn name_path(&self, namespace: &SaneName, name: &SaneName) -> PathBuf {
        let mut path = self.prefix.clone();
        path.push(BY_NAME_DIR);
        path.push(namespace.as_str());
        path.push(name.as_str());
        path
    }

    pub async fn create_user(&self, namespace: &SaneName, name: &SaneName) -> IoResult<Uuid> {
        let id = Uuid::new_v4();
        let path = self.name_path(namespace, name);
        if path.exists() {
            Err(AlreadyExists)? // someone with that name already exists
        } else {
            mkdir(path.parent().expect("several components")).await?; // ensure namespace folder exists
            write(path, id.as_bytes()).await?; // create file `name` containing the user's uuid

            let id_path = self.id_path(&id);
            if id_path.exists() {
                Err(AlreadyExists)? // this should statisically never happen
            }
            mkdir(&id_path).await?;

            let mut name_file = id_path.clone();
            name_file.push(NAME_FILE);
            write(name_file, format!("{namespace}/{name}")).await?;

            let auth_data = AuthData::default();
            self.save_auth_data(&id, &auth_data).await?;

            let challenges: Vec<crate::Challenge> = Vec::with_capacity(0);
            self.save_challenges_to_disk(&id, &challenges).await?;

            Ok(id)
        }
    }

    pub async fn user_id_by_name(&self, namespace: &SaneName, name: &SaneName) -> IoResult<Uuid> {
        let path = self.name_path(namespace, name);
        let id_data = read(path).await?;
        Uuid::from_slice(&id_data).map_err(IoError::other)
    }

    pub async fn user_name_by_id(&self, id: &Uuid) -> IoResult<(SaneName, SaneName)> {
        let mut name_file = self.id_path(id);
        name_file.push("name");
        let name_parts = read_to_string(name_file).await?;
        if let Some((namespace, name)) = name_parts.split_once('/') {
            let namespace = SaneName::new(namespace.to_string()).map_err(IoError::other)?;
            let name = SaneName::new(name.to_string()).map_err(IoError::other)?;
            Ok((namespace, name))
        } else {
            Err(InvalidData)? // bad data
        }
    }

    pub async fn load_auth_data(&self, id: &Uuid) -> IoResult<AuthData> {
        let mut path = self.id_path(id);
        path.push(AUTH_DATA_FILE);
        let data = read_to_string(path).await?;
        let result: AuthData = serde_json::from_str(&data).map_err(IoError::other)?;
        Ok(result)
    }

    pub async fn save_auth_data(&self, id: &Uuid, auth_data: &AuthData) -> IoResult<()> {
        let mut path = self.id_path(id);
        path.push(AUTH_DATA_FILE);
        write(
            path,
            serde_json::to_string(auth_data).expect("regular data"),
        )
        .await
    }

    pub async fn take(&self, id: &Uuid) -> IoResult<Vec<Challenge>> {
        let mut path = self.id_path(id);
        path.push(CHALLENGES_FILE);
        let data = read_to_string(&path).await?;
        remove_file(path).await?;
        let result: Vec<Challenge> = serde_json::from_str(&data).map_err(IoError::other)?;
        Ok(result)
    }

    // TODO think some more about attacks that clear/replace the challenge file
    pub async fn save_challenges_to_disk(
        &self,
        id: &Uuid,
        challenges: &[Challenge],
    ) -> IoResult<()> {
        let mut path = self.id_path(id);
        path.push(CHALLENGES_FILE);
        write(
            path,
            serde_json::to_string(challenges).expect("regular types"),
        )
        .await
    }

    pub async fn setup_login_challenge(
        &self,
        webauthn: &Webauthn,
        user_id: &Uuid,
    ) -> IoResult<LoginChallenge> {
        let auth_data = self.load_auth_data(user_id).await?;
        let keys: Vec<Passkey> = auth_data.webauthn.values().cloned().collect();
        let webauthn = if keys.is_empty() {
            None
        } else if let Ok((challenge, state)) = webauthn.start_passkey_authentication(&keys) {
            let state = vec![Challenge::WebauthnLogin(state)];
            self.save_challenges_to_disk(user_id, &state).await?;
            Some(challenge)
        } else {
            todo!("Handle start_passkey_authentication() failure");
        };

        Ok(LoginChallenge {
            password: auth_data.phc.is_some(),
            totp: auth_data.totp_setup.is_some(),
            webauthn,
        })
    }

    /// Verify the the login challenge
    ///
    /// The user needs to have at least 1 configured credential
    pub async fn verify_login_challenge(
        &self,
        webauthn: &Webauthn,
        user_id: &Uuid,
        password: Option<&str>,
        totp: Option<u32>,
        pubkey_credential: Option<PublicKeyCredential>,
    ) -> IoResult<bool> {
        let now = jiff::Timestamp::now().as_second() as u64;
        let mut auth_data = self.load_auth_data(user_id).await?;
        let mut methods_count: u8 = 0;

        if let Some(hash) = auth_data.password_hash() {
            methods_count += 1;
            if let Some(password) = password {
                if Argon2::default()
                    .verify_password(password.as_bytes(), &hash)
                    .is_err()
                {
                    return Ok(false);
                }
            } else {
                return Ok(false); // password required but none was provided
            }
        } // else: no password required

        if let Some(validator) = auth_data.totp() {
            methods_count += 1;
            const DURATION: u64 = 30;
            if let Some(totp) = totp {
                let mut success = false;
                // create a tolerance window
                for time in &[now - DURATION, now, now + DURATION] {
                    success = validator.verify(totp, DURATION, *time);
                    if success {
                        break;
                    }
                }
                if !success {
                    return Ok(false);
                }
            } else {
                return Ok(false); // totp required but none was provided
            }
        } // else: no totp required

        if !auth_data.webauthn.is_empty() {
            methods_count += 1;

            let Ok(challenges) = self.take(user_id).await else {
                return Ok(false); // client did not request preparation of a challenge
            };

            // There has to be exactly 1 challenge for this unless we add some other auth method in the future that also requires disk persistence for its challenges
            if challenges.len() != 1 {
                return Ok(false);
            }

            // And the challenge must be this type.
            let Challenge::WebauthnLogin(state) = &challenges[0] else {
                return Ok(false);
            };

            if let Some(pubkey_credential) = pubkey_credential {
                match webauthn.finish_passkey_authentication(&pubkey_credential, state) {
                    Ok(auth_result) => {
                        for key in auth_data.webauthn.values_mut() {
                            key.update_credential(&auth_result);
                        }
                        self.save_auth_data(user_id, &auth_data).await?;
                    }
                    Err(_) => {
                        return Ok(false);
                    }
                }
            } else {
                return Ok(false); // webauthn required but none was provided
            }
        }

        Ok(methods_count > 0) // at least one method is required
    }

    pub async fn new_totp(&self, user_id: &Uuid) -> IoResult<TOTP> {
        let mut seed = [0u8; 20];
        {
            let mut random = rand::thread_rng();
            if random.try_fill(&mut seed).is_err() {
                return Err(WouldBlock.into()); // I guess this fails if we can't get data?
            };
        } // don't try to store the RNG as part of this future

        let totp = TOTP::from_bytes(&seed);

        let seed_base32 = totp.base32_secret();
        let challenges = vec![Challenge::ConfirmTotp { seed_base32 }];

        self.save_challenges_to_disk(user_id, &challenges).await?;
        Ok(totp)
    }

    /// Confirm and install the new totp value
    pub async fn confirm_new_totp_challenge(
        &self,
        user_id: &Uuid,
        proposed_totp: u32,
    ) -> IoResult<bool> {
        let now = jiff::Timestamp::now().as_second() as u64;

        let mut challenges = self.take(user_id).await?;
        if challenges.len() != 1 {
            return Ok(false);
        }

        let Some(Challenge::ConfirmTotp { seed_base32 }) = challenges.pop() else {
            return Err(InvalidData.into()); // task has not been properly prepared
        };

        let Some(totp) = TOTP::from_base32(&seed_base32) else {
            return Err(InvalidData.into()); // strange data
        };

        if totp.verify(proposed_totp, 30, now) {
            let mut auth_data = self.load_auth_data(user_id).await?;
            auth_data.totp_setup = Some(seed_base32);
            self.save_auth_data(user_id, &auth_data).await?;
            Ok(true)
        } else {
            Ok(false)
        }
    }

    pub async fn new_webauthn_credential(
        &self,
        user_id: &Uuid,
        webauthn: &Webauthn,
    ) -> Result<CreationChallengeResponse, SetupWebauthnCredentialError> {
        let auth_data = self.load_auth_data(user_id).await?;
        let (namespace, username) = self.user_name_by_id(user_id).await?;
        let namespaced_name = format!("{namespace}/{username}");
        let exclude_credentials: Vec<CredentialID> = auth_data
            .webauthn
            .values()
            .map(|k| k.cred_id().clone())
            .collect();
        let (fido2_config, state) = webauthn.start_passkey_registration(
            *user_id,
            namespaced_name.as_str(),
            username.as_str(),
            Some(exclude_credentials),
        )?;
        let challenges = vec![Challenge::WebauthnRegister(state)];
        self.save_challenges_to_disk(user_id, &challenges).await?;
        Ok(fido2_config)
    }

    pub async fn confirm_new_webauthn_credential(
        &self,
        user_id: &Uuid,
        webauthn: &Webauthn,
        key_name: SaneName,
        fido2data: RegisterPublicKeyCredential,
    ) -> Result<(), SetupWebauthnCredentialError> {
        let challenges = self.take(user_id).await?;
        if challenges.len() != 1 {
            return Err(SetupWebauthnCredentialError::BadChallenge);
        }
        let Challenge::WebauthnRegister(state) = &challenges[0] else {
            return Err(SetupWebauthnCredentialError::BadChallenge);
        };
        let new_key = webauthn.finish_passkey_registration(&fido2data, state)?;
        let mut auth_data = self.load_auth_data(user_id).await?;
        auth_data.webauthn.insert(key_name, new_key);
        self.save_auth_data(user_id, &auth_data).await?;
        Ok(())
    }

    pub async fn delete_webauthn_credential(
        &self,
        user_id: &Uuid,
        key_name: SaneName,
    ) -> IoResult<()> {
        let mut auth_data = self.load_auth_data(user_id).await?;
        auth_data.webauthn.remove(&key_name); // idempotence: Allow double deletions
        self.save_auth_data(user_id, &auth_data).await?;
        Ok(())
    }
}

#[derive(Debug, ThisError)]
#[non_exhaustive]
pub enum SetupWebauthnCredentialError {
    #[error("Reading/writing data failed")]
    IO(#[from] IoError),
    #[error("Webauthn setup ceremony failed")]
    WebAuthn(#[from] WebauthnError),
    #[error("Bad challenge data on disk")]
    BadChallenge,
}

pub async fn username_from_id(auth_data: PathBuf, user_id: &Uuid) -> IoResult<SaneName> {
    Ok(UserDirectory::new(auth_data)
        .user_name_by_id(user_id)
        .await?
        .1)
}