Generate & return webauthn registration challenge

This commit is contained in:
Pierre HUBERT 2022-04-20 21:06:53 +02:00
parent 10982190e7
commit 1f0e6d05c8
11 changed files with 336 additions and 4 deletions

97
Cargo.lock generated
View File

@ -424,10 +424,12 @@ dependencies = [
"actix",
"actix-identity",
"actix-web",
"aes-gcm",
"askama",
"base32",
"base64",
"bcrypt",
"bincode",
"clap",
"digest 0.10.3",
"env_logger",
@ -444,8 +446,10 @@ dependencies = [
"serde_yaml",
"sha2 0.10.2",
"totp_rfc6238",
"url",
"urlencoding",
"uuid",
"webauthn-rs",
]
[[package]]
@ -459,6 +463,15 @@ dependencies = [
"getrandom",
]
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]]
name = "bit_field"
version = "0.10.1"
@ -995,6 +1008,21 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.0.1"
@ -1693,6 +1721,33 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "openssl"
version = "0.10.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c7ae222234c30df141154f159066c5093ff73b63204dcda7121eb082fc56a95"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-sys",
]
[[package]]
name = "openssl-sys"
version = "0.9.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e46109c383602735fa0a2e48dd2b7c892b048e1bf69e5c3b1d804b7d9c203cb"
dependencies = [
"autocfg 1.1.0",
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "os_str_bytes"
version = "6.0.0"
@ -1831,6 +1886,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "pkg-config"
version = "0.3.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae"
[[package]]
name = "png"
version = "0.17.5"
@ -2101,6 +2162,16 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde_cbor"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5"
dependencies = [
"half",
"serde",
]
[[package]]
name = "serde_derive"
version = "1.0.136"
@ -2539,6 +2610,7 @@ dependencies = [
"idna",
"matches",
"percent-encoding",
"serde",
]
[[package]]
@ -2562,6 +2634,12 @@ dependencies = [
"getrandom",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.4"
@ -2644,6 +2722,25 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webauthn-rs"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90b266eccb4b32595876f5c73ea443b0516da0b1df72ca07bc08ed9ba7f96ec1"
dependencies = [
"base64",
"nom",
"openssl",
"rand",
"serde",
"serde_cbor",
"serde_derive",
"serde_json",
"thiserror",
"tracing",
"url",
]
[[package]]
name = "weezl"
version = "0.1.5"

View File

@ -30,4 +30,8 @@ sha2 = "0.10.2"
lazy-regex = "2.3.0"
totp_rfc6238 = "0.5.0"
base32 = "0.4.0"
qrcode-generator = "4.1.4"
qrcode-generator = "4.1.4"
webauthn-rs = "0.3.2"
url = "2.2.2"
aes-gcm = { version = "0.9.4", features = ["aes"] }
bincode = "1.3.3"

View File

@ -9,6 +9,7 @@ use crate::data::app_config::AppConfig;
use crate::data::current_user::CurrentUser;
use crate::data::totp_key::TotpKey;
use crate::data::user::User;
use crate::data::webauthn_manager::WebAuthManagerReq;
#[derive(Template)]
#[template(path = "settings/two_factors_page.html")]
@ -26,6 +27,13 @@ struct AddTotpPage {
secret_key: String,
}
#[derive(Template)]
#[template(path = "settings/add_webauthn_page.html")]
struct AddWebauhtnPage {
_p: BaseSettingsPage,
opaque_state: String,
challenge_json: String,
}
/// Manage two factors authentication methods route
pub async fn two_factors_route(user: CurrentUser) -> impl Responder {
@ -69,4 +77,35 @@ pub async fn add_totp_factor_route(user: CurrentUser, app_conf: web::Data<AppCon
account_name: key.account_name(&user, &app_conf),
secret_key: key.get_secret(),
}.render().unwrap())
}
/// Configure a new security key factor
pub async fn add_webauthn_factor_route(user: CurrentUser, manager: WebAuthManagerReq) -> impl Responder {
let registration_request = match manager.start_register(&user) {
Ok(r) => r,
Err(e) => {
log::error!("Failed to request new key! {:?}", e);
return HttpResponse::InternalServerError().body("Failed to generate request for registration!");
}
};
let challenge_json = match serde_json::to_string(&registration_request.creation_challenge) {
Ok(r) => r,
Err(e) => {
log::error!("Failed to serialize challenge! {:?}", e);
return HttpResponse::InternalServerError().body("Failed to serialize challenge!");
}
};
HttpResponse::Ok()
.body(AddWebauhtnPage {
_p: BaseSettingsPage::get(
"New security key",
&user,
None,
None),
opaque_state: registration_request.opaque_state,
challenge_json: urlencoding::encode(&challenge_json).to_string(),
}.render().unwrap())
}

View File

@ -0,0 +1,92 @@
use std::io::ErrorKind;
use aes_gcm::{Aes256Gcm, Key, Nonce};
use aes_gcm::aead::Aead;
use aes_gcm::NewAead;
use rand::Rng;
use serde::de::DeserializeOwned;
use serde::Serialize;
use crate::utils::err::Res;
const NONCE_LEN: usize = 12;
const KEY_LEN: usize = 32;
pub struct CryptoWrapper {
key: Vec<u8>,
}
impl CryptoWrapper {
/// Generate a new memory wrapper
pub fn new_random() -> Self {
Self { key: (0..KEY_LEN).map(|_| { rand::random::<u8>() }).collect() }
}
/// Encrypt some data
pub fn encrypt<T: Serialize + DeserializeOwned>(&self, data: &T) -> Res<String> {
let aes_key = Aes256Gcm::new(Key::from_slice(&self.key));
let nonce_bytes = rand::thread_rng().gen::<[u8; NONCE_LEN]>();
let serialized_data = bincode::serialize(data)?;
let mut enc = aes_key.encrypt(Nonce::from_slice(&nonce_bytes),
serialized_data.as_slice()).unwrap();
enc.extend_from_slice(&nonce_bytes);
Ok(base64::encode(enc))
}
/// Decrypt some data previously encrypted using the [`CryptoWrapper::encrypt`] method
pub fn decrypt<T: DeserializeOwned>(&self, input: &str) -> Res<T> {
let bytes = base64::decode(input)?;
if bytes.len() < NONCE_LEN {
return Err(Box::new(std::io::Error::new(ErrorKind::Other,
"Input string is smaller than nonce!")));
}
let (enc, nonce) = bytes.split_at(bytes.len() - NONCE_LEN);
assert_eq!(nonce.len(), NONCE_LEN);
let aes_key = Aes256Gcm::new(Key::from_slice(&self.key));
let dec = match aes_key.decrypt(Nonce::from_slice(nonce), enc) {
Ok(d) => d,
Err(e) => {
log::error!("Failed to decrypt wrapped data! {:#?}", e);
return Err(Box::new(std::io::Error::new(ErrorKind::Other,
"Failed to decrypt wrapped data!")));
}
};
Ok(bincode::deserialize(&dec)?)
}
}
#[cfg(test)]
mod test {
use crate::data::crypto_wrapper::CryptoWrapper;
#[derive(serde::Serialize, serde::Deserialize, Eq, PartialEq, Debug)]
struct Message(String);
#[test]
fn encrypt_and_decrypt() {
let wrapper = CryptoWrapper::new_random();
let msg = Message("Pierre was here".to_string());
let enc = wrapper.encrypt(&msg).unwrap();
let dec: Message = wrapper.decrypt(&enc).unwrap();
assert_eq!(dec, msg)
}
#[test]
fn encrypt_and_decrypt_invalid() {
let wrapper_1 = CryptoWrapper::new_random();
let wrapper_2 = CryptoWrapper::new_random();
let msg = Message("Pierre was here".to_string());
let enc = wrapper_1.encrypt(&msg).unwrap();
wrapper_2.decrypt::<Message>(&enc).unwrap_err();
}
}

View File

@ -12,7 +12,7 @@ pub struct IdToken {
/// REQUIRED. Audience(s) that this ID Token is intended for. It MUST contain the OAuth 2.0 client_id of the Relying Party as an audience value. It MAY also contain identifiers for other audiences. In the general case, the aud value is an array of case sensitive strings. In the common special case when there is one audience, the aud value MAY be a single case sensitive string.
#[serde(rename = "aud")]
pub audience: String,
/// REQUIRED. Expiration time on or after which the ID Token MUST NOT be accepted for processing. The processing of this parameter requires that the current date/time MUST be before the expiration date/time listed in the value. Implementers MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew. Its value is a JSON number representing the number of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time. See RFC 3339 [RFC3339] for details regarding date/times in general and UTC in particular.
/// REQUIRED. Expiration time on or after which the ID Token MUST NOT be accepted for processing. The processing of this parameter requires that the current date/time MUST be before the expiration date/time listed in the value. Implementers MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew. Its value is a JSON number representing the number of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time. See RFC 3339 for details regarding date/times in general and UTC in particular.
#[serde(rename = "exp")]
pub expiration_time: u64,
/// REQUIRED. Time at which the JWT was issued. Its value is a JSON number representing the number of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time.

View File

@ -12,4 +12,6 @@ pub mod code_challenge;
pub mod open_id_user_info;
pub mod access_token;
pub mod totp_key;
pub mod login_redirect;
pub mod login_redirect;
pub mod webauthn_manager;
pub mod crypto_wrapper;

View File

@ -34,7 +34,7 @@ impl TotpKey {
/// Get QrCode URL for user
///
/// Based on https://github.com/google/google-authenticator/wiki/Key-Uri-Format
/// Based on <https://github.com/google/google-authenticator/wiki/Key-Uri-Format>
pub fn url_for_user(&self, u: &User, conf: &AppConfig) -> String {
format!(
"otpauth://totp/{}:{}?secret={}&issuer={}&algorithm=SHA1&digits={}&period={}",

View File

@ -0,0 +1,77 @@
use std::sync::Arc;
use actix_web::web;
use webauthn_rs::{RegistrationState, Webauthn, WebauthnConfig};
use webauthn_rs::proto::CreationChallengeResponse;
use crate::constants::APP_NAME;
use crate::data::app_config::AppConfig;
use crate::data::crypto_wrapper::CryptoWrapper;
use crate::data::user::{User, UserID};
use crate::utils::err::Res;
#[derive(Debug)]
struct WebAuthnAppConfig {
origin: url::Url,
relying_party_id: String,
}
impl WebauthnConfig for WebAuthnAppConfig {
fn get_relying_party_name(&self) -> &str {
APP_NAME
}
fn get_origin(&self) -> &url::Url {
&self.origin
}
fn get_relying_party_id(&self) -> &str {
&self.relying_party_id
}
}
pub struct RegisterKeyRequest {
pub opaque_state: String,
pub creation_challenge: CreationChallengeResponse,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct RegisterKeyOpaqueData {
registration_state: RegistrationState,
user_id: UserID,
}
pub type WebAuthManagerReq = web::Data<Arc<WebAuthManager>>;
pub struct WebAuthManager {
core: Webauthn<WebAuthnAppConfig>,
crypto_wrapper: CryptoWrapper,
}
impl WebAuthManager {
pub fn init(conf: &AppConfig) -> Self {
Self {
core: Webauthn::new(WebAuthnAppConfig {
origin: url::Url::parse(&conf.website_origin)
.expect("Failed to parse configuration origin!"),
relying_party_id: conf.domain_name().to_string(),
}),
crypto_wrapper: CryptoWrapper::new_random(),
}
}
pub fn start_register(&self, user: &User) -> Res<RegisterKeyRequest> {
let (creation_challenge, registration_state) = self.core.generate_challenge_register(
&user.username,
true,
)?;
Ok(RegisterKeyRequest {
opaque_state: self.crypto_wrapper.encrypt(&RegisterKeyOpaqueData {
registration_state,
user_id: user.uid.clone(),
})?,
creation_challenge,
})
}
}

View File

@ -1,3 +1,5 @@
use std::sync::Arc;
use actix::Actor;
use actix_identity::{CookieIdentityPolicy, IdentityService};
use actix_web::{App, get, HttpResponse, HttpServer, web};
@ -17,6 +19,7 @@ use basic_oidc::data::client::ClientManager;
use basic_oidc::data::entity_manager::EntityManager;
use basic_oidc::data::jwt_signer::JWTSigner;
use basic_oidc::data::user::{hash_password, User};
use basic_oidc::data::webauthn_manager::WebAuthManager;
use basic_oidc::middlewares::auth_middleware::AuthMiddleware;
#[get("/health")]
@ -68,6 +71,7 @@ async fn main() -> std::io::Result<()> {
let openid_sessions_actor = OpenIDSessionsActor::default().start();
let jwt_signer = JWTSigner::gen_from_memory()
.expect("Failed to generate JWKS key");
let webauthn_manager = Arc::new(WebAuthManager::init(&config));
log::info!("Server will listen on {}", config.listen_address);
let listen_address = config.listen_address.to_string();
@ -91,6 +95,7 @@ async fn main() -> std::io::Result<()> {
.app_data(web::Data::new(config.clone()))
.app_data(web::Data::new(clients))
.app_data(web::Data::new(jwt_signer.clone()))
.app_data(web::Data::new(webauthn_manager.clone()))
.wrap(Logger::default())
.wrap(AuthMiddleware {})
@ -124,6 +129,7 @@ async fn main() -> std::io::Result<()> {
.route("/settings/change_password", web::post().to(settings_controller::change_password_route))
.route("/settings/two_factors", web::get().to(two_factors_controller::two_factors_route))
.route("/settings/two_factors/add_totp", web::get().to(two_factors_controller::add_totp_factor_route))
.route("/settings/two_factors/add_webauthn", web::get().to(two_factors_controller::add_webauthn_factor_route))
// User API
.route("/settings/api/two_factor/save_totp_factor", web::post().to(two_factor_api::save_totp_factor))

View File

@ -0,0 +1,14 @@
{% extends "base_settings_page.html" %}
{% block content %}
<div style="max-width: 700px;">
<p>In order, to continue, please insert your security key & approve the registration request.</p>
<script>
const OPAQUE_STATE = "{{ opaque_state }}";
const REGISTRATION_CHALLENGE = JSON.parse(decodeURIComponent("{{ challenge_json }}"));
</script>
</div>
{% endblock content %}

View File

@ -12,6 +12,7 @@
<p>
<a href="/settings/two_factors/add_totp" type="button" class="btn btn-primary">Add Authenticator App</a>
<a href="/settings/two_factors/add_webauthn" type="button" class="btn btn-primary">Add Security Key</a>
</p>
<table class="table table-hover" style="max-width: 800px;" aria-describedby="Factors list">