[PM-27281] Support v2 account encryption on JIT master password signups (#6777)

* V2 prep, rename existing SSO JIT MP command to V1

* set initial master password for account registraton V2

* later removel docs

* TDE MP onboarding split

* revert separate TDE onboarding controller api

* Server side hash of the user master password hash

* use `ValidationResult` instead for validation errors

* unit test coverage

* integration test coverage

* update sql migration script date

* revert validate password change

* better requests validation

* explicit error message when org sso identifier invalid

* more unit test coverage

* renamed onboarding to set, hash naming clarifications

* update db sql script, formatting

* use raw json as request instead of request models for integration test

* v1 integration test coverage

* change of name
This commit is contained in:
Maciej Zieniuk
2026-01-09 09:17:45 +01:00
committed by GitHub
parent 62ae828143
commit 2e92a53f11
25 changed files with 2642 additions and 279 deletions

View File

@@ -1,14 +1,17 @@
using System.Security.Claims;
using Bit.Api.Auth.Controllers;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.KeyManagement.Models.Requests;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Services;
using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Kdf;
using Bit.Core.KeyManagement.Models.Api.Request;
@@ -33,7 +36,9 @@ public class AccountsControllerTests : IDisposable
private readonly IProviderUserRepository _providerUserRepository;
private readonly IPolicyService _policyService;
private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand;
private readonly ISetInitialMasterPasswordCommandV1 _setInitialMasterPasswordCommandV1;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly ITdeSetPasswordCommand _tdeSetPasswordCommand;
private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand;
private readonly IFeatureService _featureService;
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
@@ -49,7 +54,9 @@ public class AccountsControllerTests : IDisposable
_providerUserRepository = Substitute.For<IProviderUserRepository>();
_policyService = Substitute.For<IPolicyService>();
_setInitialMasterPasswordCommand = Substitute.For<ISetInitialMasterPasswordCommand>();
_setInitialMasterPasswordCommandV1 = Substitute.For<ISetInitialMasterPasswordCommandV1>();
_twoFactorIsEnabledQuery = Substitute.For<ITwoFactorIsEnabledQuery>();
_tdeSetPasswordCommand = Substitute.For<ITdeSetPasswordCommand>();
_tdeOffboardingPasswordCommand = Substitute.For<ITdeOffboardingPasswordCommand>();
_featureService = Substitute.For<IFeatureService>();
_userAccountKeysQuery = Substitute.For<IUserAccountKeysQuery>();
@@ -64,6 +71,8 @@ public class AccountsControllerTests : IDisposable
_userService,
_policyService,
_setInitialMasterPasswordCommand,
_setInitialMasterPasswordCommandV1,
_tdeSetPasswordCommand,
_tdeOffboardingPasswordCommand,
_twoFactorIsEnabledQuery,
_featureService,
@@ -379,13 +388,13 @@ public class AccountsControllerTests : IDisposable
[BitAutoData(true, null, "newPublicKey", false)]
// reject overwriting existing keys
[BitAutoData(true, "newPrivateKey", "newPublicKey", false)]
public async Task PostSetPasswordAsync_WhenUserExistsAndSettingPasswordSucceeds_ShouldHandleKeysCorrectlyAndReturn(
public async Task PostSetPasswordAsync_V1_WhenUserExistsAndSettingPasswordSucceeds_ShouldHandleKeysCorrectlyAndReturn(
bool hasExistingKeys,
string requestPrivateKey,
string requestPublicKey,
bool shouldSucceed,
User user,
SetPasswordRequestModel setPasswordRequestModel)
SetInitialPasswordRequestModel setInitialPasswordRequestModel)
{
// Arrange
const string existingPublicKey = "existingPublicKey";
@@ -402,13 +411,15 @@ public class AccountsControllerTests : IDisposable
user.PrivateKey = null;
}
UpdateSetInitialPasswordRequestModelToV1(setInitialPasswordRequestModel);
if (requestPrivateKey == null && requestPublicKey == null)
{
setPasswordRequestModel.Keys = null;
setInitialPasswordRequestModel.Keys = null;
}
else
{
setPasswordRequestModel.Keys = new KeysRequestModel
setInitialPasswordRequestModel.Keys = new KeysRequestModel
{
EncryptedPrivateKey = requestPrivateKey,
PublicKey = requestPublicKey
@@ -416,44 +427,44 @@ public class AccountsControllerTests : IDisposable
}
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult(user));
_setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(
_setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync(
user,
setPasswordRequestModel.MasterPasswordHash,
setPasswordRequestModel.Key,
setPasswordRequestModel.OrgIdentifier)
setInitialPasswordRequestModel.MasterPasswordHash,
setInitialPasswordRequestModel.Key,
setInitialPasswordRequestModel.OrgIdentifier)
.Returns(Task.FromResult(IdentityResult.Success));
// Act
if (shouldSucceed)
{
await _sut.PostSetPasswordAsync(setPasswordRequestModel);
await _sut.PostSetPasswordAsync(setInitialPasswordRequestModel);
// Assert
await _setInitialMasterPasswordCommand.Received(1)
await _setInitialMasterPasswordCommandV1.Received(1)
.SetInitialMasterPasswordAsync(
Arg.Is<User>(u => u == user),
Arg.Is<string>(s => s == setPasswordRequestModel.MasterPasswordHash),
Arg.Is<string>(s => s == setPasswordRequestModel.Key),
Arg.Is<string>(s => s == setPasswordRequestModel.OrgIdentifier));
Arg.Is<string>(s => s == setInitialPasswordRequestModel.MasterPasswordHash),
Arg.Is<string>(s => s == setInitialPasswordRequestModel.Key),
Arg.Is<string>(s => s == setInitialPasswordRequestModel.OrgIdentifier));
// Additional Assertions for User object modifications
Assert.Equal(setPasswordRequestModel.MasterPasswordHint, user.MasterPasswordHint);
Assert.Equal(setPasswordRequestModel.Kdf, user.Kdf);
Assert.Equal(setPasswordRequestModel.KdfIterations, user.KdfIterations);
Assert.Equal(setPasswordRequestModel.KdfMemory, user.KdfMemory);
Assert.Equal(setPasswordRequestModel.KdfParallelism, user.KdfParallelism);
Assert.Equal(setPasswordRequestModel.Key, user.Key);
Assert.Equal(setInitialPasswordRequestModel.MasterPasswordHint, user.MasterPasswordHint);
Assert.Equal(setInitialPasswordRequestModel.Kdf, user.Kdf);
Assert.Equal(setInitialPasswordRequestModel.KdfIterations, user.KdfIterations);
Assert.Equal(setInitialPasswordRequestModel.KdfMemory, user.KdfMemory);
Assert.Equal(setInitialPasswordRequestModel.KdfParallelism, user.KdfParallelism);
Assert.Equal(setInitialPasswordRequestModel.Key, user.Key);
}
else
{
await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostSetPasswordAsync(setPasswordRequestModel));
await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostSetPasswordAsync(setInitialPasswordRequestModel));
}
}
[Theory]
[BitAutoData]
public async Task PostSetPasswordAsync_WhenUserExistsAndHasKeysAndKeysAreUpdated_ShouldThrowAsync(
public async Task PostSetPasswordAsync_V1_WhenUserExistsAndHasKeysAndKeysAreUpdated_ShouldThrowAsync(
User user,
SetPasswordRequestModel setPasswordRequestModel)
SetInitialPasswordRequestModel setInitialPasswordRequestModel)
{
// Arrange
const string existingPublicKey = "existingPublicKey";
@@ -465,47 +476,52 @@ public class AccountsControllerTests : IDisposable
user.PublicKey = existingPublicKey;
user.PrivateKey = existingEncryptedPrivateKey;
setPasswordRequestModel.Keys = new KeysRequestModel()
UpdateSetInitialPasswordRequestModelToV1(setInitialPasswordRequestModel);
setInitialPasswordRequestModel.Keys = new KeysRequestModel()
{
PublicKey = newPublicKey,
EncryptedPrivateKey = newEncryptedPrivateKey
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult(user));
_setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(
_setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync(
user,
setPasswordRequestModel.MasterPasswordHash,
setPasswordRequestModel.Key,
setPasswordRequestModel.OrgIdentifier)
setInitialPasswordRequestModel.MasterPasswordHash,
setInitialPasswordRequestModel.Key,
setInitialPasswordRequestModel.OrgIdentifier)
.Returns(Task.FromResult(IdentityResult.Success));
// Act & Assert
await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostSetPasswordAsync(setPasswordRequestModel));
await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostSetPasswordAsync(setInitialPasswordRequestModel));
}
[Theory]
[BitAutoData]
public async Task PostSetPasswordAsync_WhenUserDoesNotExist_ShouldThrowUnauthorizedAccessException(
SetPasswordRequestModel setPasswordRequestModel)
public async Task PostSetPasswordAsync_V1_WhenUserDoesNotExist_ShouldThrowUnauthorizedAccessException(
SetInitialPasswordRequestModel setInitialPasswordRequestModel)
{
UpdateSetInitialPasswordRequestModelToV1(setInitialPasswordRequestModel);
// Arrange
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult((User)null));
// Act & Assert
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => _sut.PostSetPasswordAsync(setPasswordRequestModel));
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => _sut.PostSetPasswordAsync(setInitialPasswordRequestModel));
}
[Theory]
[BitAutoData]
public async Task PostSetPasswordAsync_WhenSettingPasswordFails_ShouldThrowBadRequestException(
public async Task PostSetPasswordAsync_V1_WhenSettingPasswordFails_ShouldThrowBadRequestException(
User user,
SetPasswordRequestModel model)
SetInitialPasswordRequestModel model)
{
UpdateSetInitialPasswordRequestModelToV1(model);
model.Keys = null;
// Arrange
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult(user));
_setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(Arg.Any<User>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
_setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync(Arg.Any<User>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(Task.FromResult(IdentityResult.Failed(new IdentityError { Description = "Some Error" })));
// Act & Assert
@@ -845,5 +861,139 @@ public class AccountsControllerTests : IDisposable
Assert.NotNull(result);
Assert.Equal("keys", result.Object);
}
[Theory]
[BitAutoData]
public async Task PostSetPasswordAsync_V2_WhenUserExistsAndSettingPasswordSucceeds_ShouldSetInitialMasterPassword(
User user,
SetInitialPasswordRequestModel setInitialPasswordRequestModel)
{
// Arrange
UpdateSetInitialPasswordRequestModelToV2(setInitialPasswordRequestModel);
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult(user));
_setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(user, Arg.Any<SetInitialMasterPasswordDataModel>())
.Returns(Task.CompletedTask);
// Act
await _sut.PostSetPasswordAsync(setInitialPasswordRequestModel);
// Assert
await _setInitialMasterPasswordCommand.Received(1)
.SetInitialMasterPasswordAsync(
Arg.Is<User>(u => u == user),
Arg.Is<SetInitialMasterPasswordDataModel>(d =>
d.MasterPasswordAuthentication != null &&
d.MasterPasswordUnlock != null &&
d.AccountKeys != null &&
d.OrgSsoIdentifier == setInitialPasswordRequestModel.OrgIdentifier));
}
[Theory]
[BitAutoData]
public async Task PostSetPasswordAsync_V2_WithTdeSetPassword_ShouldCallTdeSetPasswordCommand(
User user,
SetInitialPasswordRequestModel setInitialPasswordRequestModel)
{
// Arrange
UpdateSetInitialPasswordRequestModelToV2(setInitialPasswordRequestModel, includeTdeSetPassword: true);
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult(user));
_tdeSetPasswordCommand.SetMasterPasswordAsync(user, Arg.Any<SetInitialMasterPasswordDataModel>())
.Returns(Task.CompletedTask);
// Act
await _sut.PostSetPasswordAsync(setInitialPasswordRequestModel);
// Assert
await _tdeSetPasswordCommand.Received(1)
.SetMasterPasswordAsync(
Arg.Is<User>(u => u == user),
Arg.Is<SetInitialMasterPasswordDataModel>(d =>
d.MasterPasswordAuthentication != null &&
d.MasterPasswordUnlock != null &&
d.AccountKeys == null &&
d.OrgSsoIdentifier == setInitialPasswordRequestModel.OrgIdentifier));
}
[Theory]
[BitAutoData]
public async Task PostSetPasswordAsync_V2_WhenUserDoesNotExist_ShouldThrowUnauthorizedAccessException(
SetInitialPasswordRequestModel setInitialPasswordRequestModel)
{
// Arrange
UpdateSetInitialPasswordRequestModelToV2(setInitialPasswordRequestModel);
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult((User)null));
// Act & Assert
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => _sut.PostSetPasswordAsync(setInitialPasswordRequestModel));
}
[Theory]
[BitAutoData]
public async Task PostSetPasswordAsync_V2_WhenSettingPasswordFails_ShouldThrowException(
User user,
SetInitialPasswordRequestModel setInitialPasswordRequestModel)
{
// Arrange
UpdateSetInitialPasswordRequestModelToV2(setInitialPasswordRequestModel);
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult(user));
_setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(user, Arg.Any<SetInitialMasterPasswordDataModel>())
.Returns(Task.FromException(new Exception("Setting password failed")));
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => _sut.PostSetPasswordAsync(setInitialPasswordRequestModel));
}
private void UpdateSetInitialPasswordRequestModelToV1(SetInitialPasswordRequestModel model)
{
model.MasterPasswordAuthentication = null;
model.MasterPasswordUnlock = null;
model.AccountKeys = null;
}
private void UpdateSetInitialPasswordRequestModelToV2(SetInitialPasswordRequestModel model, bool includeTdeSetPassword = false)
{
var kdf = new KdfRequestModel
{
KdfType = KdfType.PBKDF2_SHA256,
Iterations = 600000
};
model.MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel
{
Kdf = kdf,
MasterPasswordAuthenticationHash = "authHash",
Salt = "salt"
};
model.MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel
{
Kdf = kdf,
MasterKeyWrappedUserKey = "wrappedKey",
Salt = "salt"
};
if (includeTdeSetPassword)
{
// TDE set password does not include AccountKeys
model.AccountKeys = null;
}
else
{
model.AccountKeys = new AccountKeysRequestModel
{
UserKeyEncryptedAccountPrivateKey = "privateKey",
AccountPublicKey = "publicKey"
};
}
// Clear V1 properties
model.MasterPasswordHash = null;
model.Key = null;
model.Keys = null;
model.Kdf = null;
model.KdfIterations = null;
model.KdfMemory = null;
model.KdfParallelism = null;
}
}