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);
+ }
}