From 0aa7f3aedf7c49c68b116a7c57fdd1bcc2acef6a Mon Sep 17 00:00:00 2001 From: cs4dev Date: Wed, 1 Oct 2025 09:30:50 +0800 Subject: [PATCH] feature: automated onboarding + confirmation --- Cargo.lock | 10 ++++ Cargo.toml | 4 ++ macros/Cargo.toml | 6 +++ src/api/core/organizations.rs | 87 +++++++++++++++++++++++++++++++++-- src/config.rs | 4 +- src/crypto.rs | 41 +++++++++++++++++ 6 files changed, 147 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 39a3d942..21668ec0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2904,7 +2904,13 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" name = "macros" version = "0.1.0" dependencies = [ + "base64 0.22.1", + "hex", + "pkcs8", "quote", + "rand 0.8.5", + "rsa", + "sha1", "syn", ] @@ -5701,6 +5707,7 @@ dependencies = [ "aws-config", "aws-credential-types", "aws-smithy-runtime-api", + "base64 0.22.1", "bigdecimal", "bytes", "cached", @@ -5723,6 +5730,7 @@ dependencies = [ "governor", "grass_compiler", "handlebars", + "hex", "hickory-resolver", "html5gum", "http 1.3.1", @@ -5752,9 +5760,11 @@ dependencies = [ "rocket", "rocket_ws", "rpassword", + "rsa", "semver", "serde", "serde_json", + "sha1", "subtle", "svg-hush", "syslog", diff --git a/Cargo.toml b/Cargo.toml index c62bc929..e1f2135e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,6 +99,10 @@ libsqlite3-sys = { version = "0.35.0", features = ["bundled"], optional = true } rand = "0.9.2" ring = "0.17.14" subtle = "2.6.1" +rsa = "0.9" +sha1 = "0.10" +hex = "0.4" +base64 = "0.22" # UUID generation uuid = { version = "1.18.0", features = ["v4"] } diff --git a/macros/Cargo.toml b/macros/Cargo.toml index 34e4ae04..cf331ec9 100644 --- a/macros/Cargo.toml +++ b/macros/Cargo.toml @@ -11,6 +11,12 @@ proc-macro = true [dependencies] quote = "1.0.40" syn = "2.0.105" +rsa = "0.9" +rand = "0.8" +sha1 = "0.10" +base64 = "0.22" +hex = "0.4" +pkcs8 = "0.10" [lints] workspace = true diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 22712003..a4f5829b 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -15,6 +15,7 @@ use crate::{ mail, util::{convert_json_key_lcase_first, get_uuid, NumberOrString}, CONFIG, + crypto::generate_akey_from_hex, }; pub fn routes() -> Vec { @@ -113,10 +114,11 @@ pub fn routes() -> Vec { #[serde(rename_all = "camelCase")] struct OrgData { billing_email: String, - collection_name: String, + collection_name: Option, key: String, name: String, keys: Option, + okey: Option, #[allow(dead_code)] plan_type: NumberOrString, // Ignored, always use the same plan } @@ -196,7 +198,10 @@ async fn create_organization(headers: Headers, data: Json, mut conn: Db let org = Organization::new(data.name, data.billing_email, private_key, public_key); let mut member = Membership::new(headers.user.uuid, org.uuid.clone(), None); - let collection = Collection::new(org.uuid.clone(), data.collection_name, None); + + if let Some(okey) = data.okey { + member.external_id = Some(okey).clone(); + } member.akey = data.key; member.access_all = true; @@ -205,8 +210,11 @@ async fn create_organization(headers: Headers, data: Json, mut conn: Db org.save(&mut conn).await?; member.save(&mut conn).await?; - collection.save(&mut conn).await?; + if let Some(collection_name) = data.collection_name { + let collection = Collection::new(org.uuid.clone(), collection_name, None); + collection.save(&mut conn).await?; + } Ok(Json(org.to_json())) } @@ -1357,7 +1365,7 @@ async fn accept_invite( }; // In case the user was invited before the mail was saved in db. - member.invited_by_email = member.invited_by_email.or(claims.invited_by_email); + member.invited_by_email = member.invited_by_email.or(claims.invited_by_email.clone()); accept_org_invite(&headers.user, member, reset_password_key, &mut conn).await?; } else if CONFIG.mail_enabled() { @@ -1366,6 +1374,77 @@ async fn accept_invite( mail::send_invite_confirmed(&claims.email, &org_name).await?; } + // Check if invitation comes from the configured email and auto-confirm + if let Some(config_invited_email) = CONFIG.invited_email() { + let invited_by_verified = claims.invited_by_email.as_ref() + .map(|invited_by| invited_by == &config_invited_email) + .unwrap_or(false); + + info!("invited_by_verified: {:?}", invited_by_verified); + // Auto-confirm user if invited by verified email + if **member_id != FAKE_ADMIN_UUID && invited_by_verified { + if let Some(mut member) = Membership::find_by_uuid_and_org(member_id, &claims.org_id, &mut conn).await { + if member.status == MembershipStatus::Accepted as i32 { + // Set status to confirmed and use akey from existing member with same email + member.status = MembershipStatus::Confirmed as i32; + + info!("config_invited_email: {:?}", &config_invited_email); + // Find existing member with the invited email to get their akey + let existing_akey = if let Some(existing_user) = User::find_by_mail(&config_invited_email, &mut conn).await { + info!("existing_user.uuid: {:?}", existing_user.uuid); + let invited_user_public_key = if let Some(existing_user) = User::find_by_uuid(&member.user_uuid, &mut conn).await { + existing_user.public_key + } else { + None + }; + info!("invited_user_public_key: {:?}", invited_user_public_key); + if let Some(existing_member) = Membership::find_confirmed_by_user_and_org(&existing_user.uuid, &claims.org_id, &mut conn).await { + info!("existing_member.external_id: {:?}", existing_member.external_id); + let akey = if let (Some(external_id), Some(public_key)) = (existing_member.external_id.as_deref(), invited_user_public_key.as_deref()) { + Some(generate_akey_from_hex(external_id, public_key)) + } else { + None + }; + // Delete the existing member to avoid duplicates + existing_member.delete(&mut conn).await?; + info!("Deleted existing member for user: {}", existing_user.uuid); + akey + } else { + None + } + } else { + None + }; + + member.akey = existing_akey.unwrap_or_default(); + + // Log the confirmation event + log_event( + EventType::OrganizationUserConfirmed as i32, + &member.uuid, + &claims.org_id, + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + &mut conn, + ).await; + + member.save(&mut conn).await?; + + info!("User auto-confirmed due to verified invited_email"); + // Send confirmation email if enabled + if CONFIG.mail_enabled() { + let org_name = match Organization::find_by_uuid(&claims.org_id, &mut conn).await { + Some(org) => org.name, + None => "Organization".to_string(), + }; + mail::send_invite_confirmed(&claims.email, &org_name).await?; + } + } + } + } + } + Ok(()) } diff --git a/src/config.rs b/src/config.rs index 116c9096..f095a69a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -685,7 +685,9 @@ make_config! { /// Enforce Single Org with Reset Password Policy |> Enforce that the Single Org policy is enabled before setting the Reset Password policy /// Bitwarden enforces this by default. In Vaultwarden we encouraged to use multiple organizations because groups were not available. /// Setting this to true will enforce the Single Org Policy to be enabled before you can enable the Reset Password policy. - enforce_single_org_with_reset_pw_policy: bool, false, def, false; + enforce_single_org_with_reset_pw_policy: bool, false, def, false; + /// Invited Email |> Email address to verify invitations against + invited_email: String, true, option; }, /// OpenID Connect SSO settings diff --git a/src/crypto.rs b/src/crypto.rs index ada0a26a..0f6b0a87 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -9,6 +9,11 @@ use ring::{digest, hmac, pbkdf2}; const DIGEST_ALG: pbkdf2::Algorithm = pbkdf2::PBKDF2_HMAC_SHA256; const OUTPUT_LEN: usize = digest::SHA256_OUTPUT_LEN; +use rsa::{RsaPublicKey, Oaep}; +use rsa::pkcs8::DecodePublicKey; +use sha1::Sha1; +use base64::{Engine as _, engine::general_purpose::STANDARD}; + pub fn hash_password(secret: &[u8], salt: &[u8], iterations: u32) -> Vec { let mut out = vec![0u8; OUTPUT_LEN]; // Initialize array with zeros @@ -113,3 +118,39 @@ pub fn ct_eq, U: AsRef<[u8]>>(a: T, b: U) -> bool { use subtle::ConstantTimeEq; a.as_ref().ct_eq(b.as_ref()).into() } + +pub fn generate_akey_from_hex(org_key_hex: &str, user_public_key_pem: &str) -> String { + // Convert hex → raw bytes + let org_key_bytes = hex::decode(org_key_hex) + .expect("Invalid hex for org key"); + + // Parse PEM public key - add headers if missing and format properly + let formatted_pem = if user_public_key_pem.starts_with("-----BEGIN") { + user_public_key_pem.to_string() + } else { + // Split base64 content into 64-character lines for proper PEM format + let base64_lines: Vec = user_public_key_pem + .chars() + .collect::>() + .chunks(64) + .map(|chunk| chunk.iter().collect()) + .collect(); + + format!("-----BEGIN PUBLIC KEY-----\n{}\n-----END PUBLIC KEY-----", + base64_lines.join("\n")) + }; + + println!("formatted_pem: {:?}", formatted_pem); + + let public_key = RsaPublicKey::from_public_key_pem(&formatted_pem) + .expect("Invalid public key PEM"); + + // Encrypt with RSA-OAEP using SHA1 (Vaultwarden convention for version 4) + let padding = Oaep::new::(); + let mut rng = rsa::rand_core::OsRng; + let encrypted = public_key.encrypt(&mut rng, padding, &org_key_bytes) + .expect("Encryption failed"); + + // Base64 encode and prefix with version "4." + format!("4.{}", STANDARD.encode(encrypted)) +} \ No newline at end of file