-
Notifications
You must be signed in to change notification settings - Fork 1.7k
[PM-39454] feat(km): reject unlock data whose user key id does not match the stored one #8111
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: km/user-key-id-05-write-path
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 |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| using Bit.Core.Auth.Entities; | ||
| using Bit.Core.Auth.Models.Data; | ||
| using Bit.Core.Entities; | ||
| using Bit.Core.Exceptions; | ||
| using Bit.Core.KeyManagement.Models.Data; | ||
| using Bit.Core.Tools.Entities; | ||
| using Bit.Core.Vault.Entities; | ||
|
|
@@ -28,4 +29,23 @@ public class BaseRotateUserAccountKeysData | |
| /// authoritative key id of the request: it is the value persisted as the account's key id. | ||
| /// </summary> | ||
| public KeyId? UserKeyId { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// Validates the provided key id against the key id of this request. This should be used to verify | ||
| /// that the items directly encrypted by the user-key (cipher-keys, private-key, signature-key) are | ||
| /// encrypted by the correct key. | ||
| /// </summary> | ||
| public void ValidateContainedKeyIdMatches(KeyId? containedKeyId) | ||
|
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. ❓ QUESTION: Should a rotation also reject a key id that equals the one already stored for the account? DetailsThis validates the unlock data against the request's own key id, but nothing compares the request's key id to
|
||
| { | ||
| if (containedKeyId == null) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| if (containedKeyId != UserKeyId) | ||
| { | ||
| throw new BadRequestException( | ||
| "The user key id contained in the unlock data must match the user key id of the key rotation."); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| using Bit.Core.Exceptions; | ||
| using Bit.Core.KeyManagement.Models.Data; | ||
| using Bit.Core.KeyManagement.UserKey.Models.Data; | ||
| using Xunit; | ||
|
|
||
| namespace Bit.Core.Test.KeyManagement.UserKey.Models.Data; | ||
|
|
||
| public class BaseRotateUserAccountKeysDataTests | ||
| { | ||
| private const string _keyIdA = "0123456789abcdef0123456789abcdef"; | ||
| private const string _keyIdB = "fedcba9876543210fedcba9876543210"; | ||
|
|
||
| private static BaseRotateUserAccountKeysData MakeData(string? rotationKeyId) => new() | ||
| { | ||
| AccountKeys = new UserAccountKeysData | ||
| { | ||
| PublicKeyEncryptionKeyPairData = | ||
| new PublicKeyEncryptionKeyPairData("mockWrappedPrivateKey", "mockPublicKey") | ||
| }, | ||
| EmergencyAccesses = [], | ||
| OrganizationUsers = [], | ||
| WebAuthnKeys = [], | ||
| DeviceKeys = [], | ||
| Ciphers = [], | ||
| Folders = [], | ||
| Sends = [], | ||
| UserKeyId = KeyId.FromHexEncodedString(rotationKeyId) | ||
| }; | ||
|
|
||
| [Fact] | ||
| public void ValidateContainedKeyIdMatches_WhenKeyIdsMatch_DoesNotThrow() | ||
| { | ||
| MakeData(_keyIdA).ValidateContainedKeyIdMatches(KeyId.FromHexEncodedString(_keyIdA)); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void ValidateContainedKeyIdMatches_WhenKeyIdsDiffer_Throws() | ||
| { | ||
| var exception = Assert.Throws<BadRequestException>(() => | ||
| MakeData(_keyIdA).ValidateContainedKeyIdMatches(KeyId.FromHexEncodedString(_keyIdB))); | ||
|
|
||
| Assert.Equal("The user key id contained in the unlock data must match the user key id of the key rotation.", | ||
| exception.Message); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void ValidateContainedKeyIdMatches_WhenContainedKeyIdIsAbsent_DoesNotThrow() | ||
| { | ||
| // Clients that predate the field carry no key id in the unlock data. | ||
| MakeData(_keyIdA).ValidateContainedKeyIdMatches(null); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void ValidateContainedKeyIdMatches_WhenRotationKeyIdIsAbsent_Throws() | ||
| { | ||
| // Only the rotation's own key id is persisted, so a request that reports one solely in its | ||
| // unlock data would silently leave the server with no key id for the new user key. | ||
| Assert.Throws<BadRequestException>(() => | ||
| MakeData(null).ValidateContainedKeyIdMatches(KeyId.FromHexEncodedString(_keyIdA))); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void ValidateContainedKeyIdMatches_WhenNeitherKeyIdIsPresent_DoesNotThrow() | ||
| { | ||
| MakeData(null).ValidateContainedKeyIdMatches(null); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| using Bit.Core.Entities; | ||
| using Bit.Core.Enums; | ||
| using Bit.Core.Exceptions; | ||
| using Bit.Core.KeyManagement.Models.Data; | ||
| using Bit.Core.KeyManagement.UserKey.Models.Data; | ||
| using Bit.Test.Common.AutoFixture.Attributes; | ||
| using Xunit; | ||
|
|
||
| namespace Bit.Core.Test.KeyManagement.UserKey.Models.Data; | ||
|
|
||
| public class MasterPasswordRotateUserAccountKeysDataTests | ||
| { | ||
| private const string _keyIdA = "0123456789abcdef0123456789abcdef"; | ||
| private const string _keyIdB = "fedcba9876543210fedcba9876543210"; | ||
| private const string _mockMasterKeyWrappedUserKey = "mockMasterKeyWrappedUserKey"; | ||
|
|
||
| private static KdfSettings ValidKdf | ||
| { | ||
| get => new() { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000, Memory = null, Parallelism = null }; | ||
| } | ||
|
|
||
| private static void SetupValidUser(User user) | ||
| { | ||
| user.Email = "test@example.com"; | ||
| user.MasterPasswordSalt = null; | ||
| user.Key = "mockUserKey"; | ||
| user.MasterPassword = "mockMasterPasswordHash"; | ||
| user.Kdf = ValidKdf.KdfType; | ||
| user.KdfIterations = ValidKdf.Iterations; | ||
| user.KdfMemory = ValidKdf.Memory; | ||
| user.KdfParallelism = ValidKdf.Parallelism; | ||
| } | ||
|
|
||
| private static MasterPasswordRotateUserAccountKeysData CreateModel(string salt, string? unlockUserKeyId, | ||
| string? rotationUserKeyId) => | ||
| new() | ||
| { | ||
| MasterPasswordUnlockData = new MasterPasswordUnlockData | ||
| { | ||
| Kdf = ValidKdf, | ||
| MasterKeyWrappedUserKey = _mockMasterKeyWrappedUserKey, | ||
| Salt = salt, | ||
| UserKeyId = KeyId.FromHexEncodedString(unlockUserKeyId) | ||
| }, | ||
| BaseData = new BaseRotateUserAccountKeysData | ||
| { | ||
| AccountKeys = new UserAccountKeysData | ||
| { | ||
| PublicKeyEncryptionKeyPairData = | ||
| new PublicKeyEncryptionKeyPairData("mockWrappedPrivateKey", "mockPublicKey") | ||
| }, | ||
| EmergencyAccesses = [], | ||
| OrganizationUsers = [], | ||
| WebAuthnKeys = [], | ||
| DeviceKeys = [], | ||
| Ciphers = [], | ||
| Folders = [], | ||
| Sends = [], | ||
| UserKeyId = KeyId.FromHexEncodedString(rotationUserKeyId) | ||
| } | ||
| }; | ||
|
|
||
| [Theory] | ||
| [BitAutoData] | ||
| public void ValidateForUser_UnlockKeyIdMatchesRotationKeyId_DoesNotThrow(User user) | ||
| { | ||
| SetupValidUser(user); | ||
| var model = CreateModel(user.Email, _keyIdA, _keyIdA); | ||
|
|
||
| model.ValidateForUser(user); | ||
| } | ||
|
|
||
| [Theory] | ||
| [BitAutoData] | ||
| public void ValidateForUser_NoKeyIds_DoesNotThrow(User user) | ||
| { | ||
| // Clients that predate the field send neither key id. | ||
| SetupValidUser(user); | ||
| var model = CreateModel(user.Email, null, null); | ||
|
|
||
| model.ValidateForUser(user); | ||
| } | ||
|
|
||
| [Theory] | ||
| [BitAutoData] | ||
| public void ValidateForUser_UnlockKeyIdDiffersFromRotationKeyId_ThrowsBadRequestException(User user) | ||
| { | ||
| SetupValidUser(user); | ||
| var model = CreateModel(user.Email, _keyIdB, _keyIdA); | ||
|
|
||
| Assert.Throws<BadRequestException>(() => model.ValidateForUser(user)); | ||
| } | ||
|
|
||
| [Theory] | ||
| [BitAutoData] | ||
| public void ValidateForUser_UnlockKeyIdWithoutRotationKeyId_ThrowsBadRequestException(User user) | ||
| { | ||
| // The rotation's key id is the only one persisted, so a request that reports one solely in | ||
| // its unlock data must not pass as if the key id were tracked. | ||
| SetupValidUser(user); | ||
| var model = CreateModel(user.Email, _keyIdA, null); | ||
|
|
||
| Assert.Throws<BadRequestException>(() => model.ValidateForUser(user)); | ||
| } | ||
| } |
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.
♻️ DEBT: No test covers the rejection this line adds, unlike the salt checks right above it.
Details and fix
ChangeKdfCommandTestshasChangeKdfAsync_AuthDataSaltMismatch_ThrowsandChangeKdfAsync_UnlockDataSaltMismatch_Throwsfor the two duplicated salt checks, each assertingBadRequestExceptionandSaveUpdateExistingKdfConfigurationAsyncreceived 0 calls. Every existing test in that file buildsMasterPasswordUnlockDatawithout aUserKeyId, so the new call only ever takes the early-return path — deleting the line would keep the suite green.Suggest mirroring the salt tests: