diff --git a/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs b/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs index b5f2b77cfb..bd8542e8bf 100644 --- a/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs +++ b/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs @@ -1,8 +1,7 @@ using System.Text.Json.Serialization; +using Bit.Core.KeyManagement.Models.Response; using Bit.Core.Models.Api; -#nullable enable - namespace Bit.Core.Auth.Models.Api.Response; public class UserDecryptionOptions : ResponseModel @@ -14,8 +13,15 @@ public class UserDecryptionOptions : ResponseModel /// /// Gets or sets whether the current user has a master password that can be used to decrypt their vault. /// + [Obsolete("Use MasterPasswordUnlock instead. This will be removed in a future version.")] public bool HasMasterPassword { get; set; } + /// + /// Gets or sets whether the current user has master password unlock data available. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public MasterPasswordUnlockResponseModel? MasterPasswordUnlock { get; set; } + /// /// Gets or sets the WebAuthn PRF decryption keys. /// diff --git a/src/Core/KeyManagement/Models/Response/MasterPasswordUnlockResponseModel.cs b/src/Core/KeyManagement/Models/Response/MasterPasswordUnlockResponseModel.cs new file mode 100644 index 0000000000..f7d5dee852 --- /dev/null +++ b/src/Core/KeyManagement/Models/Response/MasterPasswordUnlockResponseModel.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Enums; +using Bit.Core.Utilities; + +namespace Bit.Core.KeyManagement.Models.Response; + +public class MasterPasswordUnlockResponseModel +{ + public required MasterPasswordUnlockKdfResponseModel Kdf { get; init; } + [EncryptedString] public required string MasterKeyEncryptedUserKey { get; init; } + [StringLength(256)] public required string Salt { get; init; } +} + +public class MasterPasswordUnlockKdfResponseModel +{ + public required KdfType KdfType { get; init; } + public required int Iterations { get; init; } + public int? Memory { get; init; } + public int? Parallelism { get; init; } +} diff --git a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs index 61543f9751..dc27842210 100644 --- a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs +++ b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs @@ -5,6 +5,7 @@ using Bit.Core.Auth.Utilities; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Response; using Bit.Core.Repositories; using Bit.Core.Utilities; using Bit.Identity.Utilities; @@ -25,7 +26,7 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder private readonly ILoginApprovingClientTypes _loginApprovingClientTypes; private UserDecryptionOptions _options = new UserDecryptionOptions(); - private User? _user; + private User _user = null!; private SsoConfig? _ssoConfig; private Device? _device; @@ -44,7 +45,6 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder public IUserDecryptionOptionsBuilder ForUser(User user) { - _options.HasMasterPassword = user.HasMasterPassword(); _user = user; return this; } @@ -72,6 +72,7 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder public async Task BuildAsync() { + BuildMasterPasswordUnlock(); BuildKeyConnectorOptions(); await BuildTrustedDeviceOptions(); @@ -101,7 +102,7 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder } var isTdeActive = _ssoConfig.GetData() is { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption }; - var isTdeOffboarding = _user != null && !_user.HasMasterPassword() && _device != null && _device.IsTrusted() && !isTdeActive; + var isTdeOffboarding = !_user.HasMasterPassword() && _device != null && _device.IsTrusted() && !isTdeActive; if (!isTdeActive && !isTdeOffboarding) { return; @@ -116,7 +117,7 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder } var hasLoginApprovingDevice = false; - if (_device != null && _user != null) + if (_device != null) { var allDevices = await _deviceRepository.GetManyByUserIdAsync(_user.Id); // Checks if the current user has any devices that are capable of approving login with device requests except for @@ -134,16 +135,12 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder hasManageResetPasswordPermission = await _currentContext.ManageResetPassword(_ssoConfig!.OrganizationId); } - var hasAdminApproval = false; - if (_user != null) - { - // If sso configuration data is not null then I know for sure that ssoConfiguration isn't null - var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(_ssoConfig.OrganizationId, _user.Id); + // If sso configuration data is not null then I know for sure that ssoConfiguration isn't null + var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(_ssoConfig.OrganizationId, _user.Id); - hasManageResetPasswordPermission |= organizationUser != null && (organizationUser.Type == OrganizationUserType.Owner || organizationUser.Type == OrganizationUserType.Admin); - // They are only able to be approved by an admin if they have enrolled is reset password - hasAdminApproval = organizationUser != null && !string.IsNullOrEmpty(organizationUser.ResetPasswordKey); - } + hasManageResetPasswordPermission |= organizationUser != null && (organizationUser.Type == OrganizationUserType.Owner || organizationUser.Type == OrganizationUserType.Admin); + // They are only able to be approved by an admin if they have enrolled is reset password + var hasAdminApproval = organizationUser != null && !string.IsNullOrEmpty(organizationUser.ResetPasswordKey); _options.TrustedDeviceOption = new TrustedDeviceUserDecryptionOption( hasAdminApproval, @@ -153,4 +150,28 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder encryptedPrivateKey, encryptedUserKey); } + + private void BuildMasterPasswordUnlock() + { + if (_user.HasMasterPassword()) + { + _options.HasMasterPassword = true; + _options.MasterPasswordUnlock = new MasterPasswordUnlockResponseModel + { + Kdf = new MasterPasswordUnlockKdfResponseModel + { + KdfType = _user.Kdf, + Iterations = _user.KdfIterations, + Memory = _user.KdfMemory, + Parallelism = _user.KdfParallelism + }, + MasterKeyEncryptedUserKey = _user.Key!, + Salt = _user.Email.ToLowerInvariant() + }; + } + else + { + _options.HasMasterPassword = false; + } + } } diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs index c4ceaa70df..b9ab1b0d02 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs @@ -36,7 +36,15 @@ public class IdentityServerSsoTests public async Task Test_MasterPassword_DecryptionType() { // Arrange - using var responseBody = await RunSuccessTestAsync(MemberDecryptionType.MasterPassword); + User? expectedUser = null; + using var responseBody = await RunSuccessTestAsync(async factory => + { + var database = factory.GetDatabaseContext(); + + expectedUser = await database.Users.SingleAsync(u => u.Email == TestEmail); + Assert.NotNull(expectedUser); + }, MemberDecryptionType.MasterPassword); + Assert.NotNull(expectedUser); // Assert // If the organization has a member decryption type of MasterPassword that should be the only option in the reply @@ -47,13 +55,33 @@ public class IdentityServerSsoTests // Expected to look like: // "UserDecryptionOptions": { // "Object": "userDecryptionOptions" - // "HasMasterPassword": true + // "HasMasterPassword": true, + // "MasterPasswordUnlock": { + // "Kdf": { + // "KdfType": 0, + // "Iterations": 600000 + // }, + // "MasterKeyEncryptedUserKey": "2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==", + // "Salt": "sso_user@email.com" + // } // } AssertHelper.AssertJsonProperty(userDecryptionOptions, "HasMasterPassword", JsonValueKind.True); - - // One property for the Object and one for master password - Assert.Equal(2, userDecryptionOptions.EnumerateObject().Count()); + var objectString = AssertHelper.AssertJsonProperty(userDecryptionOptions, "Object", JsonValueKind.String).ToString(); + Assert.Equal("userDecryptionOptions", objectString); + var masterPasswordUnlock = AssertHelper.AssertJsonProperty(userDecryptionOptions, "MasterPasswordUnlock", JsonValueKind.Object); + // MasterPasswordUnlock.Kdf + var kdf = AssertHelper.AssertJsonProperty(masterPasswordUnlock, "Kdf", JsonValueKind.Object); + var kdfType = AssertHelper.AssertJsonProperty(kdf, "KdfType", JsonValueKind.Number).GetInt32(); + Assert.Equal((int)expectedUser.Kdf, kdfType); + var kdfIterations = AssertHelper.AssertJsonProperty(kdf, "Iterations", JsonValueKind.Number).GetInt32(); + Assert.Equal(expectedUser.KdfIterations, kdfIterations); + // MasterPasswordUnlock.MasterKeyEncryptedUserKey + var masterKeyEncryptedUserKey = AssertHelper.AssertJsonProperty(masterPasswordUnlock, "MasterKeyEncryptedUserKey", JsonValueKind.String).ToString(); + Assert.Equal(expectedUser.Key, masterKeyEncryptedUserKey); + // MasterPasswordUnlock.Salt + var salt = AssertHelper.AssertJsonProperty(masterPasswordUnlock, "Salt", JsonValueKind.String).ToString(); + Assert.Equal(TestEmail, salt); } [Fact] diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs index e63858117f..6f10f22002 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs @@ -67,7 +67,7 @@ public class IdentityServerTests : IClassFixture Assert.Equal(0, kdf); var kdfIterations = AssertHelper.AssertJsonProperty(root, "KdfIterations", JsonValueKind.Number).GetInt32(); Assert.Equal(AuthConstants.PBKDF2_ITERATIONS.Default, kdfIterations); - AssertUserDecryptionOptions(root); + AssertUserDecryptionOptions(root, user); } [Theory, RegisterFinishRequestModelCustomize] @@ -601,14 +601,27 @@ public class IdentityServerTests : IClassFixture Assert.StartsWith("sso authentication", errorDescription.ToLowerInvariant()); } - private static void AssertUserDecryptionOptions(JsonElement tokenResponse) + private static void AssertUserDecryptionOptions(JsonElement tokenResponse, User expectedUser) { - var userDecryptionOptions = AssertHelper.AssertJsonProperty(tokenResponse, "UserDecryptionOptions", JsonValueKind.Object) - .EnumerateObject(); + var userDecryptionOptions = + AssertHelper.AssertJsonProperty(tokenResponse, "UserDecryptionOptions", JsonValueKind.Object); - Assert.Collection(userDecryptionOptions, - (prop) => { Assert.Equal("HasMasterPassword", prop.Name); Assert.Equal(JsonValueKind.True, prop.Value.ValueKind); }, - (prop) => { Assert.Equal("Object", prop.Name); Assert.Equal("userDecryptionOptions", prop.Value.GetString()); }); + AssertHelper.AssertJsonProperty(userDecryptionOptions, "HasMasterPassword", JsonValueKind.True); + var objectString = AssertHelper.AssertJsonProperty(userDecryptionOptions, "Object", JsonValueKind.String).ToString(); + Assert.Equal("userDecryptionOptions", objectString); + var masterPasswordUnlock = AssertHelper.AssertJsonProperty(userDecryptionOptions, "MasterPasswordUnlock", JsonValueKind.Object); + // MasterPasswordUnlock.Kdf + var kdf = AssertHelper.AssertJsonProperty(masterPasswordUnlock, "Kdf", JsonValueKind.Object); + var kdfType = AssertHelper.AssertJsonProperty(kdf, "KdfType", JsonValueKind.Number).GetInt32(); + Assert.Equal((int)expectedUser.Kdf, kdfType); + var kdfIterations = AssertHelper.AssertJsonProperty(kdf, "Iterations", JsonValueKind.Number).GetInt32(); + Assert.Equal(expectedUser.KdfIterations, kdfIterations); + // MasterPasswordUnlock.MasterKeyEncryptedUserKey + var masterKeyEncryptedUserKey = AssertHelper.AssertJsonProperty(masterPasswordUnlock, "MasterKeyEncryptedUserKey", JsonValueKind.String).ToString(); + Assert.Equal(expectedUser.Key, masterKeyEncryptedUserKey); + // MasterPasswordUnlock.Salt + var salt = AssertHelper.AssertJsonProperty(masterPasswordUnlock, "Salt", JsonValueKind.String).ToString(); + Assert.Equal(expectedUser.Email.ToLower(), salt); } private void ReinitializeDbForTests(IdentityApplicationFactory factory) diff --git a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs index aab98a583c..a6283233dd 100644 --- a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs @@ -3,10 +3,13 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Repositories; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Response; using Bit.Core.Models.Api; using Bit.Core.Repositories; using Bit.Core.Services; @@ -27,6 +30,9 @@ namespace Bit.Identity.Test.IdentityServer; public class BaseRequestValidatorTests { + private static readonly string _mockEncryptedString = + "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk="; + private UserManager _userManager; private readonly IUserService _userService; private readonly IEventService _eventService; @@ -377,6 +383,102 @@ public class BaseRequestValidatorTests Assert.Equal(expectedMessage, errorResponse.Message); } + [Theory, BitAutoData] + public async Task ValidateAsync_CustomResponse_NoMasterPassword_ShouldSetUserDecryptionOptions( + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + CustomValidatorRequestContext requestContext, + GrantValidationResult grantResult) + { + // Arrange + _userDecryptionOptionsBuilder.ForUser(Arg.Any()).Returns(_userDecryptionOptionsBuilder); + _userDecryptionOptionsBuilder.WithDevice(Arg.Any()).Returns(_userDecryptionOptionsBuilder); + _userDecryptionOptionsBuilder.WithSso(Arg.Any()).Returns(_userDecryptionOptionsBuilder); + _userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any()).Returns(_userDecryptionOptionsBuilder); + _userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions + { + HasMasterPassword = false, + MasterPasswordUnlock = null + })); + + var context = CreateContext(tokenRequest, requestContext, grantResult); + _sut.isValid = true; + + _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest) + .Returns(Task.FromResult(new Tuple(false, null))); + _deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext) + .Returns(Task.FromResult(true)); + + // Act + await _sut.ValidateAsync(context); + + // Assert + Assert.False(context.GrantResult.IsError); + var customResponse = context.GrantResult.CustomResponse; + Assert.Contains("UserDecryptionOptions", customResponse); + Assert.IsType(customResponse["UserDecryptionOptions"]); + var userDecryptionOptions = (UserDecryptionOptions)customResponse["UserDecryptionOptions"]; + Assert.False(userDecryptionOptions.HasMasterPassword); + Assert.Null(userDecryptionOptions.MasterPasswordUnlock); + } + + [Theory] + [BitAutoData(KdfType.PBKDF2_SHA256, 654_321, null, null)] + [BitAutoData(KdfType.Argon2id, 11, 128, 5)] + public async Task ValidateAsync_CustomResponse_MasterPassword_ShouldSetUserDecryptionOptions( + KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + CustomValidatorRequestContext requestContext, + GrantValidationResult grantResult) + { + // Arrange + _userDecryptionOptionsBuilder.ForUser(Arg.Any()).Returns(_userDecryptionOptionsBuilder); + _userDecryptionOptionsBuilder.WithDevice(Arg.Any()).Returns(_userDecryptionOptionsBuilder); + _userDecryptionOptionsBuilder.WithSso(Arg.Any()).Returns(_userDecryptionOptionsBuilder); + _userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any()).Returns(_userDecryptionOptionsBuilder); + _userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions + { + HasMasterPassword = true, + MasterPasswordUnlock = new MasterPasswordUnlockResponseModel + { + Kdf = new MasterPasswordUnlockKdfResponseModel + { + KdfType = kdfType, + Iterations = kdfIterations, + Memory = kdfMemory, + Parallelism = kdfParallelism + }, + MasterKeyEncryptedUserKey = _mockEncryptedString, + Salt = "test@example.com" + } + })); + + var context = CreateContext(tokenRequest, requestContext, grantResult); + _sut.isValid = true; + + _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest) + .Returns(Task.FromResult(new Tuple(false, null))); + _deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext) + .Returns(Task.FromResult(true)); + + // Act + await _sut.ValidateAsync(context); + + // Assert + Assert.False(context.GrantResult.IsError); + var customResponse = context.GrantResult.CustomResponse; + Assert.Contains("UserDecryptionOptions", customResponse); + Assert.IsType(customResponse["UserDecryptionOptions"]); + var userDecryptionOptions = (UserDecryptionOptions)customResponse["UserDecryptionOptions"]; + Assert.True(userDecryptionOptions.HasMasterPassword); + Assert.NotNull(userDecryptionOptions.MasterPasswordUnlock); + Assert.Equal(kdfType, userDecryptionOptions.MasterPasswordUnlock.Kdf.KdfType); + Assert.Equal(kdfIterations, userDecryptionOptions.MasterPasswordUnlock.Kdf.Iterations); + Assert.Equal(kdfMemory, userDecryptionOptions.MasterPasswordUnlock.Kdf.Memory); + Assert.Equal(kdfParallelism, userDecryptionOptions.MasterPasswordUnlock.Kdf.Parallelism); + Assert.Equal(_mockEncryptedString, userDecryptionOptions.MasterPasswordUnlock.MasterKeyEncryptedUserKey); + Assert.Equal("test@example.com", userDecryptionOptions.MasterPasswordUnlock.Salt); + } + private BaseRequestValidationContextFake CreateContext( ValidatedTokenRequest tokenRequest, CustomValidatorRequestContext requestContext, diff --git a/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs b/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs index 25182743e5..b44dfe8d5f 100644 --- a/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs +++ b/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs @@ -28,6 +28,8 @@ public class UserDecryptionOptionsBuilderTests _organizationUserRepository = Substitute.For(); _loginApprovingClientTypes = Substitute.For(); _builder = new UserDecryptionOptionsBuilder(_currentContext, _deviceRepository, _organizationUserRepository, _loginApprovingClientTypes); + var user = new User(); + _builder.ForUser(user); } [Theory] @@ -285,4 +287,32 @@ public class UserDecryptionOptionsBuilderTests Assert.True(result.TrustedDeviceOption?.HasAdminApproval); } + + [Theory, BitAutoData] + public async Task Build_WhenUserHasNoMasterPassword_ShouldReturnNoMasterPasswordUnlock(User user) + { + user.MasterPassword = null; + + var result = await _builder.ForUser(user).BuildAsync(); + + Assert.False(result.HasMasterPassword); + Assert.Null(result.MasterPasswordUnlock); + } + + [Theory, BitAutoData] + public async Task Build_WhenUserHasMasterPassword_ShouldReturnMasterPasswordUnlock(User user) + { + user.Email = "test@example.COM"; + + var result = await _builder.ForUser(user).BuildAsync(); + + Assert.True(result.HasMasterPassword); + Assert.NotNull(result.MasterPasswordUnlock); + Assert.Equal(user.Kdf, result.MasterPasswordUnlock.Kdf.KdfType); + Assert.Equal(user.KdfIterations, result.MasterPasswordUnlock.Kdf.Iterations); + Assert.Equal(user.KdfMemory, result.MasterPasswordUnlock.Kdf.Memory); + Assert.Equal(user.KdfParallelism, result.MasterPasswordUnlock.Kdf.Parallelism); + Assert.Equal("test@example.com", result.MasterPasswordUnlock.Salt); + Assert.Equal(user.Key, result.MasterPasswordUnlock.MasterKeyEncryptedUserKey); + } }