diff --git a/.env.template b/.env.template index 3895a28995..031dee9c11 100644 --- a/.env.template +++ b/.env.template @@ -459,6 +459,8 @@ # SSO_ORGANIZATIONS_TOKEN_PATH=/groups ### Debug only, log all the tokens # SSO_DEBUG_TOKENS=false +## Experimental (running this will make reverting complicated) +# SSO_EXPERIMENTAL_NO_MASTER_PWD=false ######################## ### MFA/2FA settings ### diff --git a/README.md b/README.md index dee3f2c837..e70c6e2a93 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,29 @@ Ex to build with latest: `--build-arg OIDC_WEB_RELEASE="https://github.com/Timsh [Readme](test/oidc/README.md) +## Experimental version + +### Stop storing Master Password hash + +This allow to stop storing the Master password in the server database. +This is a work in progress and released for testing. +Once activated newly created account will no longer store a master password hash, making reverting to a standard VaultWarden instance troublesome. + +#### To activate + + - `SSO_EXPERIMENTAL_NO_MASTER_PWD`: Control the activation of the feature. Default `false`. + +Additionnaly a new web build is available which stop sending the hash cf `experimental` in [Timshel/oidc_web_builds](https://github.com/Timshel/oidc_web_builds/releases) +You'll need to pass an env variable: `-e SSO_FRONTEND='experimental'` (cf [start.sh](docker/start.sh)). + +#### To revert + +You'll first need to run the server without the `experimental` front-end. +\ +You can then go to `Account settings \ Security \ Keys` and trigger the `Change KDF`. +\ +This endpoint is not modified and will save the new master password hash, every user will need to do this to restore a Master password in db. + ## DB Migration ATM The migrations add an independant table `sso_nonce` and a column `invited_by_email` to `users_organizations`. diff --git a/docker/Dockerfile.alpine b/docker/Dockerfile.alpine index b6135c78f9..f3d4633321 100644 --- a/docker/Dockerfile.alpine +++ b/docker/Dockerfile.alpine @@ -42,6 +42,7 @@ ENV DEBIAN_FRONTEND=noninteractive \ # Get all version of the front-end RUN curl -L "${OIDC_WEB_RELEASE}/oidc_button_web_vault.tar.gz" | tar -xz ; mv web-vault /web-vault_button RUN curl -L "${OIDC_WEB_RELEASE}/oidc_override_web_vault.tar.gz" | tar -xz ; mv web-vault /web-vault_override +RUN curl -L "${OIDC_WEB_RELEASE}/oidc_experimental_web_vault.tar.gz" | tar -xz ; mv web-vault /web-vault_experimental # Create CARGO_HOME folder and don't download rust docs RUN mkdir -pv "${CARGO_HOME}" \ diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian index f5a4a579f3..fe3ae57259 100644 --- a/docker/Dockerfile.debian +++ b/docker/Dockerfile.debian @@ -67,6 +67,7 @@ RUN xx-apt-get install -y \ # Get all version of the front-end RUN curl -L "${OIDC_WEB_RELEASE}/oidc_button_web_vault.tar.gz" | tar -xz ; mv web-vault /web-vault_button RUN curl -L "${OIDC_WEB_RELEASE}/oidc_override_web_vault.tar.gz" | tar -xz ; mv web-vault /web-vault_override +RUN curl -L "${OIDC_WEB_RELEASE}/oidc_experimental_web_vault.tar.gz" | tar -xz ; mv web-vault /web-vault_experimental # Create CARGO_HOME folder and don't download rust docs RUN mkdir -pv "${CARGO_HOME}" \ @@ -178,6 +179,7 @@ COPY docker/start.sh /start.sh COPY --from=build /web-vault_button ./web-vault_button COPY --from=build /web-vault_override ./web-vault_override +COPY --from=build /web-vault_experimental ./web-vault_experimental COPY --from=build /app/target/final/vaultwarden . HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"] diff --git a/docker/Dockerfile.j2 b/docker/Dockerfile.j2 index 8f3a7219db..6be2ab17ca 100644 --- a/docker/Dockerfile.j2 +++ b/docker/Dockerfile.j2 @@ -86,6 +86,7 @@ RUN xx-apt-get install -y \ # Get all version of the front-end RUN curl -L "${OIDC_WEB_RELEASE}/oidc_button_web_vault.tar.gz" | tar -xz ; mv web-vault /web-vault_button RUN curl -L "${OIDC_WEB_RELEASE}/oidc_override_web_vault.tar.gz" | tar -xz ; mv web-vault /web-vault_override +RUN curl -L "${OIDC_WEB_RELEASE}/oidc_experimental_web_vault.tar.gz" | tar -xz ; mv web-vault /web-vault_experimental # Create CARGO_HOME folder and don't download rust docs RUN mkdir -pv "${CARGO_HOME}" \ @@ -221,6 +222,7 @@ COPY docker/start.sh /start.sh COPY --from=build /web-vault_button ./web-vault_button COPY --from=build /web-vault_override ./web-vault_override +COPY --from=build /web-vault_experimental ./web-vault_experimental COPY --from=build /app/target/final/vaultwarden . HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"] diff --git a/docker/start.sh b/docker/start.sh index 2d29a45cc0..012703f0fe 100755 --- a/docker/start.sh +++ b/docker/start.sh @@ -26,6 +26,9 @@ rm -f /web-vault if [ "$SSO_FRONTEND" = "override" ] ; then echo "### Running web-vault frontend with SSO override ###" ln -s /web-vault_override /web-vault +elif [ "$SSO_FRONTEND" = "experimental" ] ; then + echo "### Running web-vault frontend with SSO experimental ###" + ln -s /web-vault_experimental /web-vault else echo "### Running web-vault frontend with SSO button ###" ln -s /web-vault_button /web-vault diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index fb1ed845ce..e6b1d8bd4e 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -90,7 +90,7 @@ pub struct SetPasswordData { KdfParallelism: Option, Key: String, Keys: Option, - MasterPasswordHash: String, + MasterPasswordHash: Option, MasterPasswordHint: Option, #[allow(dead_code)] OrgIdentifier: Option, @@ -201,7 +201,7 @@ pub async fn _register(data: JsonUpcase, mut conn: DbConn) -> Json user.client_kdf_memory = data.KdfMemory; user.client_kdf_parallelism = data.KdfParallelism; - user.set_password(&data.MasterPasswordHash, Some(data.Key), true, None); + user.set_password(&data.MasterPasswordHash, true, None); user.password_hint = password_hint; // Add extra fields if present @@ -209,6 +209,7 @@ pub async fn _register(data: JsonUpcase, mut conn: DbConn) -> Json user.name = name; } + user.akey = data.Key; if let Some(keys) = data.Keys { user.private_key = Some(keys.EncryptedPrivateKey); user.public_key = Some(keys.PublicKey); @@ -238,10 +239,27 @@ async fn post_set_password(data: JsonUpcase, headers: Headers, let data: SetPasswordData = data.into_inner().data; let mut user = headers.user; - // Check against the password hint setting here so if it fails, the user - // can retry without losing their invitation below. - let password_hint = clean_password_hint(&data.MasterPasswordHint); - enforce_password_hint_setting(&password_hint)?; + if !CONFIG.sso_enabled() || !CONFIG.sso_experimental_no_master_pwd() { + if let Some(password_hash) = data.MasterPasswordHash { + // Check against the password hint setting here so if it fails, the user + // can retry without losing their invitation below. + let password_hint = clean_password_hint(&data.MasterPasswordHint); + enforce_password_hint_setting(&password_hint)?; + + // We need to allow revision-date to use the old security_timestamp + let routes = ["revision_date"]; + let routes: Option> = Some(routes.iter().map(ToString::to_string).collect()); + + user.set_password(&password_hash, false, routes); + user.password_hint = password_hint; + + if CONFIG.mail_enabled() { + mail::send_set_password(&user.email.to_lowercase(), &user.name).await?; + } + } else { + err_code!("Missing password hash", Status::UnprocessableEntity.code) + } + } if let Some(client_kdf_iter) = data.KdfIterations { user.client_kdf_iter = client_kdf_iter; @@ -251,25 +269,15 @@ async fn post_set_password(data: JsonUpcase, headers: Headers, user.client_kdf_type = client_kdf_type; } - // We need to allow revision-date to use the old security_timestamp - let routes = ["revision_date"]; - let routes: Option> = Some(routes.iter().map(ToString::to_string).collect()); - user.client_kdf_memory = data.KdfMemory; user.client_kdf_parallelism = data.KdfParallelism; - user.set_password(&data.MasterPasswordHash, Some(data.Key), false, routes); - user.password_hint = password_hint; - + user.akey = data.Key; if let Some(keys) = data.Keys { user.private_key = Some(keys.EncryptedPrivateKey); user.public_key = Some(keys.PublicKey); } - if CONFIG.mail_enabled() { - mail::send_set_password(&user.email.to_lowercase(), &user.name).await?; - } - user.save(&mut conn).await?; Ok(Json(json!({ "Object": "set-password", @@ -374,8 +382,8 @@ async fn post_keys(data: JsonUpcase, headers: Headers, mut conn: DbCon #[derive(Deserialize)] #[allow(non_snake_case)] struct ChangePassData { - MasterPasswordHash: String, - NewMasterPasswordHash: String, + MasterPasswordHash: Option, + NewMasterPasswordHash: Option, MasterPasswordHint: Option, Key: String, } @@ -390,22 +398,37 @@ async fn post_password( let data: ChangePassData = data.into_inner().data; let mut user = headers.user; - if !user.check_valid_password(&data.MasterPasswordHash) { - err!("Invalid password") - } - user.password_hint = clean_password_hint(&data.MasterPasswordHint); enforce_password_hint_setting(&user.password_hint)?; + if !CONFIG.sso_enabled() || !CONFIG.sso_experimental_no_master_pwd() { + if let Some(password_hash) = data.MasterPasswordHash { + if !user.check_valid_password(&password_hash) { + err!("Invalid password") + } + } else { + err_code!("Missing password hash", Status::UnprocessableEntity.code) + } + + if let Some(new_password_hash) = data.NewMasterPasswordHash { + user.set_password( + &new_password_hash, + true, + Some(vec![ + String::from("post_rotatekey"), + String::from("get_contacts"), + String::from("get_public_keys"), + ]), + ); + } else { + err_code!("Missing password hash", Status::UnprocessableEntity.code) + } + } + log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &mut conn) .await; - user.set_password( - &data.NewMasterPasswordHash, - Some(data.Key), - true, - Some(vec![String::from("post_rotatekey"), String::from("get_contacts"), String::from("get_public_keys")]), - ); + user.akey = data.Key; let save_result = user.save(&mut conn).await; @@ -469,7 +492,8 @@ async fn post_kdf(data: JsonUpcase, headers: Headers, mut conn: D } user.client_kdf_iter = data.KdfIterations; user.client_kdf_type = data.Kdf; - user.set_password(&data.NewMasterPasswordHash, Some(data.Key), true, None); + user.akey = data.Key; + user.set_password(&data.NewMasterPasswordHash, true, None); let save_result = user.save(&mut conn).await; nt.send_logout(&user, Some(headers.device.uuid)).await; @@ -687,7 +711,8 @@ async fn post_email( user.email_new = None; user.email_new_token = None; - user.set_password(&data.NewMasterPasswordHash, Some(data.Key), true, None); + user.akey = data.Key; + user.set_password(&data.NewMasterPasswordHash, true, None); let save_result = user.save(&mut conn).await; @@ -905,7 +930,7 @@ struct SecretVerificationRequest { pub async fn kdf_upgrade(user: &mut User, pwd_hash: &str, conn: &mut DbConn) -> ApiResult<()> { if user.password_iterations != CONFIG.password_iterations() { user.password_iterations = CONFIG.password_iterations(); - user.set_password(pwd_hash, None, false, None); + user.set_password(pwd_hash, false, None); if let Err(e) = user.save(conn).await { error!("Error updating user: {:#?}", e); @@ -923,11 +948,13 @@ async fn verify_password( let data: SecretVerificationRequest = data.into_inner().data; let mut user = headers.user; - if !user.check_valid_password(&data.MasterPasswordHash) { - err!("Invalid password") - } + if !CONFIG.sso_enabled() || !CONFIG.sso_experimental_no_master_pwd() { + if !user.check_valid_password(&data.MasterPasswordHash) { + err!("Invalid password") + } - kdf_upgrade(&mut user, &data.MasterPasswordHash, &mut conn).await?; + kdf_upgrade(&mut user, &data.MasterPasswordHash, &mut conn).await?; + } Ok(Json(json!({ "MasterPasswordPolicy": {}, // Required for SSO login with mobile apps diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index 6416101153..e0b316c370 100644 --- a/src/api/core/emergency_access.rs +++ b/src/api/core/emergency_access.rs @@ -687,7 +687,8 @@ async fn password_emergency_access( }; // change grantor_user password - grantor_user.set_password(new_master_password_hash, Some(data.Key), true, None); + grantor_user.akey = data.Key; + grantor_user.set_password(new_master_password_hash, true, None); grantor_user.save(&mut conn).await?; // Disable TwoFactor providers since they will otherwise block logins diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 4d464c87d9..35f92007d7 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -2780,7 +2780,8 @@ async fn put_reset_password( let reset_request = data.into_inner().data; let mut user = user; - user.set_password(reset_request.NewMasterPasswordHash.as_str(), Some(reset_request.Key), true, None); + user.akey = reset_request.Key; + user.set_password(reset_request.NewMasterPasswordHash.as_str(), true, None); user.save(&mut conn).await?; nt.send_logout(&user, None).await; diff --git a/src/api/identity.rs b/src/api/identity.rs index 2b0d44c7b2..3276a77f58 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -202,7 +202,7 @@ async fn _sso_login( (user, device, new_device, None) } - Some((mut user, device, new_device, twofactor_token)) if user.password_hash.is_empty() => { + Some((mut user, device, new_device, twofactor_token)) if user.public_key.is_none() => { user.verified_at = Some(now); if let Some(user_name) = user_infos.user_name { user.name = user_name; @@ -393,7 +393,7 @@ async fn authenticated_response( "scope": auth_tokens.scope(), "unofficialServer": true, "UserDecryptionOptions": { - "HasMasterPassword": !user.password_hash.is_empty(), + "HasMasterPassword": user.public_key.is_some(), "Object": "userDecryptionOptions" }, }); diff --git a/src/business/organization_logic.rs b/src/business/organization_logic.rs index aa47bd6263..5cf94481bf 100644 --- a/src/business/organization_logic.rs +++ b/src/business/organization_logic.rs @@ -22,7 +22,7 @@ pub async fn invite( let mut user_org_status = UserOrgStatus::Invited; // automatically accept existing users if mail is disabled - if !user.password_hash.is_empty() && !CONFIG.mail_enabled() { + if user.public_key.is_some() && !CONFIG.mail_enabled() { user_org_status = UserOrgStatus::Accepted; } diff --git a/src/config.rs b/src/config.rs index 6fabaca2e6..04d883b236 100644 --- a/src/config.rs +++ b/src/config.rs @@ -649,6 +649,8 @@ make_config! { sso_organizations_token_path: String, true, def, "/groups".to_string(); /// Log all tokens sso_debug_tokens: bool, true, def, false; + /// Deactivate saving the master password + sso_experimental_no_master_pwd: bool, true, def, false; }, /// Yubikey settings @@ -861,6 +863,10 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { err!("`SSO_CLIENT_ID`, `SSO_CLIENT_SECRET` and `SSO_AUTHORITY` must be set for SSO support") } + if !cfg.sso_only && cfg.sso_experimental_no_master_pwd { + warn!("`sso_only` should be activated when running `sso_experimental_no_master_pwd`") + } + internal_sso_issuer_url(&cfg.sso_authority)?; internal_sso_redirect_url(&cfg.sso_callback_path)?; check_master_password_policy(&cfg.sso_master_password_policy)?; diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 4d38388c2e..ccb7802397 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -165,23 +165,13 @@ impl User { /// These routes are able to use the previous stamp id for the next 2 minutes. /// After these 2 minutes this stamp will expire. /// - pub fn set_password( - &mut self, - password: &str, - new_key: Option, - reset_security_stamp: bool, - allow_next_route: Option>, - ) { + pub fn set_password(&mut self, password: &str, reset_security_stamp: bool, allow_next_route: Option>) { self.password_hash = crypto::hash_password(password.as_bytes(), &self.salt, self.password_iterations as u32); if let Some(route) = allow_next_route { self.set_stamp_exception(route); } - if let Some(new_key) = new_key { - self.akey = new_key; - } - if reset_security_stamp { self.reset_security_stamp() }