diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 6c19049c49..2555a6fe2d 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -355,6 +355,7 @@ public class AccountsController : Controller throw new BadRequestException(ModelState); } + [Obsolete("Replaced by the safer rotate-user-account-keys endpoint.")] [HttpPost("key")] public async Task PostKey([FromBody] UpdateKeyRequestModel model) { diff --git a/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs b/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs new file mode 100644 index 0000000000..ba57788cec --- /dev/null +++ b/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs @@ -0,0 +1,66 @@ +#nullable enable + +using System.ComponentModel.DataAnnotations; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Utilities; + +namespace Bit.Api.Auth.Models.Request.Accounts; + +public class MasterPasswordUnlockDataModel : IValidatableObject +{ + public required KdfType KdfType { get; set; } + public required int KdfIterations { get; set; } + public int? KdfMemory { get; set; } + public int? KdfParallelism { get; set; } + + [StrictEmailAddress] + [StringLength(256)] + public required string Email { get; set; } + [StringLength(300)] + public required string MasterKeyAuthenticationHash { get; set; } + [EncryptedString] public required string MasterKeyEncryptedUserKey { get; set; } + [StringLength(50)] + public string? MasterPasswordHint { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (KdfType == KdfType.PBKDF2_SHA256) + { + if (KdfMemory.HasValue || KdfParallelism.HasValue) + { + yield return new ValidationResult("KdfMemory and KdfParallelism must be null for PBKDF2_SHA256", new[] { nameof(KdfMemory), nameof(KdfParallelism) }); + } + } + else if (KdfType == KdfType.Argon2id) + { + if (!KdfMemory.HasValue || !KdfParallelism.HasValue) + { + yield return new ValidationResult("KdfMemory and KdfParallelism must have values for Argon2id", new[] { nameof(KdfMemory), nameof(KdfParallelism) }); + } + } + else + { + yield return new ValidationResult("Invalid KdfType", new[] { nameof(KdfType) }); + } + } + + public MasterPasswordUnlockData ToUnlockData() + { + var data = new MasterPasswordUnlockData + { + KdfType = KdfType, + KdfIterations = KdfIterations, + KdfMemory = KdfMemory, + KdfParallelism = KdfParallelism, + + Email = Email, + + MasterKeyAuthenticationHash = MasterKeyAuthenticationHash, + MasterKeyEncryptedUserKey = MasterKeyEncryptedUserKey, + MasterPasswordHint = MasterPasswordHint + }; + return data; + } + +} diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index b8d5e30949..85e0981f22 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -1,10 +1,23 @@ #nullable enable +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.Auth.Models.Request; +using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Api.KeyManagement.Models.Requests; +using Bit.Api.KeyManagement.Validators; +using Bit.Api.Tools.Models.Request; +using Bit.Api.Vault.Models.Request; using Bit.Core; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Commands.Interfaces; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.UserKey; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Tools.Entities; +using Bit.Core.Vault.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -19,18 +32,45 @@ public class AccountsKeyManagementController : Controller private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IRegenerateUserAsymmetricKeysCommand _regenerateUserAsymmetricKeysCommand; private readonly IUserService _userService; + private readonly IRotateUserAccountKeysCommand _rotateUserAccountKeysCommand; + private readonly IRotationValidator, IEnumerable> _cipherValidator; + private readonly IRotationValidator, IEnumerable> _folderValidator; + private readonly IRotationValidator, IReadOnlyList> _sendValidator; + private readonly IRotationValidator, IEnumerable> + _emergencyAccessValidator; + private readonly IRotationValidator, + IReadOnlyList> + _organizationUserValidator; + private readonly IRotationValidator, IEnumerable> + _webauthnKeyValidator; public AccountsKeyManagementController(IUserService userService, IFeatureService featureService, IOrganizationUserRepository organizationUserRepository, IEmergencyAccessRepository emergencyAccessRepository, - IRegenerateUserAsymmetricKeysCommand regenerateUserAsymmetricKeysCommand) + IRegenerateUserAsymmetricKeysCommand regenerateUserAsymmetricKeysCommand, + IRotateUserAccountKeysCommand rotateUserKeyCommandV2, + IRotationValidator, IEnumerable> cipherValidator, + IRotationValidator, IEnumerable> folderValidator, + IRotationValidator, IReadOnlyList> sendValidator, + IRotationValidator, IEnumerable> + emergencyAccessValidator, + IRotationValidator, IReadOnlyList> + organizationUserValidator, + IRotationValidator, IEnumerable> webAuthnKeyValidator) { _userService = userService; _featureService = featureService; _regenerateUserAsymmetricKeysCommand = regenerateUserAsymmetricKeysCommand; _organizationUserRepository = organizationUserRepository; _emergencyAccessRepository = emergencyAccessRepository; + _rotateUserAccountKeysCommand = rotateUserKeyCommandV2; + _cipherValidator = cipherValidator; + _folderValidator = folderValidator; + _sendValidator = sendValidator; + _emergencyAccessValidator = emergencyAccessValidator; + _organizationUserValidator = organizationUserValidator; + _webauthnKeyValidator = webAuthnKeyValidator; } [HttpPost("regenerate-keys")] @@ -47,4 +87,45 @@ public class AccountsKeyManagementController : Controller await _regenerateUserAsymmetricKeysCommand.RegenerateKeysAsync(request.ToUserAsymmetricKeys(user.Id), usersOrganizationAccounts, designatedEmergencyAccess); } + + + [HttpPost("rotate-user-account-keys")] + public async Task RotateUserAccountKeysAsync([FromBody] RotateUserAccountKeysAndDataRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + var dataModel = new RotateUserAccountKeysData + { + OldMasterKeyAuthenticationHash = model.OldMasterKeyAuthenticationHash, + + UserKeyEncryptedAccountPrivateKey = model.AccountKeys.UserKeyEncryptedAccountPrivateKey, + AccountPublicKey = model.AccountKeys.AccountPublicKey, + + MasterPasswordUnlockData = model.AccountUnlockData.MasterPasswordUnlockData.ToUnlockData(), + EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.AccountUnlockData.EmergencyAccessUnlockData), + OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.AccountUnlockData.OrganizationAccountRecoveryUnlockData), + WebAuthnKeys = await _webauthnKeyValidator.ValidateAsync(user, model.AccountUnlockData.PasskeyUnlockData), + + Ciphers = await _cipherValidator.ValidateAsync(user, model.AccountData.Ciphers), + Folders = await _folderValidator.ValidateAsync(user, model.AccountData.Folders), + Sends = await _sendValidator.ValidateAsync(user, model.AccountData.Sends), + }; + + var result = await _rotateUserAccountKeysCommand.RotateUserAccountKeysAsync(user, dataModel); + if (result.Succeeded) + { + return; + } + + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + throw new BadRequestException(ModelState); + } } diff --git a/src/Api/KeyManagement/Models/Requests/AccountKeysRequestModel.cs b/src/Api/KeyManagement/Models/Requests/AccountKeysRequestModel.cs new file mode 100644 index 0000000000..7c7de4d210 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/AccountKeysRequestModel.cs @@ -0,0 +1,10 @@ +#nullable enable +using Bit.Core.Utilities; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class AccountKeysRequestModel +{ + [EncryptedString] public required string UserKeyEncryptedAccountPrivateKey { get; set; } + public required string AccountPublicKey { get; set; } +} diff --git a/src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs new file mode 100644 index 0000000000..b0b19e2bd3 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs @@ -0,0 +1,13 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class RotateUserAccountKeysAndDataRequestModel +{ + [StringLength(300)] + public required string OldMasterKeyAuthenticationHash { get; set; } + public required UnlockDataRequestModel AccountUnlockData { get; set; } + public required AccountKeysRequestModel AccountKeys { get; set; } + public required AccountDataRequestModel AccountData { get; set; } +} diff --git a/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs new file mode 100644 index 0000000000..5156e2a655 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs @@ -0,0 +1,16 @@ +#nullable enable +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.Auth.Models.Request; +using Bit.Api.Auth.Models.Request.Accounts; +using Bit.Api.Auth.Models.Request.WebAuthn; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class UnlockDataRequestModel +{ + // All methods to get to the userkey + public required MasterPasswordUnlockDataModel MasterPasswordUnlockData { get; set; } + public required IEnumerable EmergencyAccessUnlockData { get; set; } + public required IEnumerable OrganizationAccountRecoveryUnlockData { get; set; } + public required IEnumerable PasskeyUnlockData { get; set; } +} diff --git a/src/Api/KeyManagement/Models/Requests/UserDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/UserDataRequestModel.cs new file mode 100644 index 0000000000..f854d82bcc --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/UserDataRequestModel.cs @@ -0,0 +1,12 @@ +#nullable enable +using Bit.Api.Tools.Models.Request; +using Bit.Api.Vault.Models.Request; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class AccountDataRequestModel +{ + public required IEnumerable Ciphers { get; set; } + public required IEnumerable Folders { get; set; } + public required IEnumerable Sends { get; set; } +} diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index df102c855f..16a0ef9805 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -32,6 +32,7 @@ public static class UserServiceCollectionExtensions public static void AddUserKeyCommands(this IServiceCollection services, IGlobalSettings globalSettings) { services.AddScoped(); + services.AddScoped(); } private static void AddUserPasswordCommands(this IServiceCollection services) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 0ae9f1d8d7..e41391b173 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -168,6 +168,9 @@ public static class FeatureFlagKeys public const string Argon2Default = "argon2-default"; public const string UsePricingService = "use-pricing-service"; public const string RecordInstallationLastActivityDate = "installation-last-activity-date"; + public const string UserkeyRotationV2 = "userkey-rotation-v2"; + public const string EnablePasswordManagerSyncAndroid = "enable-password-manager-sync-android"; + public const string EnablePasswordManagerSynciOS = "enable-password-manager-sync-ios"; public const string AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner"; public const string SingleTapPasskeyCreation = "single-tap-passkey-creation"; public const string SingleTapPasskeyAuthentication = "single-tap-passkey-authentication"; diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs new file mode 100644 index 0000000000..0ddfc03190 --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs @@ -0,0 +1,34 @@ +#nullable enable +using Bit.Core.Entities; +using Bit.Core.Enums; + +namespace Bit.Core.KeyManagement.Models.Data; + +public class MasterPasswordUnlockData +{ + public KdfType KdfType { get; set; } + public int KdfIterations { get; set; } + public int? KdfMemory { get; set; } + public int? KdfParallelism { get; set; } + + public required string Email { get; set; } + public required string MasterKeyAuthenticationHash { get; set; } + public required string MasterKeyEncryptedUserKey { get; set; } + public string? MasterPasswordHint { get; set; } + + public bool ValidateForUser(User user) + { + if (KdfType != user.Kdf || KdfMemory != user.KdfMemory || KdfParallelism != user.KdfParallelism || KdfIterations != user.KdfIterations) + { + return false; + } + else if (Email != user.Email) + { + return false; + } + else + { + return true; + } + } +} diff --git a/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs b/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs new file mode 100644 index 0000000000..7cb1c273a3 --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs @@ -0,0 +1,28 @@ +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Entities; +using Bit.Core.Tools.Entities; +using Bit.Core.Vault.Entities; + +namespace Bit.Core.KeyManagement.Models.Data; + +public class RotateUserAccountKeysData +{ + // Authentication for this requests + public string OldMasterKeyAuthenticationHash { get; set; } + + // Other keys encrypted by the userkey + public string UserKeyEncryptedAccountPrivateKey { get; set; } + public string AccountPublicKey { get; set; } + + // All methods to get to the userkey + public MasterPasswordUnlockData MasterPasswordUnlockData { get; set; } + public IEnumerable EmergencyAccesses { get; set; } + public IReadOnlyList OrganizationUsers { get; set; } + public IEnumerable WebAuthnKeys { get; set; } + + // User vault data encrypted by the userkey + public IEnumerable Ciphers { get; set; } + public IEnumerable Folders { get; set; } + public IReadOnlyList Sends { get; set; } +} diff --git a/src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs b/src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs new file mode 100644 index 0000000000..ec40e7031d --- /dev/null +++ b/src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs @@ -0,0 +1,20 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.KeyManagement.UserKey; + +/// +/// Responsible for rotation of a user key and updating database with re-encrypted data +/// +public interface IRotateUserAccountKeysCommand +{ + /// + /// Sets a new user key and updates all encrypted data. + /// + /// All necessary information for rotation. If data is not included, this will lead to the change being rejected. + /// An IdentityResult for verification of the master password hash + /// User must be provided. + /// User KDF settings and email must match the model provided settings. + Task RotateUserAccountKeysAsync(User user, RotateUserAccountKeysData model); +} diff --git a/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs b/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs new file mode 100644 index 0000000000..f4dcf31d5c --- /dev/null +++ b/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs @@ -0,0 +1,134 @@ +using Bit.Core.Auth.Repositories; +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tools.Repositories; +using Bit.Core.Vault.Repositories; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.KeyManagement.UserKey.Implementations; + +/// +public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand +{ + private readonly IUserService _userService; + private readonly IUserRepository _userRepository; + private readonly ICipherRepository _cipherRepository; + private readonly IFolderRepository _folderRepository; + private readonly ISendRepository _sendRepository; + private readonly IEmergencyAccessRepository _emergencyAccessRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IPushNotificationService _pushService; + private readonly IdentityErrorDescriber _identityErrorDescriber; + private readonly IWebAuthnCredentialRepository _credentialRepository; + private readonly IPasswordHasher _passwordHasher; + + /// + /// Instantiates a new + /// + /// Master password hash validation + /// Updates user keys and re-encrypted data if needed + /// Provides a method to update re-encrypted cipher data + /// Provides a method to update re-encrypted folder data + /// Provides a method to update re-encrypted send data + /// Provides a method to update re-encrypted emergency access data + /// Provides a method to update re-encrypted organization user data + /// Hashes the new master password + /// Logs out user from other devices after successful rotation + /// Provides a password mismatch error if master password hash validation fails + /// Provides a method to update re-encrypted WebAuthn keys + public RotateUserAccountKeysCommand(IUserService userService, IUserRepository userRepository, + ICipherRepository cipherRepository, IFolderRepository folderRepository, ISendRepository sendRepository, + IEmergencyAccessRepository emergencyAccessRepository, IOrganizationUserRepository organizationUserRepository, + IPasswordHasher passwordHasher, + IPushNotificationService pushService, IdentityErrorDescriber errors, IWebAuthnCredentialRepository credentialRepository) + { + _userService = userService; + _userRepository = userRepository; + _cipherRepository = cipherRepository; + _folderRepository = folderRepository; + _sendRepository = sendRepository; + _emergencyAccessRepository = emergencyAccessRepository; + _organizationUserRepository = organizationUserRepository; + _pushService = pushService; + _identityErrorDescriber = errors; + _credentialRepository = credentialRepository; + _passwordHasher = passwordHasher; + } + + /// + public async Task RotateUserAccountKeysAsync(User user, RotateUserAccountKeysData model) + { + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + if (!await _userService.CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)) + { + return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); + } + + var now = DateTime.UtcNow; + user.RevisionDate = user.AccountRevisionDate = now; + user.LastKeyRotationDate = now; + user.SecurityStamp = Guid.NewGuid().ToString(); + + if ( + !model.MasterPasswordUnlockData.ValidateForUser(user) + ) + { + throw new InvalidOperationException("The provided master password unlock data is not valid for this user."); + } + if ( + model.AccountPublicKey != user.PublicKey + ) + { + throw new InvalidOperationException("The provided account public key does not match the user's current public key, and changing the account asymmetric keypair is currently not supported during key rotation."); + } + + user.Key = model.MasterPasswordUnlockData.MasterKeyEncryptedUserKey; + user.PrivateKey = model.UserKeyEncryptedAccountPrivateKey; + user.MasterPassword = _passwordHasher.HashPassword(user, model.MasterPasswordUnlockData.MasterKeyAuthenticationHash); + user.MasterPasswordHint = model.MasterPasswordUnlockData.MasterPasswordHint; + + List saveEncryptedDataActions = new(); + if (model.Ciphers.Any()) + { + saveEncryptedDataActions.Add(_cipherRepository.UpdateForKeyRotation(user.Id, model.Ciphers)); + } + + if (model.Folders.Any()) + { + saveEncryptedDataActions.Add(_folderRepository.UpdateForKeyRotation(user.Id, model.Folders)); + } + + if (model.Sends.Any()) + { + saveEncryptedDataActions.Add(_sendRepository.UpdateForKeyRotation(user.Id, model.Sends)); + } + + if (model.EmergencyAccesses.Any()) + { + saveEncryptedDataActions.Add( + _emergencyAccessRepository.UpdateForKeyRotation(user.Id, model.EmergencyAccesses)); + } + + if (model.OrganizationUsers.Any()) + { + saveEncryptedDataActions.Add( + _organizationUserRepository.UpdateForKeyRotation(user.Id, model.OrganizationUsers)); + } + + if (model.WebAuthnKeys.Any()) + { + saveEncryptedDataActions.Add(_credentialRepository.UpdateKeysForRotationAsync(user.Id, model.WebAuthnKeys)); + } + + await _userRepository.UpdateUserKeyAndEncryptedDataV2Async(user, saveEncryptedDataActions); + await _pushService.PushLogOutAsync(user.Id); + return IdentityResult.Success; + } +} diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 040e6e1f49..0e59b9998f 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -32,5 +32,7 @@ public interface IUserRepository : IRepository /// Registered database calls to update re-encrypted data. Task UpdateUserKeyAndEncryptedDataAsync(User user, IEnumerable updateDataActions); + Task UpdateUserKeyAndEncryptedDataV2Async(User user, + IEnumerable updateDataActions); Task DeleteManyAsync(IEnumerable users); } diff --git a/src/Infrastructure.Dapper/Repositories/UserRepository.cs b/src/Infrastructure.Dapper/Repositories/UserRepository.cs index 227a7c03e5..28478a0c41 100644 --- a/src/Infrastructure.Dapper/Repositories/UserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/UserRepository.cs @@ -254,6 +254,42 @@ public class UserRepository : Repository, IUserRepository } + public async Task UpdateUserKeyAndEncryptedDataV2Async( + User user, + IEnumerable updateDataActions) + { + await using var connection = new SqlConnection(ConnectionString); + connection.Open(); + + await using var transaction = connection.BeginTransaction(); + try + { + user.AccountRevisionDate = user.RevisionDate; + + ProtectData(user); + await connection.ExecuteAsync( + $"[{Schema}].[{Table}_Update]", + user, + transaction: transaction, + commandType: CommandType.StoredProcedure); + + // Update re-encrypted data + foreach (var action in updateDataActions) + { + await action(connection, transaction); + } + transaction.Commit(); + } + catch + { + transaction.Rollback(); + UnprotectData(user); + throw; + } + UnprotectData(user); + } + + public async Task> GetManyAsync(IEnumerable ids) { using (var connection = new SqlConnection(ReadOnlyConnectionString)) @@ -295,6 +331,18 @@ public class UserRepository : Repository, IUserRepository var originalKey = user.Key; // Protect values + ProtectData(user); + + // Save + await saveTask(); + + // Restore original values + user.MasterPassword = originalMasterPassword; + user.Key = originalKey; + } + + private void ProtectData(User user) + { if (!user.MasterPassword?.StartsWith(Constants.DatabaseFieldProtectedPrefix) ?? false) { user.MasterPassword = string.Concat(Constants.DatabaseFieldProtectedPrefix, @@ -306,13 +354,6 @@ public class UserRepository : Repository, IUserRepository user.Key = string.Concat(Constants.DatabaseFieldProtectedPrefix, _dataProtector.Protect(user.Key!)); } - - // Save - await saveTask(); - - // Restore original values - user.MasterPassword = originalMasterPassword; - user.Key = originalKey; } private void UnprotectData(User? user) diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index cbfefb6483..127646ed59 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -170,6 +170,7 @@ public class UserRepository : Repository, IUserR entity.SecurityStamp = user.SecurityStamp; entity.Key = user.Key; + entity.PrivateKey = user.PrivateKey; entity.LastKeyRotationDate = user.LastKeyRotationDate; entity.AccountRevisionDate = user.AccountRevisionDate; @@ -194,6 +195,52 @@ public class UserRepository : Repository, IUserR } + + public async Task UpdateUserKeyAndEncryptedDataV2Async(Core.Entities.User user, + IEnumerable updateDataActions) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + + // Update user + var userEntity = await dbContext.Users.FindAsync(user.Id); + if (userEntity == null) + { + throw new ArgumentException("User not found", nameof(user)); + } + + userEntity.SecurityStamp = user.SecurityStamp; + userEntity.Key = user.Key; + userEntity.PrivateKey = user.PrivateKey; + + userEntity.Kdf = user.Kdf; + userEntity.KdfIterations = user.KdfIterations; + userEntity.KdfMemory = user.KdfMemory; + userEntity.KdfParallelism = user.KdfParallelism; + + userEntity.Email = user.Email; + + userEntity.MasterPassword = user.MasterPassword; + userEntity.MasterPasswordHint = user.MasterPasswordHint; + + userEntity.LastKeyRotationDate = user.LastKeyRotationDate; + userEntity.AccountRevisionDate = user.AccountRevisionDate; + userEntity.RevisionDate = user.RevisionDate; + + await dbContext.SaveChangesAsync(); + + // Update re-encrypted data + foreach (var action in updateDataActions) + { + // connection and transaction aren't used in EF + await action(); + } + + await transaction.CommitAsync(); + } + public async Task> GetManyAsync(IEnumerable ids) { using (var scope = ServiceScopeFactory.CreateScope()) diff --git a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index ec7ca37460..7c05e1d680 100644 --- a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -2,12 +2,18 @@ using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.KeyManagement.Models.Requests; +using Bit.Api.Tools.Models.Request; +using Bit.Api.Vault.Models; +using Bit.Api.Vault.Models.Request; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Billing.Enums; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; +using Bit.Core.Vault.Enums; using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Identity; using Xunit; namespace Bit.Api.IntegrationTest.KeyManagement.Controllers; @@ -23,6 +29,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture _passwordHasher; private string _ownerEmail = null!; public AccountsKeyManagementControllerTests(ApiApplicationFactory factory) @@ -35,6 +42,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture(); _emergencyAccessRepository = _factory.GetService(); _organizationUserRepository = _factory.GetService(); + _passwordHasher = _factory.GetService>(); } public async Task InitializeAsync() @@ -161,4 +169,87 @@ public class AccountsKeyManagementControllerTests : IClassFixture sutProvider, + RotateUserAccountKeysAndDataRequestModel data, User user) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + sutProvider.GetDependency().RotateUserAccountKeysAsync(Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Success); + await sutProvider.Sut.RotateUserAccountKeysAsync(data); + + await sutProvider.GetDependency, IEnumerable>>().Received(1) + .ValidateAsync(Arg.Any(), Arg.Is(data.AccountUnlockData.EmergencyAccessUnlockData)); + await sutProvider.GetDependency, IReadOnlyList>>().Received(1) + .ValidateAsync(Arg.Any(), Arg.Is(data.AccountUnlockData.OrganizationAccountRecoveryUnlockData)); + await sutProvider.GetDependency, IEnumerable>>().Received(1) + .ValidateAsync(Arg.Any(), Arg.Is(data.AccountUnlockData.PasskeyUnlockData)); + + await sutProvider.GetDependency, IEnumerable>>().Received(1) + .ValidateAsync(Arg.Any(), Arg.Is(data.AccountData.Ciphers)); + await sutProvider.GetDependency, IEnumerable>>().Received(1) + .ValidateAsync(Arg.Any(), Arg.Is(data.AccountData.Folders)); + await sutProvider.GetDependency, IReadOnlyList>>().Received(1) + .ValidateAsync(Arg.Any(), Arg.Is(data.AccountData.Sends)); + + await sutProvider.GetDependency().Received(1) + .RotateUserAccountKeysAsync(Arg.Is(user), Arg.Is(d => + d.OldMasterKeyAuthenticationHash == data.OldMasterKeyAuthenticationHash + + && d.MasterPasswordUnlockData.KdfType == data.AccountUnlockData.MasterPasswordUnlockData.KdfType + && d.MasterPasswordUnlockData.KdfIterations == data.AccountUnlockData.MasterPasswordUnlockData.KdfIterations + && d.MasterPasswordUnlockData.KdfMemory == data.AccountUnlockData.MasterPasswordUnlockData.KdfMemory + && d.MasterPasswordUnlockData.KdfParallelism == data.AccountUnlockData.MasterPasswordUnlockData.KdfParallelism + && d.MasterPasswordUnlockData.Email == data.AccountUnlockData.MasterPasswordUnlockData.Email + + && d.MasterPasswordUnlockData.MasterKeyAuthenticationHash == data.AccountUnlockData.MasterPasswordUnlockData.MasterKeyAuthenticationHash + && d.MasterPasswordUnlockData.MasterKeyEncryptedUserKey == data.AccountUnlockData.MasterPasswordUnlockData.MasterKeyEncryptedUserKey + + && d.AccountPublicKey == data.AccountKeys.AccountPublicKey + && d.UserKeyEncryptedAccountPrivateKey == data.AccountKeys.UserKeyEncryptedAccountPrivateKey + )); + } + + + [Theory] + [BitAutoData] + public async Task RotateUserKeyNoUser_Throws(SutProvider sutProvider, + RotateUserAccountKeysAndDataRequestModel data) + { + User? user = null; + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + sutProvider.GetDependency().RotateUserAccountKeysAsync(Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Success); + await Assert.ThrowsAsync(() => sutProvider.Sut.RotateUserAccountKeysAsync(data)); + } + + [Theory] + [BitAutoData] + public async Task RotateUserKeyWrongData_Throws(SutProvider sutProvider, + RotateUserAccountKeysAndDataRequestModel data, User user, IdentityErrorDescriber _identityErrorDescriber) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + sutProvider.GetDependency().RotateUserAccountKeysAsync(Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch())); + try + { + await sutProvider.Sut.RotateUserAccountKeysAsync(data); + Assert.Fail("Should have thrown"); + } + catch (BadRequestException ex) + { + Assert.NotEmpty(ex.ModelState.Values); + } + } } diff --git a/test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs b/test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs new file mode 100644 index 0000000000..4c78c7015a --- /dev/null +++ b/test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs @@ -0,0 +1,68 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; +using Bit.Api.Auth.Models.Request.Accounts; +using Bit.Core.Enums; +using Xunit; + +namespace Bit.Api.Test.KeyManagement.Models.Request; + +public class MasterPasswordUnlockDataModelTests +{ + + readonly string _mockEncryptedString = "2.3Uk+WNBIoU5xzmVFNcoWzz==|1MsPIYuRfdOHfu/0uY6H2Q==|/98sp4wb6pHP1VTZ9JcNCYgQjEUMFPlqJgCwRk1YXKg="; + + [Theory] + [InlineData(KdfType.PBKDF2_SHA256, 5000, null, null)] + [InlineData(KdfType.PBKDF2_SHA256, 100000, null, null)] + [InlineData(KdfType.PBKDF2_SHA256, 600000, null, null)] + [InlineData(KdfType.Argon2id, 3, 64, 4)] + public void Validate_Success(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) + { + var model = new MasterPasswordUnlockDataModel + { + KdfType = kdfType, + KdfIterations = kdfIterations, + KdfMemory = kdfMemory, + KdfParallelism = kdfParallelism, + Email = "example@example.com", + MasterKeyAuthenticationHash = "hash", + MasterKeyEncryptedUserKey = _mockEncryptedString, + MasterPasswordHint = "hint" + }; + var result = Validate(model); + Assert.Empty(result); + } + + [Theory] + [InlineData(KdfType.Argon2id, 1, null, 1)] + [InlineData(KdfType.Argon2id, 1, 64, null)] + [InlineData(KdfType.PBKDF2_SHA256, 5000, 0, null)] + [InlineData(KdfType.PBKDF2_SHA256, 5000, null, 0)] + [InlineData(KdfType.PBKDF2_SHA256, 5000, 0, 0)] + [InlineData((KdfType)2, 100000, null, null)] + [InlineData((KdfType)2, 2, 64, 4)] + public void Validate_Failure(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) + { + var model = new MasterPasswordUnlockDataModel + { + KdfType = kdfType, + KdfIterations = kdfIterations, + KdfMemory = kdfMemory, + KdfParallelism = kdfParallelism, + Email = "example@example.com", + MasterKeyAuthenticationHash = "hash", + MasterKeyEncryptedUserKey = _mockEncryptedString, + MasterPasswordHint = "hint" + }; + var result = Validate(model); + Assert.Single(result); + Assert.NotNull(result.First().ErrorMessage); + } + + private static List Validate(MasterPasswordUnlockDataModel model) + { + var results = new List(); + Validator.TryValidateObject(model, new ValidationContext(model), results, true); + return results; + } +} diff --git a/test/Core.Test/KeyManagement/UserKey/RotateUserAccountKeysCommandTests.cs b/test/Core.Test/KeyManagement/UserKey/RotateUserAccountKeysCommandTests.cs new file mode 100644 index 0000000000..e677814fc1 --- /dev/null +++ b/test/Core.Test/KeyManagement/UserKey/RotateUserAccountKeysCommandTests.cs @@ -0,0 +1,120 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.UserKey.Implementations; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.KeyManagement.UserKey; + +[SutProviderCustomize] +public class RotateUserAccountKeysCommandTests +{ + [Theory, BitAutoData] + public async Task RejectsWrongOldMasterPassword(SutProvider sutProvider, User user, + RotateUserAccountKeysData model) + { + user.Email = model.MasterPasswordUnlockData.Email; + sutProvider.GetDependency().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash) + .Returns(false); + + var result = await sutProvider.Sut.RotateUserAccountKeysAsync(user, model); + + Assert.NotEqual(IdentityResult.Success, result); + } + [Theory, BitAutoData] + public async Task ThrowsWhenUserIsNull(SutProvider sutProvider, + RotateUserAccountKeysData model) + { + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RotateUserAccountKeysAsync(null, model)); + } + [Theory, BitAutoData] + public async Task RejectsEmailChange(SutProvider sutProvider, User user, + RotateUserAccountKeysData model) + { + user.Kdf = Enums.KdfType.Argon2id; + user.KdfIterations = 3; + user.KdfMemory = 64; + user.KdfParallelism = 4; + + model.MasterPasswordUnlockData.Email = user.Email + ".different-domain"; + model.MasterPasswordUnlockData.KdfType = Enums.KdfType.Argon2id; + model.MasterPasswordUnlockData.KdfIterations = 3; + model.MasterPasswordUnlockData.KdfMemory = 64; + model.MasterPasswordUnlockData.KdfParallelism = 4; + sutProvider.GetDependency().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash) + .Returns(true); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RotateUserAccountKeysAsync(user, model)); + } + + [Theory, BitAutoData] + public async Task RejectsKdfChange(SutProvider sutProvider, User user, + RotateUserAccountKeysData model) + { + user.Kdf = Enums.KdfType.Argon2id; + user.KdfIterations = 3; + user.KdfMemory = 64; + user.KdfParallelism = 4; + + model.MasterPasswordUnlockData.Email = user.Email; + model.MasterPasswordUnlockData.KdfType = Enums.KdfType.PBKDF2_SHA256; + model.MasterPasswordUnlockData.KdfIterations = 600000; + model.MasterPasswordUnlockData.KdfMemory = null; + model.MasterPasswordUnlockData.KdfParallelism = null; + sutProvider.GetDependency().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash) + .Returns(true); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RotateUserAccountKeysAsync(user, model)); + } + + + [Theory, BitAutoData] + public async Task RejectsPublicKeyChange(SutProvider sutProvider, User user, + RotateUserAccountKeysData model) + { + user.PublicKey = "old-public"; + user.Kdf = Enums.KdfType.Argon2id; + user.KdfIterations = 3; + user.KdfMemory = 64; + user.KdfParallelism = 4; + + model.AccountPublicKey = "new-public"; + model.MasterPasswordUnlockData.Email = user.Email; + model.MasterPasswordUnlockData.KdfType = Enums.KdfType.Argon2id; + model.MasterPasswordUnlockData.KdfIterations = 3; + model.MasterPasswordUnlockData.KdfMemory = 64; + model.MasterPasswordUnlockData.KdfParallelism = 4; + + sutProvider.GetDependency().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash) + .Returns(true); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RotateUserAccountKeysAsync(user, model)); + } + + [Theory, BitAutoData] + public async Task RotatesCorrectly(SutProvider sutProvider, User user, + RotateUserAccountKeysData model) + { + user.Kdf = Enums.KdfType.Argon2id; + user.KdfIterations = 3; + user.KdfMemory = 64; + user.KdfParallelism = 4; + + model.MasterPasswordUnlockData.Email = user.Email; + model.MasterPasswordUnlockData.KdfType = Enums.KdfType.Argon2id; + model.MasterPasswordUnlockData.KdfIterations = 3; + model.MasterPasswordUnlockData.KdfMemory = 64; + model.MasterPasswordUnlockData.KdfParallelism = 4; + + model.AccountPublicKey = user.PublicKey; + + sutProvider.GetDependency().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash) + .Returns(true); + + var result = await sutProvider.Sut.RotateUserAccountKeysAsync(user, model); + + Assert.Equal(IdentityResult.Success, result); + } +} diff --git a/test/Core.Test/KeyManagement/UserKey/RotateUserKeyCommandTests.cs b/test/Core.Test/KeyManagement/UserKey/RotateUserKeyCommandTests.cs index 53263d8805..000fa7e90c 100644 --- a/test/Core.Test/KeyManagement/UserKey/RotateUserKeyCommandTests.cs +++ b/test/Core.Test/KeyManagement/UserKey/RotateUserKeyCommandTests.cs @@ -11,7 +11,7 @@ using Microsoft.AspNetCore.Identity; using NSubstitute; using Xunit; -namespace Bit.Core.Test.KeyManagement.UserFeatures.UserKey; +namespace Bit.Core.Test.KeyManagement.UserKey; [SutProviderCustomize] public class RotateUserKeyCommandTests diff --git a/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs index 066a550fa8..151bd47c44 100644 --- a/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs +++ b/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Test.AutoFixture.Attributes; using Bit.Infrastructure.EFIntegration.Test.AutoFixture; @@ -289,4 +290,27 @@ public class UserRepositoryTests var distinctItems = returnedList.Distinct(equalityComparer); Assert.True(!distinctItems.Skip(1).Any()); } + + [CiSkippedTheory, EfUserAutoData] + public async Task UpdateUserKeyAndEncryptedDataAsync_Works_DataMatches(User user, SqlRepo.UserRepository sqlUserRepo) + { + var sqlUser = await sqlUserRepo.CreateAsync(user); + sqlUser.Kdf = KdfType.PBKDF2_SHA256; + sqlUser.KdfIterations = 6_000_000; + sqlUser.KdfMemory = 7_000_000; + sqlUser.KdfParallelism = 8_000_000; + sqlUser.MasterPassword = "masterPasswordHash"; + sqlUser.MasterPasswordHint = "masterPasswordHint"; + sqlUser.Email = "example@example.com"; + + await sqlUserRepo.UpdateUserKeyAndEncryptedDataV2Async(sqlUser, []); + var updatedUser = await sqlUserRepo.GetByIdAsync(sqlUser.Id); + Assert.Equal(sqlUser.Kdf, updatedUser.Kdf); + Assert.Equal(sqlUser.KdfIterations, updatedUser.KdfIterations); + Assert.Equal(sqlUser.KdfMemory, updatedUser.KdfMemory); + Assert.Equal(sqlUser.KdfParallelism, updatedUser.KdfParallelism); + Assert.Equal(sqlUser.MasterPassword, updatedUser.MasterPassword); + Assert.Equal(sqlUser.MasterPasswordHint, updatedUser.MasterPasswordHint); + Assert.Equal(sqlUser.Email, updatedUser.Email); + } }