From 49716a8bf57e0bde0c6edc48dd0ea642a029b1a7 Mon Sep 17 00:00:00 2001 From: Pierre Hubert Date: Thu, 21 Apr 2022 19:24:26 +0200 Subject: [PATCH] Register user security keys --- assets/js/base64_lib.js | 243 ++++++++++++++++++++++ src/controllers/two_factor_api.rs | 39 ++++ src/data/user.rs | 8 +- src/data/webauthn_manager.rs | 29 ++- src/main.rs | 1 + templates/settings/add_webauthn_page.html | 74 ++++++- 6 files changed, 387 insertions(+), 7 deletions(-) create mode 100644 assets/js/base64_lib.js diff --git a/assets/js/base64_lib.js b/assets/js/base64_lib.js new file mode 100644 index 0000000..588f88e --- /dev/null +++ b/assets/js/base64_lib.js @@ -0,0 +1,243 @@ +// From : https://gitlab.com/comunic/comunicconsole/-/raw/master/src/utils/Base64Lib.ts + +/* +MIT License + +Copyright (c) 2020 Egor Nepomnyaschih + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +/* +// This constant can also be computed with the following algorithm: +const base64abc = [], + A = "A".charCodeAt(0), + a = "a".charCodeAt(0), + n = "0".charCodeAt(0); +for (let i = 0; i < 26; ++i) { + base64abc.push(String.fromCharCode(A + i)); +} +for (let i = 0; i < 26; ++i) { + base64abc.push(String.fromCharCode(a + i)); +} +for (let i = 0; i < 10; ++i) { + base64abc.push(String.fromCharCode(n + i)); +} +base64abc.push("+"); +base64abc.push("/"); +*/ +const base64abc = [ + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "+", + "/", +]; + +/* +// This constant can also be computed with the following algorithm: +const l = 256, base64codes = new Uint8Array(l); +for (let i = 0; i < l; ++i) { + base64codes[i] = 255; // invalid character +} +base64abc.forEach((char, index) => { + base64codes[char.charCodeAt(0)] = index; +}); +base64codes["=".charCodeAt(0)] = 0; // ignored anyway, so we just need to prevent an error +*/ +const base64codes = [ + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 62, 255, + 255, 255, 63, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 255, 255, 255, 0, 255, + 255, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, + 19, 20, 21, 22, 23, 24, 25, 255, 255, 255, 255, 255, 255, 26, 27, 28, 29, + 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, + 49, 50, 51, +]; + +function getBase64Code(charCode) { + if (charCode >= base64codes.length) { + throw new Error("Unable to parse base64 string."); + } + const code = base64codes[charCode]; + if (code === 255) { + throw new Error("Unable to parse base64 string."); + } + return code; +} + +function bytesToBase64(bytes) { + let result = "", + i, + l = bytes.length; + for (i = 2; i < l; i += 3) { + result += base64abc[bytes[i - 2] >> 2]; + result += base64abc[((bytes[i - 2] & 0x03) << 4) | (bytes[i - 1] >> 4)]; + result += base64abc[((bytes[i - 1] & 0x0f) << 2) | (bytes[i] >> 6)]; + result += base64abc[bytes[i] & 0x3f]; + } + if (i === l + 1) { + // 1 octet yet to write + result += base64abc[bytes[i - 2] >> 2]; + result += base64abc[(bytes[i - 2] & 0x03) << 4]; + result += "=="; + } + if (i === l) { + // 2 octets yet to write + result += base64abc[bytes[i - 2] >> 2]; + result += base64abc[((bytes[i - 2] & 0x03) << 4) | (bytes[i - 1] >> 4)]; + result += base64abc[(bytes[i - 1] & 0x0f) << 2]; + result += "="; + } + return result; +} + +function base64ToBytes(str) { + if (str.length % 4 !== 0) { + throw new Error("Unable to parse base64 string."); + } + const index = str.indexOf("="); + if (index !== -1 && index < str.length - 2) { + throw new Error("Unable to parse base64 string."); + } + let missingOctets = str.endsWith("==") ? 2 : str.endsWith("=") ? 1 : 0, + n = str.length, + result = new Uint8Array(3 * (n / 4)), + buffer; + for (let i = 0, j = 0; i < n; i += 4, j += 3) { + buffer = + (getBase64Code(str.charCodeAt(i)) << 18) | + (getBase64Code(str.charCodeAt(i + 1)) << 12) | + (getBase64Code(str.charCodeAt(i + 2)) << 6) | + getBase64Code(str.charCodeAt(i + 3)); + result[j] = buffer >> 16; + result[j + 1] = (buffer >> 8) & 0xff; + result[j + 2] = buffer & 0xff; + } + return result.subarray(0, result.length - missingOctets); +} + +function base64encode(str, encoder = new TextEncoder()) { + return bytesToBase64(encoder.encode(str)); +} + +function base64decode(str, decoder = new TextDecoder()) { + return decoder.decode(base64ToBytes(str)); +} + + + +// From: https://gitlab.com/comunic/comunicconsole/-/raw/master/src/utils/Base64Utils.ts + +/** + * Add padding to base64 string + * + * Based on : https://gist.github.com/catwell/3046205 + * + * @param input Input base64, without padding + */ +function base64AddPadding(input) { + const remainder = input.length % 4; + + if (remainder === 2) input += "=="; + else if (remainder === 3) input += "="; + + return input.replaceAll("-", "+").replaceAll("_", "/"); +} + +/** + * Turn a base64 string without padding into Uint8Array + * + * @param input Input base64 (without padding) string + */ +function base64NoPaddingToUint8Array(input) { + return Uint8Array.from(atob(base64AddPadding(input)), (c) => + c.charCodeAt(0) + ); +} + +/** + * Convert a buffer to a base64-encoded string + * + * @param buff Buffer to convert + */ +function ArrayBufferToBase64(buff) { + const arr = new Uint8Array(buff); + return bytesToBase64(arr); +} \ No newline at end of file diff --git a/src/controllers/two_factor_api.rs b/src/controllers/two_factor_api.rs index c8874b6..239f84f 100644 --- a/src/controllers/two_factor_api.rs +++ b/src/controllers/two_factor_api.rs @@ -1,12 +1,14 @@ use actix::Addr; use actix_web::{HttpResponse, Responder, web}; use uuid::Uuid; +use webauthn_rs::proto::RegisterPublicKeyCredential; use crate::actors::users_actor; use crate::actors::users_actor::UsersActor; use crate::data::current_user::CurrentUser; use crate::data::totp_key::TotpKey; use crate::data::user::{FactorID, TwoFactor, TwoFactorType, User}; +use crate::data::webauthn_manager::WebAuthManagerReq; #[derive(serde::Deserialize)] pub struct AddTOTPRequest { @@ -45,6 +47,43 @@ pub async fn save_totp_factor(user: CurrentUser, form: web::Json } } +#[derive(serde::Deserialize)] +pub struct AddWebauthnRequest { + opaque_state: String, + factor_name: String, + credential: RegisterPublicKeyCredential, +} + +pub async fn save_webauthn_factor(user: CurrentUser, form: web::Json, + users: web::Data>, + manager: WebAuthManagerReq) -> impl Responder { + let key = match manager.finish_registration( + &user, + &form.0.opaque_state, + form.0.credential, + ) { + Ok(k) => k, + Err(e) => { + log::error!("Failed to register security key! {:?}", e); + return HttpResponse::InternalServerError().body("Failed to register key!"); + } + }; + + let mut user = User::from(user); + user.add_factor(TwoFactor { + id: FactorID(Uuid::new_v4().to_string()), + name: form.0.factor_name, + kind: TwoFactorType::WEBAUTHN(key), + }); + let res = users.send(users_actor::UpdateUserRequest(user)).await.unwrap().0; + + if !res { + HttpResponse::InternalServerError().body("Failed to update user information!") + } else { + HttpResponse::Ok().body("Added new factor!") + } +} + #[derive(serde::Deserialize)] pub struct DeleteFactorRequest { id: FactorID, diff --git a/src/data/user.rs b/src/data/user.rs index 8ded161..9f3d983 100644 --- a/src/data/user.rs +++ b/src/data/user.rs @@ -2,6 +2,7 @@ use crate::data::client::ClientID; use crate::data::entity_manager::EntityManager; use crate::data::login_redirect::LoginRedirect; use crate::data::totp_key::TotpKey; +use crate::data::webauthn_manager::WebauthnPubKey; use crate::utils::err::Res; #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] @@ -13,7 +14,7 @@ pub struct FactorID(pub String); #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum TwoFactorType { TOTP(TotpKey), - _OTHER, + WEBAUTHN(WebauthnPubKey), } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] @@ -27,7 +28,7 @@ impl TwoFactor { pub fn type_str(&self) -> &'static str { match self.kind { TwoFactorType::TOTP(_) => "Authenticator app", - _ => unimplemented!() + TwoFactorType::WEBAUTHN(_) => "Security key", } } @@ -35,7 +36,8 @@ impl TwoFactor { match self.kind { TwoFactorType::TOTP(_) => format!("/2fa_otp?id={}&redirect={}", self.id.0, redirect_uri.get_encoded()), - _ => unimplemented!() + TwoFactorType::WEBAUTHN(_) => format!("/2fa_webauthn?id={}&redirect={}", + self.id.0, redirect_uri.get_encoded()), } } } diff --git a/src/data/webauthn_manager.rs b/src/data/webauthn_manager.rs index 2614160..be44013 100644 --- a/src/data/webauthn_manager.rs +++ b/src/data/webauthn_manager.rs @@ -1,8 +1,9 @@ +use std::io::ErrorKind; use std::sync::Arc; use actix_web::web; use webauthn_rs::{RegistrationState, Webauthn, WebauthnConfig}; -use webauthn_rs::proto::CreationChallengeResponse; +use webauthn_rs::proto::{CreationChallengeResponse, Credential, RegisterPublicKeyCredential}; use crate::constants::APP_NAME; use crate::data::app_config::AppConfig; @@ -35,6 +36,11 @@ pub struct RegisterKeyRequest { pub creation_challenge: CreationChallengeResponse, } +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct WebauthnPubKey { + creds: Credential, +} + #[derive(Debug, serde::Serialize, serde::Deserialize)] struct RegisterKeyOpaqueData { registration_state: RegistrationState, @@ -54,7 +60,10 @@ impl WebAuthManager { 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(), + relying_party_id: conf.domain_name().split_once(':') + .map(|s| s.0) + .unwrap_or(conf.domain_name()) + .to_string(), }), crypto_wrapper: CryptoWrapper::new_random(), } @@ -63,7 +72,7 @@ impl WebAuthManager { pub fn start_register(&self, user: &User) -> Res { let (creation_challenge, registration_state) = self.core.generate_challenge_register( &user.username, - true, + false, )?; Ok(RegisterKeyRequest { @@ -74,4 +83,18 @@ impl WebAuthManager { creation_challenge, }) } + + pub fn finish_registration(&self, user: &User, opaque_state: &str, + pub_cred: RegisterPublicKeyCredential) -> Res { + let state: RegisterKeyOpaqueData = self.crypto_wrapper.decrypt(opaque_state)?; + if state.user_id != user.uid { + return Err(Box::new( + std::io::Error::new(ErrorKind::Other, "Invalid user for pubkey!"))); + } + + let res = self.core + .register_credential(&pub_cred, &state.registration_state, |_| Ok(false))?; + + Ok(WebauthnPubKey { creds: res.0 }) + } } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 65468c8..1806614 100644 --- a/src/main.rs +++ b/src/main.rs @@ -133,6 +133,7 @@ async fn main() -> std::io::Result<()> { // User API .route("/settings/api/two_factor/save_totp_factor", web::post().to(two_factor_api::save_totp_factor)) + .route("/settings/api/two_factor/save_webauthn_factor", web::post().to(two_factor_api::save_webauthn_factor)) .route("/settings/api/two_factor/delete_factor", web::post().to(two_factor_api::delete_factor)) // Admin routes diff --git a/templates/settings/add_webauthn_page.html b/templates/settings/add_webauthn_page.html index 15e3ea8..56873fe 100644 --- a/templates/settings/add_webauthn_page.html +++ b/templates/settings/add_webauthn_page.html @@ -2,11 +2,83 @@ {% block content %}
-

In order, to continue, please insert your security key & approve the registration request.

+

In order to continue, please click on the "Start Enrollment" button, insert your security key and approve the + registration request.

+
+ + + Please give a name to your key to identify it more easily later. +
Please give a name to this security key
+
+ + + +