-
Notifications
You must be signed in to change notification settings - Fork 1.5k
[PM-27279] Implement TDE Registration with V2 Keys #6671
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,7 @@ | |
| using Bit.Core.Enums; | ||
| using Bit.Core.Exceptions; | ||
| using Bit.Core.KeyManagement.Kdf; | ||
| using Bit.Core.KeyManagement.Models.Data; | ||
| using Bit.Core.KeyManagement.Queries.Interfaces; | ||
| using Bit.Core.Models.Api.Response; | ||
| using Bit.Core.Repositories; | ||
|
|
@@ -44,6 +45,7 @@ public class AccountsController : Controller | |
| private readonly IUserAccountKeysQuery _userAccountKeysQuery; | ||
| private readonly ITwoFactorEmailService _twoFactorEmailService; | ||
| private readonly IChangeKdfCommand _changeKdfCommand; | ||
| private readonly IUserRepository _userRepository; | ||
|
|
||
| public AccountsController( | ||
| IOrganizationService organizationService, | ||
|
|
@@ -57,7 +59,8 @@ public AccountsController( | |
| IFeatureService featureService, | ||
| IUserAccountKeysQuery userAccountKeysQuery, | ||
| ITwoFactorEmailService twoFactorEmailService, | ||
| IChangeKdfCommand changeKdfCommand | ||
| IChangeKdfCommand changeKdfCommand, | ||
| IUserRepository userRepository | ||
| ) | ||
| { | ||
| _organizationService = organizationService; | ||
|
|
@@ -72,6 +75,7 @@ IChangeKdfCommand changeKdfCommand | |
| _userAccountKeysQuery = userAccountKeysQuery; | ||
| _twoFactorEmailService = twoFactorEmailService; | ||
| _changeKdfCommand = changeKdfCommand; | ||
| _userRepository = userRepository; | ||
| } | ||
|
|
||
|
|
||
|
|
@@ -440,8 +444,40 @@ public async Task<KeysResponseModel> PostKeys([FromBody] KeysRequestModel model) | |
| } | ||
| } | ||
|
|
||
| await _userService.SaveUserAsync(model.ToUser(user)); | ||
| return new KeysResponseModel(user); | ||
| if (model.AccountKeys != null) | ||
| { | ||
| var accountKeysData = model.AccountKeys.ToAccountKeysData(); | ||
| if (accountKeysData.IsV2Encryption()) | ||
| { | ||
| await _userRepository.SetV2AccountCryptographicStateAsync(user.Id, accountKeysData); | ||
| return new KeysResponseModel(accountKeysData, user.Key); | ||
| } | ||
| else | ||
| { | ||
| // Todo: Drop this after a transition period | ||
| await _userService.SaveUserAsync(model.ToUser(user)); | ||
| return new KeysResponseModel(new UserAccountKeysData | ||
| { | ||
| PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( | ||
| user.PrivateKey, | ||
| user.PublicKey | ||
| ) | ||
| }, user.Key); | ||
|
Comment on lines
+458
to
+465
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The
Recommendation: Reload the user from the database after saving: var updatedUser = model.ToUser(user);
await _userService.SaveUserAsync(updatedUser);
// Reload to ensure we return what was actually saved
user = await _userService.GetUserByIdAsync(updatedUser.Id);
return new KeysResponseModel(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
user.PrivateKey,
user.PublicKey
)
}, user.Key);Alternatively, use the var updatedUser = model.ToUser(user);
await _userService.SaveUserAsync(updatedUser);
return new KeysResponseModel(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
updatedUser.PrivateKey,
updatedUser.PublicKey
)
}, updatedUser.Key); |
||
| } | ||
| } | ||
| else | ||
| { | ||
| // Todo: Drop this after a transition period | ||
| await _userService.SaveUserAsync(model.ToUser(user)); | ||
| return new KeysResponseModel(new UserAccountKeysData | ||
| { | ||
| PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( | ||
| user.PrivateKey, | ||
| user.PublicKey | ||
| ) | ||
| }, user.Key); | ||
| } | ||
|
Comment on lines
+455
to
+479
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. โป๏ธ CODE DUPLICATION: The v1 fallback logic (lines 458-466 and 471-479) is duplicated. This makes the code harder to maintain and increases the risk of bugs. Recommendation: Extract the common v1 handling logic: if (model.AccountKeys != null)
{
var accountKeysData = model.AccountKeys.ToAccountKeysData();
if (accountKeysData.IsV2Encryption())
{
await _userRepository.SetV2AccountCryptographicStateAsync(user.Id, accountKeysData);
return new KeysResponseModel(accountKeysData, user.Key);
}
}
// V1 fallback path (handles both null AccountKeys and non-V2 AccountKeys)
var updatedUser = model.ToUser(user);
await _userService.SaveUserAsync(updatedUser);
return new KeysResponseModel(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
updatedUser.PrivateKey,
updatedUser.PublicKey
)
}, updatedUser.Key);This simplifies the logic and makes it clear that there are two paths: V2 and V1 (fallback). |
||
|
|
||
| } | ||
|
|
||
| [HttpGet("keys")] | ||
|
|
@@ -453,7 +489,8 @@ public async Task<KeysResponseModel> GetKeys() | |
| throw new UnauthorizedAccessException(); | ||
| } | ||
|
|
||
| return new KeysResponseModel(user); | ||
| var accountKeys = await _userAccountKeysQuery.Run(user); | ||
| return new KeysResponseModel(accountKeys, user.Key); | ||
| } | ||
|
|
||
| [HttpDelete] | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,27 +1,32 @@ | ||
| ๏ปฟ// FIXME: Update this file to be null safe and then delete the line below | ||
| #nullable disable | ||
|
|
||
| using Bit.Core.Entities; | ||
| ๏ปฟusing Bit.Core.KeyManagement.Models.Api.Response; | ||
| using Bit.Core.KeyManagement.Models.Data; | ||
| using Bit.Core.Models.Api; | ||
|
|
||
| namespace Bit.Api.Models.Response; | ||
|
|
||
| public class KeysResponseModel : ResponseModel | ||
| { | ||
| public KeysResponseModel(User user) | ||
| public KeysResponseModel(UserAccountKeysData accountKeys, string? masterKeyWrappedUserKey) | ||
| : base("keys") | ||
| { | ||
| if (user == null) | ||
| if (masterKeyWrappedUserKey != null) | ||
| { | ||
| throw new ArgumentNullException(nameof(user)); | ||
| Key = masterKeyWrappedUserKey; | ||
| } | ||
|
|
||
| Key = user.Key; | ||
| PublicKey = user.PublicKey; | ||
| PrivateKey = user.PrivateKey; | ||
| PublicKey = accountKeys.PublicKeyEncryptionKeyPairData.PublicKey; | ||
| PrivateKey = accountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey; | ||
| AccountKeys = new PrivateKeysResponseModel(accountKeys); | ||
|
Comment on lines
+9
to
+19
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐ GOOD REFACTORING: The new constructor properly:
This is a clean migration path that maintains backward compatibility while supporting V2 encryption. |
||
| } | ||
|
|
||
| public string Key { get; set; } | ||
| /// <summary> | ||
| /// The master key wrapped user key. The master key can either be a master-password master key or a | ||
| /// key-connector master key. | ||
| /// </summary> | ||
| public string? Key { get; set; } | ||
| [Obsolete("Use AccountKeys.PublicKeyEncryptionKeyPair.PublicKey instead")] | ||
| public string PublicKey { get; set; } | ||
| [Obsolete("Use AccountKeys.PublicKeyEncryptionKeyPair.WrappedPrivateKey instead")] | ||
| public string PrivateKey { get; set; } | ||
| public PrivateKeysResponseModel AccountKeys { get; set; } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,17 +3,22 @@ | |
|
|
||
| using System.ComponentModel.DataAnnotations; | ||
| using Bit.Core.Entities; | ||
| using Bit.Core.KeyManagement.Models.Api.Request; | ||
| using Bit.Core.Utilities; | ||
|
|
||
| namespace Bit.Core.Auth.Models.Api.Request.Accounts; | ||
|
|
||
| public class KeysRequestModel | ||
| { | ||
| [Obsolete("Use AccountKeys.AccountPublicKey instead")] | ||
| [Required] | ||
| public string PublicKey { get; set; } | ||
| [Obsolete("Use AccountKeys.UserKeyEncryptedAccountPrivateKey instead")] | ||
| [Required] | ||
| public string EncryptedPrivateKey { get; set; } | ||
| public AccountKeysRequestModel AccountKeys { get; set; } | ||
quexten marked this conversation as resolved.
Show resolved
Hide resolved
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This creates an ambiguous state where:
The current Recommendation: Add validation to ensure at least one set of keys is provided: public class KeysRequestModel : IValidatableObject
{
[Obsolete("Use AccountKeys.AccountPublicKey instead")]
public string? PublicKey { get; set; }
[Obsolete("Use AccountKeys.UserKeyEncryptedAccountPrivateKey instead")]
public string? EncryptedPrivateKey { get; set; }
public AccountKeysRequestModel? AccountKeys { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
// Either new AccountKeys or both legacy fields must be provided
if (AccountKeys == null &&
(string.IsNullOrWhiteSpace(PublicKey) || string.IsNullOrWhiteSpace(EncryptedPrivateKey)))
{
yield return new ValidationResult(
"Either AccountKeys or both PublicKey and EncryptedPrivateKey must be provided.",
new[] { nameof(AccountKeys), nameof(PublicKey), nameof(EncryptedPrivateKey) });
}
}
}This would also allow removing the |
||
|
|
||
| [Obsolete("Use SetAccountKeysForUserCommand instead")] | ||
| public User ToUser(User existingUser) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(PublicKey) || string.IsNullOrWhiteSpace(EncryptedPrivateKey)) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,7 @@ | |
| using Bit.Core.Entities; | ||
| using Bit.Core.Exceptions; | ||
| using Bit.Core.KeyManagement.Kdf; | ||
| using Bit.Core.KeyManagement.Models.Api.Request; | ||
| using Bit.Core.KeyManagement.Models.Data; | ||
| using Bit.Core.KeyManagement.Queries.Interfaces; | ||
| using Bit.Core.Repositories; | ||
|
|
@@ -38,6 +39,7 @@ public class AccountsControllerTests : IDisposable | |
| private readonly IUserAccountKeysQuery _userAccountKeysQuery; | ||
| private readonly ITwoFactorEmailService _twoFactorEmailService; | ||
| private readonly IChangeKdfCommand _changeKdfCommand; | ||
| private readonly IUserRepository _userRepository; | ||
|
|
||
| public AccountsControllerTests() | ||
| { | ||
|
|
@@ -53,6 +55,7 @@ public AccountsControllerTests() | |
| _userAccountKeysQuery = Substitute.For<IUserAccountKeysQuery>(); | ||
| _twoFactorEmailService = Substitute.For<ITwoFactorEmailService>(); | ||
| _changeKdfCommand = Substitute.For<IChangeKdfCommand>(); | ||
| _userRepository = Substitute.For<IUserRepository>(); | ||
|
|
||
| _sut = new AccountsController( | ||
| _organizationService, | ||
|
|
@@ -66,7 +69,8 @@ public AccountsControllerTests() | |
| _featureService, | ||
| _userAccountKeysQuery, | ||
| _twoFactorEmailService, | ||
| _changeKdfCommand | ||
| _changeKdfCommand, | ||
| _userRepository | ||
| ); | ||
| } | ||
|
|
||
|
|
@@ -738,5 +742,79 @@ private void ConfigureUserServiceToReturnNullUserId() | |
| _userService.GetUserByIdAsync(Arg.Any<Guid>()) | ||
| .Returns(Task.FromResult((User)null)); | ||
| } | ||
|
|
||
| [Theory, BitAutoData] | ||
| public async Task PostKeys_WithAccountKeys_CallsSetV2AccountCryptographicState( | ||
| User user, | ||
| KeysRequestModel model) | ||
| { | ||
| // Arrange | ||
| user.PublicKey = "public-key"; | ||
| user.PrivateKey = "encrypted-private-key"; | ||
| model.AccountKeys = new AccountKeysRequestModel | ||
| { | ||
| UserKeyEncryptedAccountPrivateKey = "wrapped-private-key", | ||
| AccountPublicKey = "public-key", | ||
| PublicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairRequestModel | ||
| { | ||
| PublicKey = "public-key", | ||
| WrappedPrivateKey = "wrapped-private-key", | ||
| SignedPublicKey = "signed-public-key" | ||
| }, | ||
| SignatureKeyPair = new SignatureKeyPairRequestModel | ||
| { | ||
| VerifyingKey = "verifying-key", | ||
| SignatureAlgorithm = "ed25519", | ||
| WrappedSigningKey = "wrapped-signing-key" | ||
| }, | ||
| SecurityState = new SecurityStateModel | ||
| { | ||
| SecurityState = "security-state", | ||
| SecurityVersion = 2 | ||
| } | ||
| }; | ||
|
|
||
| _userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user); | ||
| _featureService.IsEnabled(Bit.Core.FeatureFlagKeys.ReturnErrorOnExistingKeypair).Returns(false); | ||
|
|
||
| // Act | ||
| var result = await _sut.PostKeys(model); | ||
|
|
||
| // Assert | ||
| await _userRepository.Received(1).SetV2AccountCryptographicStateAsync( | ||
| user.Id, | ||
| Arg.Any<UserAccountKeysData>()); | ||
| await _userService.DidNotReceiveWithAnyArgs().SaveUserAsync(Arg.Any<User>()); | ||
| Assert.NotNull(result); | ||
| Assert.Equal("keys", result.Object); | ||
| } | ||
|
Comment on lines
+788
to
+790
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐ TEST COVERAGE GAP: This test only validates that the result is not null and has the correct object type. It doesn't verify:
Recommendation: Add comprehensive response validation: // Assert
await _userRepository.Received(1).SetV2AccountCryptographicStateAsync(
user.Id,
Arg.Any<UserAccountKeysData>());
await _userService.DidNotReceiveWithAnyArgs().SaveUserAsync(Arg.Any<User>());
Assert.NotNull(result);
Assert.Equal("keys", result.Object);
Assert.Equal(user.Key, result.Key);
Assert.NotNull(result.AccountKeys);
Assert.NotNull(result.PublicKey);
Assert.NotNull(result.PrivateKey);Similar validation should be added to the |
||
|
|
||
| [Theory, BitAutoData] | ||
| public async Task PostKeys_WithoutAccountKeys_CallsSaveUser( | ||
| User user, | ||
| KeysRequestModel model) | ||
| { | ||
| // Arrange | ||
| user.PublicKey = null; | ||
| user.PrivateKey = null; | ||
| model.AccountKeys = null; | ||
| model.PublicKey = "public-key"; | ||
| model.EncryptedPrivateKey = "encrypted-private-key"; | ||
|
|
||
| _userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user); | ||
| _featureService.IsEnabled(Bit.Core.FeatureFlagKeys.ReturnErrorOnExistingKeypair).Returns(false); | ||
quexten marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // Act | ||
| var result = await _sut.PostKeys(model); | ||
|
|
||
| // Assert | ||
| await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u => | ||
| u.PublicKey == model.PublicKey && | ||
| u.PrivateKey == model.EncryptedPrivateKey)); | ||
| await _userRepository.DidNotReceiveWithAnyArgs() | ||
| .SetV2AccountCryptographicStateAsync(Arg.Any<Guid>(), Arg.Any<UserAccountKeysData>()); | ||
| Assert.NotNull(result); | ||
| Assert.Equal("keys", result.Object); | ||
| } | ||
|
Comment on lines
+816
to
+818
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐ TEST COVERAGE GAP: Similar to the V2 test, this test needs better response validation to catch potential bugs in response construction. Recommendation: Add assertions to verify the response data: // Assert
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u =>
u.PublicKey == model.PublicKey &&
u.PrivateKey == model.EncryptedPrivateKey));
await _userRepository.DidNotReceiveWithAnyArgs()
.SetV2AccountCryptographicStateAsync(Arg.Any<Guid>(), Arg.Any<UserAccountKeysData>());
Assert.NotNull(result);
Assert.Equal("keys", result.Object);
Assert.Equal(user.Key, result.Key);
Assert.NotNull(result.AccountKeys);
Assert.Equal(model.PublicKey, result.PublicKey);
Assert.Equal(model.EncryptedPrivateKey, result.PrivateKey);Note: This assumes the controller is fixed to use the updated user reference (see my comment on AccountsController.cs). |
||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
โ GOOD: Properly validating V2 encryption state with
IsV2Encryption()before callingSetV2AccountCryptographicStateAsync. This prevents invalid cryptographic states from being persisted.The repository method also performs this validation, providing defense-in-depth.