commit 937d9ab2666b4d0cd3fa9bf991bc4a19975c5bf9 Author: jacob.eva Date: Wed Mar 8 14:57:27 2023 +0000 First commit diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..9e63fe3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target +Cargo.lock +.ycm_extra_conf.py +.vimspector.json +tls +.cargo diff --git a/Cargo.toml b/Cargo.toml new file mode 100755 index 0000000..e84057d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "foxtrot_mfa" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +rocket = { version = "0.5.0-rc.2", features = ["mtls"] } +sqlx = { version = "0.6", features = [ "runtime-async-std-native-tls", "mysql" ] } +argon2 = "0.4.1" +rand = { version = "0.8", features = ["std"] } +openssl = "0.10.42" +totp-rs = "3.1.0" +data-encoding = "1.1.0" diff --git a/README.md b/README.md new file mode 100644 index 0000000..e60513c --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# Foxtrot MFA +This application is an example implementation of 3 methods of authentication, all of which must be satisfied in order to be granted access. +These methods are: +* Username & password +* Time based one time password +* Mutual TLS certificate + +## Setup + +Create SQL database: +`sudo mysql -p < migrations/20221017163745_users.sql` + +Create TLS directory: +`mkdir tls` + +Generate TLS certs: +`openssl req -x509 -newkey rsa:4096 -keyout tls/key.pem -out tls/cert.pem -sha256 -days 365 -nodes` + +Generate CA: +`openssl genrsa -out tls/ca.pem 4096 +openssl req -x509 -new -sha512 -nodes -key tls/ca.pem -days 365 -out tls/ca.crt` + +And fill in the options as required. diff --git a/Rocket.toml b/Rocket.toml new file mode 100644 index 0000000..a058978 --- /dev/null +++ b/Rocket.toml @@ -0,0 +1,7 @@ +[default.tls] +key = "tls/key.pem" +certs = "tls/cert.pem" + +[default.tls.mutual] +ca_certs = "tls/ca_crt.pem" +mandatory = false diff --git a/migrations/20221017163745_users.sql b/migrations/20221017163745_users.sql new file mode 100755 index 0000000..b8e4498 --- /dev/null +++ b/migrations/20221017163745_users.sql @@ -0,0 +1,28 @@ +drop database if exists foxtrot_mfa; + +create database foxtrot_mfa; + +grant all privileges on foxtrot_mfa.* to foxtrot@localhost; + +use foxtrot_mfa; + +create table users +( + id uuid DEFAULT uuid() PRIMARY KEY, + username varchar(255), + password varchar(255), + otpSecret varchar(255), + creationTime timestamp +); + +create table certs +( + id uuid DEFAULT uuid() PRIMARY KEY, + userID uuid, + FOREIGN KEY (userID) REFERENCES users(id), + pkcs12 BLOB, + creationTime timestamp +); + + +insert into users ( id, username, password) values ( uuid(), "test", "test"); diff --git a/src/api.rs b/src/api.rs new file mode 100755 index 0000000..1f48317 --- /dev/null +++ b/src/api.rs @@ -0,0 +1,181 @@ +use rocket::form::Form; // for creating form struct +use rocket::State; +use rocket::fs::NamedFile; +use sqlx::mysql::MySqlPool; +use std::fs::File; +use std::io::prelude::*; +use rocket::mtls::Certificate; +use rocket::response::content::RawHtml; +use rocket::http::ContentType; + +#[derive(FromForm)] +pub struct RegisterForm { + username: String, + password: String, + confirmpassword: String, +} + +#[derive(FromForm)] +pub struct LoginForm { + username: String, + password: String, + otp: String, +} + +pub struct Pool(pub MySqlPool); + +/* This function takes the user's submitted registration form and adds them to the users table of + * the database if the user's password and confirmation password are equal. If there are any + * errors during the database operation, the error is safely handled. */ +#[post("/user/create", data = "")] +pub async fn user_create(register_form: Form, pool: &State) -> RawHtml +{ + if !register_form.password.eq(®ister_form.confirmpassword) // if the password and the + // confirmation password aren't + // the same + { + let response = format!("Password and confirmation password are not the same."); + RawHtml(response) // return error to user + } + else + { + let hash_result = super::database::hash_pass(®ister_form.password).await; // hash password + let hash = match hash_result { // if the function returned a hash, then save it, if it + // returned an error then show the error to the user + Ok(hash) => hash, + Err(e) => { + return RawHtml(format!("Error hashing password, {}", e)) // return error to the user + } + }; + + // add user + let uuid_result = super::database::add_user(®ister_form.username, &hash, &pool.0).await; + + let uuid = match uuid_result { // if the function returned an id, then save it, if it returned an error + // then show the error to the user + Ok(uuid) => uuid, + Err(e) => return RawHtml(format!("Error adding user, {}", e)) // return error to the user + }; + + let otp_result = super::database::create_otp(&uuid, &pool.0).await; + + let otp = match otp_result { + Ok(otp) => otp, + Err(e) => return RawHtml(format!("Error generating OTP, {}", e)) + }; + + let response = format!("

Your OTP secret is: {}

\n

Please click here to download your p12 file.

", otp, uuid); + + RawHtml(response) + } +} + +/* This function is absolutely bloody awful right now but I have no idea how to generate a + * reference to a file without actually writing to the hard disk :( */ +#[get("/user//cert.p12")] +pub async fn user_cert(uuid: String, pool: &State) -> (ContentType, NamedFile) +{ + let cert_result = super::database::create_cert(&uuid, &pool.0).await; + + let cert = match cert_result { + Ok(cert) => cert, + Err(e) => panic!("Error creating cert, {}", e) + }; + + let cert_file_result = File::create("/tmp/browser.p12"); + + let mut cert_file = match cert_file_result { + Ok(cert_file) => cert_file, + Err(e) => panic!("Error creating cert file, {}", e) + }; + + let cert_der = match cert.to_der() { + Ok(cert_der) => cert_der, + Err(e) => panic!("Error converting cert to der, {}", e) + }; + + let write_result = cert_file.write_all(&cert_der[..]); + + match write_result { + Ok(_w) => _w, + Err(e) => panic!("Error writing to cert file, {}", e) + }; + + let named_cert_result = NamedFile::open("/tmp/browser.p12").await; + + let named_cert = match named_cert_result { + Ok(named_cert) => named_cert, + Err(e) => panic!("Error converting cert to der, {}", e) + }; + + let content_type = ContentType::new("application", "x-pkcs12"); + (content_type, named_cert) +} + +#[post("/user/login", data = "")] +pub async fn user_login(_cert: Certificate<'_>, login_form: Form, pool: &State) -> String +{ + let hash_result = super::database::retrieve_hash(&login_form.username, &pool.0).await; + + let hash_result_value = match hash_result { + Ok(hash_result_value) => hash_result_value, + Err(e) => return format!("Error, username doesn't exist: {}", e) + }; + + let comparison_result = super::database::verify_pass(&login_form.password, &hash_result_value).await; + + let comparison_result_value = match comparison_result { + Ok(comparison_result_value) => comparison_result_value, + Err(e) => return format!("Error when comparing hash and password: {}", e) + }; + + let otp_result = super::database::verify_otp(&login_form.username, &login_form.otp, &pool.0).await; + + let otp_result_value = match otp_result { + Ok(result_value) => result_value, + Err(e) => return format!("Error when verifying otp: {}", e) + }; + + if otp_result_value && comparison_result_value + { + return format!("You've successfully authenticated!"); + } + else + { + return format!("Incorrect login."); + } +} + +#[cfg(test)] + +mod main_tests { + use super::super::rocket; + use rocket::local::asynchronous::Client; + use rocket::http::Status; + use rocket::http::ContentType; + + /* This unit test checks if the /register route outputs that the password and confirmation + * password are not equal when provided with two different inputs for those fields. */ + #[rocket::async_test] + async fn register_test() { + let client = Client::tracked(super::super::rocket().await).await.expect("Valid instance"); + let response = client + .post("/register") + .body("username=test&password=1&confirmpassword=2") + .header(ContentType::Form) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + assert_eq!(response.into_string().await.unwrap(), "Password and confirmation password are not equal."); + } + + /* This unit test checks if the connection to the database works properly */ + #[rocket::async_test] + async fn pool_test() { + let pool_result = super::super::database::connect().await; + match pool_result { + Ok(_p) => assert!(true), + Err(_e) => assert!(false) + }; + } +} diff --git a/src/cert.rs b/src/cert.rs new file mode 100644 index 0000000..fab800a --- /dev/null +++ b/src/cert.rs @@ -0,0 +1,98 @@ +use openssl::asn1::Asn1Time; +use openssl::bn::{BigNum, MsbOption}; +use openssl::error::ErrorStack; +use openssl::hash::MessageDigest; +use openssl::pkey::{PKey, Private}; +use openssl::rsa::Rsa; +use openssl::x509::extension::{ + AuthorityKeyIdentifier, BasicConstraints, KeyUsage, SubjectAlternativeName, + SubjectKeyIdentifier, ExtendedKeyUsage +}; +use openssl::x509::{X509NameBuilder, X509Req, X509ReqBuilder, X509}; +use openssl::pkcs12::Pkcs12; + +async fn create_req(key_pair: &PKey) -> Result +{ + let mut req_builder = X509ReqBuilder::new()?; + req_builder.set_pubkey(key_pair)?; + + let mut x509_name = X509NameBuilder::new()?; + x509_name.append_entry_by_text("C", "GB")?; + x509_name.append_entry_by_text("ST", "Cornwall")?; + x509_name.append_entry_by_text("O", "Foxtrot MFA Corp.")?; + x509_name.append_entry_by_text("CN", "foxtrot.mfa")?; + let x509_name = x509_name.build(); + req_builder.set_subject_name(&x509_name)?; + + req_builder.sign(key_pair, MessageDigest::sha256())?; + let req = req_builder.build(); + Ok(req) +} + +pub async fn create_ca_signed_cert(ca_cert: &X509, ca_key_pair: &PKey) -> Result<(X509, PKey), ErrorStack> +{ + let rsa = Rsa::generate(4096)?; + let key_pair = PKey::from_rsa(rsa)?; + + let req = create_req(&key_pair).await?; + + let mut cert_builder = X509::builder()?; + cert_builder.set_version(2)?; + let serial_number = { + let mut serial = BigNum::new()?; + serial.rand(159, MsbOption::MAYBE_ZERO, false)?; + serial.to_asn1_integer()? + }; + cert_builder.set_serial_number(&serial_number)?; + cert_builder.set_subject_name(req.subject_name())?; + cert_builder.set_issuer_name(ca_cert.subject_name())?; + cert_builder.set_pubkey(&key_pair)?; + let not_before = Asn1Time::days_from_now(0)?; + cert_builder.set_not_before(¬_before)?; + let not_after = Asn1Time::days_from_now(365)?; + cert_builder.set_not_after(¬_after)?; + + cert_builder.append_extension(BasicConstraints::new().build()?)?; + + cert_builder.append_extension( + KeyUsage::new() + .critical() + .non_repudiation() + .digital_signature() + .key_encipherment() + .build()?, + )?; + + cert_builder.append_extension( + ExtendedKeyUsage::new() + .client_auth() + .build()?, + )?; + + let subject_key_identifier = + SubjectKeyIdentifier::new().build(&cert_builder.x509v3_context(Some(ca_cert), None))?; + cert_builder.append_extension(subject_key_identifier)?; + + let auth_key_identifier = AuthorityKeyIdentifier::new() + .keyid(false) + .issuer(false) + .build(&cert_builder.x509v3_context(Some(ca_cert), None))?; + cert_builder.append_extension(auth_key_identifier)?; + + let subject_alt_name = SubjectAlternativeName::new() + .dns("foxtrot.mfa") + .build(&cert_builder.x509v3_context(Some(ca_cert), None))?; + cert_builder.append_extension(subject_alt_name)?; + + cert_builder.sign(ca_key_pair, MessageDigest::sha256())?; + let cert = cert_builder.build(); + + Ok((cert, key_pair)) +} + +pub async fn create_pkcs12(cert: &X509, key: &PKey) -> Result +{ + let pkcs12_builder = Pkcs12::builder(); + let pkcs12_keystore = pkcs12_builder.build("", "browser", key, cert)?; + Ok(pkcs12_keystore) +} diff --git a/src/database.rs b/src/database.rs new file mode 100755 index 0000000..9bd8fdd --- /dev/null +++ b/src/database.rs @@ -0,0 +1,231 @@ +use sqlx::mysql::MySqlPool; // for connecting to db +use argon2::Argon2; +use std::{env, fs}; +use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use rand::rngs::OsRng; +use rand::Rng; +use openssl::error::ErrorStack; +use openssl::pkey::PKey; +use openssl::x509::X509; +use totp_rs::{Algorithm, TOTP, Secret}; +use openssl::pkcs12::Pkcs12; +use data_encoding::base32; + +pub async fn connect() -> Result +{ + let url = env!("DATABASE_URL"); + + let pool = MySqlPool::connect(url).await?; + + Ok(pool) +} + +pub async fn hash_pass(password: &String) -> Result { + let argon2 = Argon2::default(); // init new instance of argon2 hashing + let password_u8 = password.as_bytes(); // get value of password as bytes + let salt = SaltString::generate(&mut OsRng); // generate salt + let hash = argon2.hash_password(password_u8, &salt)?.to_string(); // generate hash of password using salt + + Ok(hash) // return hash +} + +pub async fn verify_pass(password: &String, hash: &String) -> Result { + let argon2 = Argon2::default(); + let password_u8 = password.as_bytes(); + let hash_parsed = PasswordHash::new(hash)?; + let result = argon2.verify_password(password_u8, &hash_parsed).is_ok(); + if result + { + return Ok(true); + } + else + { + return Ok(false); + } +} + +pub async fn retrieve_hash(username: &String, pool: &MySqlPool) -> Result +{ + let result = sqlx::query!( + r#" + select password from users + where username = ? + "#, + username) + .fetch_one(pool) + .await?; + + let hash: String = result.password.unwrap(); + + Ok(hash) +} + +pub async fn add_user(username: &String, hash: &String, pool: &MySqlPool) -> Result +{ + sqlx::query!( + r#" + insert into users ( username, password ) + values ( ?, ? ) + "#, + username, + hash + ) + .execute(pool) + .await?; + + let user_id_result = sqlx::query!( + r#" + select id from users + where username=? + "#, + username + ) + .fetch_one(pool) + .await?; + + Ok(user_id_result.id) +} + +pub async fn create_otp(uuid: &String, pool: &MySqlPool) -> Result +{ + let mut secret = String::from(""); + for _ in 1..22 + { + let mut rng = rand::thread_rng(); + let letter: char = rng.gen_range(b'a'..b'z') as char; + secret.push(letter); + } + + let base32_secret = base32::encode(secret.as_bytes()); + let trimmed_secret = base32_secret.replace("=", ""); // trim the equals from the base32 encoded + // string + + sqlx::query!( + r#" + update users + set otpSecret = ? + where id = ? + "#, + trimmed_secret, // store base32 encoded secret + uuid + ) + .execute(pool) + .await? + .rows_affected(); + + return Ok(trimmed_secret); +} + +pub async fn verify_otp(username: &String, otp_code: &String, pool: &MySqlPool) -> Result +{ + let otp_result = sqlx::query!( + r#" + select otpSecret from users + where username = ? + "#, + username + ) + .fetch_one(pool) + .await?; + + let otp_secret: String = otp_result.otpSecret.unwrap(); + + let totp = TOTP::new( + Algorithm::SHA1, + 6, + 1, + 30, + Secret::Encoded(otp_secret.to_string()).to_bytes().unwrap(), + ).unwrap(); + let expected_code = totp.generate_current().unwrap(); + + if otp_code.eq(&expected_code) + { + return Ok(true) + } + else + { + return Ok(false) + } +} + +pub async fn create_cert(uuid: &String, pool: &MySqlPool) -> Result +{ + let ca_crt_pem_result = fs::read_to_string("tls/ca_crt.pem"); + + let ca_crt_pem = match ca_crt_pem_result { + Ok(ca_crt_pem) => ca_crt_pem, + Err(e) => panic!("{}", e) + }; + + let ca_key_pem_result = fs::read_to_string("tls/ca_key.pem"); + + let ca_key_pem = match ca_key_pem_result { + Ok(ca_key_pem) => ca_key_pem, + Err(e) => panic!("{}", e) + }; + + let ca_crt_pem_obj = X509::from_pem(ca_crt_pem.as_bytes())?; + + let ca_key_pem_obj = PKey::private_key_from_pem(ca_key_pem.as_bytes())?; + + let cert_and_key = super::cert::create_ca_signed_cert(&ca_crt_pem_obj, &ca_key_pem_obj).await?; + + let cert = cert_and_key.0; + let key = cert_and_key.1; + + let pkcs12_keystore = super::cert::create_pkcs12(&cert, &key).await?; + + let pkcs12_der = match pkcs12_keystore.to_der() { + Ok(pkcs12_der) => pkcs12_der, + Err(e) => panic!("{}", e) + }; + + let cert_id_result = sqlx::query!( + r#" + insert into certs ( userID, pkcs12 ) + values ( ?, ? ) + "#, + uuid, + &pkcs12_der[..] + ) + .execute(pool) + .await; + + let _o = match cert_id_result { + Ok(_o) => _o, + Err(e) => panic!("{}", e) + }; + + Ok(pkcs12_keystore) +} + +#[cfg(test)] +mod database_tests { + use sqlx::mysql::MySqlPool; + + /* This unit test checks if the add user functionality successfully inserts the record + * into the database */ + #[sqlx::test] + async fn add_user_test(pool: MySqlPool) { + use super::*; + let username = String::from("test"); + let password = String::from("test"); + let id_result = add_user(&username, &password, &pool).await; // add test user to database + match id_result { // panic if add user returns an error + Ok(_p) => assert!(true), + Err(_e) => assert!(false) + }; + } + + /* This unit test checks if the connection to the database works properly */ + #[rocket::async_test] + async fn pool_test() { + use super::*; + let pool_result = connect().await; // connect to the database + match pool_result { // panic if connect returns an error + Ok(_p) => assert!(true), + Err(_e) => assert!(false) + }; + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100755 index 0000000..4a84683 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,24 @@ +#[macro_use] extern crate rocket; + +use rocket::fs::{FileServer, relative}; // for serving static dir +mod database; +mod api; +mod cert; + +#[launch] +pub async fn rocket() -> _ { + let pool_result = database::connect().await; + + let pool = match pool_result { + Ok(pool) => pool, + Err(e) => panic!("Error with database connection, {}", e) + }; + + rocket::build() + .manage(api::Pool(pool)) + .mount("/", FileServer::from(relative!("static"))) + .mount("/", routes![api::user_create]) + .mount("/", routes![api::user_login]) + .mount("/", routes![api::user_cert]) +} + diff --git a/static/index.html b/static/index.html new file mode 100755 index 0000000..bbdca3e --- /dev/null +++ b/static/index.html @@ -0,0 +1,14 @@ + + + Foxtrot MFA system + + + +

Welcome

+ + + + + + + diff --git a/static/login.html b/static/login.html new file mode 100755 index 0000000..44932ed --- /dev/null +++ b/static/login.html @@ -0,0 +1,16 @@ + + + Foxtrot MFA system + + + +
+ +
+ +
+ +
+ +
+ diff --git a/static/register.html b/static/register.html new file mode 100755 index 0000000..93fcf21 --- /dev/null +++ b/static/register.html @@ -0,0 +1,16 @@ + + + Foxtrot MFA system + + + +
+ +
+ +
+ +
+ +
+