diff --git a/src/Api/Models/Response/DomainsResponseModel.cs b/src/Api/Models/Response/DomainsResponseModel.cs index 4df161f38e..82abddb4e4 100644 --- a/src/Api/Models/Response/DomainsResponseModel.cs +++ b/src/Api/Models/Response/DomainsResponseModel.cs @@ -8,10 +8,10 @@ using Bit.Core.Models.Api; namespace Bit.Api.Models.Response; -public class DomainsResponseModel : ResponseModel +public class DomainsResponseModel() : ResponseModel("domains") { public DomainsResponseModel(User user, bool excluded = true) - : base("domains") + : this() { if (user == null) { @@ -38,13 +38,13 @@ public class DomainsResponseModel : ResponseModel public IEnumerable GlobalEquivalentDomains { get; set; } - public class GlobalDomains + public class GlobalDomains() { public GlobalDomains( GlobalEquivalentDomainsType globalDomain, IEnumerable domains, IEnumerable excludedDomains, - bool excluded) + bool excluded) : this() { Type = (byte)globalDomain; Domains = domains; diff --git a/src/Api/Vault/Models/Response/SyncResponseModel.cs b/src/Api/Vault/Models/Response/SyncResponseModel.cs index dc34ffa46a..e19defce51 100644 --- a/src/Api/Vault/Models/Response/SyncResponseModel.cs +++ b/src/Api/Vault/Models/Response/SyncResponseModel.cs @@ -7,6 +7,7 @@ using Bit.Api.Tools.Models.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Response; using Bit.Core.Models.Api; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; @@ -18,7 +19,7 @@ using Bit.Core.Vault.Models.Data; namespace Bit.Api.Vault.Models.Response; -public class SyncResponseModel : ResponseModel +public class SyncResponseModel() : ResponseModel("sync") { public SyncResponseModel( GlobalSettings globalSettings, @@ -37,7 +38,7 @@ public class SyncResponseModel : ResponseModel bool excludeDomains, IEnumerable policies, IEnumerable sends) - : base("sync") + : this() { Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingingUser); @@ -54,6 +55,23 @@ public class SyncResponseModel : ResponseModel Domains = excludeDomains ? null : new DomainsResponseModel(user, false); Policies = policies?.Select(p => new PolicyResponseModel(p)) ?? new List(); Sends = sends.Select(s => new SendResponseModel(s, globalSettings)); + UserDecryption = new UserDecryptionResponseModel + { + MasterPasswordUnlock = user.HasMasterPassword() + ? new MasterPasswordUnlockResponseModel + { + Kdf = new MasterPasswordUnlockKdfResponseModel + { + KdfType = user.Kdf, + Iterations = user.KdfIterations, + Memory = user.KdfMemory, + Parallelism = user.KdfParallelism + }, + MasterKeyEncryptedUserKey = user.Key!, + Salt = user.Email.ToLowerInvariant() + } + : null + }; } public ProfileResponseModel Profile { get; set; } @@ -63,4 +81,5 @@ public class SyncResponseModel : ResponseModel public DomainsResponseModel Domains { get; set; } public IEnumerable Policies { get; set; } public IEnumerable Sends { get; set; } + public UserDecryptionResponseModel UserDecryption { get; set; } } diff --git a/src/Core/KeyManagement/Models/Response/UserDecryptionResponseModel.cs b/src/Core/KeyManagement/Models/Response/UserDecryptionResponseModel.cs new file mode 100644 index 0000000000..a4d259a00a --- /dev/null +++ b/src/Core/KeyManagement/Models/Response/UserDecryptionResponseModel.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.KeyManagement.Models.Response; + +public class UserDecryptionResponseModel +{ + /// + /// Returns the unlock data when the user has a master password that can be used to decrypt their vault. + /// + public MasterPasswordUnlockResponseModel? MasterPasswordUnlock { get; set; } +} diff --git a/test/Api.IntegrationTest/Vault/Controllers/SyncControllerTests.cs b/test/Api.IntegrationTest/Vault/Controllers/SyncControllerTests.cs new file mode 100644 index 0000000000..16d4b0fb66 --- /dev/null +++ b/test/Api.IntegrationTest/Vault/Controllers/SyncControllerTests.cs @@ -0,0 +1,100 @@ +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Api.Vault.Models.Response; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Api.IntegrationTest.Vault.Controllers; + +public class SyncControllerTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + + private readonly LoginHelper _loginHelper; + + private readonly IUserRepository _userRepository; + private string _ownerEmail = null!; + + public SyncControllerTests(ApiApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + _userRepository = _factory.GetService(); + } + + public async Task InitializeAsync() + { + _ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + // [BitAutoData] + public async Task Get_HaveNoMasterPassword_UserDecryptionMasterPasswordUnlockIsNull() + { + var tempEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(tempEmail); + await _loginHelper.LoginAsync(tempEmail); + + // Remove user's password. + var user = await _userRepository.GetByEmailAsync(tempEmail); + Assert.NotNull(user); + user.MasterPassword = null; + await _userRepository.UpsertAsync(user); + + var response = await _client.GetAsync("/sync"); + response.EnsureSuccessStatusCode(); + + var syncResponseModel = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(syncResponseModel); + Assert.NotNull(syncResponseModel.UserDecryption); + Assert.Null(syncResponseModel.UserDecryption.MasterPasswordUnlock); + } + + [Theory] + [BitAutoData(KdfType.PBKDF2_SHA256, 654_321, null, null)] + [BitAutoData(KdfType.Argon2id, 11, 128, 5)] + public async Task Get_HaveMasterPassword_UserDecryptionMasterPasswordUnlockNotNull( + KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) + { + var tempEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(tempEmail); + await _loginHelper.LoginAsync(tempEmail); + + // Change KDF settings + var user = await _userRepository.GetByEmailAsync(tempEmail); + Assert.NotNull(user); + user.Kdf = kdfType; + user.KdfIterations = kdfIterations; + user.KdfMemory = kdfMemory; + user.KdfParallelism = kdfParallelism; + await _userRepository.UpsertAsync(user); + + var response = await _client.GetAsync("/sync"); + response.EnsureSuccessStatusCode(); + + var syncResponseModel = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(syncResponseModel); + Assert.NotNull(syncResponseModel.UserDecryption); + Assert.NotNull(syncResponseModel.UserDecryption.MasterPasswordUnlock); + Assert.NotNull(syncResponseModel.UserDecryption.MasterPasswordUnlock.Kdf); + Assert.Equal(kdfType, syncResponseModel.UserDecryption.MasterPasswordUnlock.Kdf.KdfType); + Assert.Equal(kdfIterations, syncResponseModel.UserDecryption.MasterPasswordUnlock.Kdf.Iterations); + Assert.Equal(kdfMemory, syncResponseModel.UserDecryption.MasterPasswordUnlock.Kdf.Memory); + Assert.Equal(kdfParallelism, syncResponseModel.UserDecryption.MasterPasswordUnlock.Kdf.Parallelism); + Assert.Equal(user.Key, syncResponseModel.UserDecryption.MasterPasswordUnlock.MasterKeyEncryptedUserKey); + Assert.Equal(user.Email.ToLower(), syncResponseModel.UserDecryption.MasterPasswordUnlock.Salt); + } +} diff --git a/test/Api.Test/Vault/Controllers/SyncControllerTests.cs b/test/Api.Test/Vault/Controllers/SyncControllerTests.cs index ebbfc2a2ba..54db1e4053 100644 --- a/test/Api.Test/Vault/Controllers/SyncControllerTests.cs +++ b/test/Api.Test/Vault/Controllers/SyncControllerTests.cs @@ -317,6 +317,55 @@ public class SyncControllerTests } } + [Theory] + [BitAutoData] + public async Task Get_HaveNoMasterPassword_UserDecryptionMasterPasswordUnlockIsNull( + User user, SutProvider sutProvider) + { + user.EquivalentDomains = null; + user.ExcludedGlobalEquivalentDomains = null; + + user.MasterPassword = null; + + var userService = sutProvider.GetDependency(); + userService.GetUserByPrincipalAsync(Arg.Any()).ReturnsForAnyArgs(user); + + var result = await sutProvider.Sut.Get(); + + Assert.Null(result.UserDecryption.MasterPasswordUnlock); + } + + [Theory] + [BitAutoData(KdfType.PBKDF2_SHA256, 654_321, null, null)] + [BitAutoData(KdfType.Argon2id, 11, 128, 5)] + public async Task Get_HaveMasterPassword_UserDecryptionMasterPasswordUnlockNotNull( + KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism, + User user, SutProvider sutProvider) + { + user.EquivalentDomains = null; + user.ExcludedGlobalEquivalentDomains = null; + + user.Key = "test-key"; + user.MasterPassword = "test-master-password"; + user.Kdf = kdfType; + user.KdfIterations = kdfIterations; + user.KdfMemory = kdfMemory; + user.KdfParallelism = kdfParallelism; + + var userService = sutProvider.GetDependency(); + userService.GetUserByPrincipalAsync(Arg.Any()).ReturnsForAnyArgs(user); + + var result = await sutProvider.Sut.Get(); + + Assert.NotNull(result.UserDecryption.MasterPasswordUnlock); + Assert.NotNull(result.UserDecryption.MasterPasswordUnlock.Kdf); + Assert.Equal(kdfType, result.UserDecryption.MasterPasswordUnlock.Kdf.KdfType); + Assert.Equal(kdfIterations, result.UserDecryption.MasterPasswordUnlock.Kdf.Iterations); + Assert.Equal(kdfMemory, result.UserDecryption.MasterPasswordUnlock.Kdf.Memory); + Assert.Equal(kdfParallelism, result.UserDecryption.MasterPasswordUnlock.Kdf.Parallelism); + Assert.Equal(user.Key, result.UserDecryption.MasterPasswordUnlock.MasterKeyEncryptedUserKey); + Assert.Equal(user.Email.ToLower(), result.UserDecryption.MasterPasswordUnlock.Salt); + } private async Task AssertMethodsCalledAsync(IUserService userService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,