From 2a458807a536ad19005f3d8e2191a7c6618de88c Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 26 Jan 2026 12:04:23 -0600
Subject: [PATCH 01/13] [deps] Vault: Update AngleSharp to 1.4.0 (#5868)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Nick Krantz <125900171+nick-livefront@users.noreply.github.com>
---
src/Icons/Icons.csproj | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Icons/Icons.csproj b/src/Icons/Icons.csproj
index 97e9562183..9dc39eab1e 100644
--- a/src/Icons/Icons.csproj
+++ b/src/Icons/Icons.csproj
@@ -9,7 +9,7 @@
-
+
From 440f5dc0daf569ca12431ebc67ebe5605c031ed9 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 27 Jan 2026 15:36:13 +0100
Subject: [PATCH 02/13] [deps]: Update github/codeql-action action to v4.31.10
(#6906)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.github/workflows/build.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f3cc279a58..e23711e449 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -270,7 +270,7 @@ jobs:
output-format: sarif
- name: Upload Grype results to GitHub
- uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
+ uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
with:
sarif_file: ${{ steps.container-scan.outputs.sarif }}
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
From 67f8cbf5b31b11c22ec6385217a93b21ad8b34d4 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 27 Jan 2026 15:37:01 +0100
Subject: [PATCH 03/13] [deps]: Update anchore/scan-action action to v7.2.3
(#6905)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.github/workflows/build.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index e23711e449..1adb6a8a1a 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -263,7 +263,7 @@ jobs:
- name: Scan Docker image
id: container-scan
- uses: anchore/scan-action@3c9a191a0fbab285ca6b8530b5de5a642cba332f # v7.2.2
+ uses: anchore/scan-action@62b74fb7bb810d2c45b1865f47a77655621862a5 # v7.2.3
with:
image: ${{ steps.image-tags.outputs.primary_tag }}
fail-build: false
From 898904a673fad374caa71f5e9cedb2798fbc2ee0 Mon Sep 17 00:00:00 2001
From: Jared McCannon
Date: Tue, 27 Jan 2026 09:03:06 -0600
Subject: [PATCH 04/13] Renamed for clarity (#6902)
---
.../AutomaticUserConfirmationPolicyRequirement.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs
index 3430f33a77..9b6cf86257 100644
--- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs
+++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs
@@ -19,7 +19,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements
/// Collection of policy details that apply to this user id
public class AutomaticUserConfirmationPolicyRequirement(IEnumerable policyDetails) : IPolicyRequirement
{
- public bool CannotBeGrantedEmergencyAccess() => policyDetails.Any();
+ public bool CannotHaveEmergencyAccess() => policyDetails.Any();
public bool CannotJoinProvider() => policyDetails.Any();
From 80eec2df853c111af3455d48769f32624818043f Mon Sep 17 00:00:00 2001
From: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
Date: Wed, 28 Jan 2026 04:11:15 +1000
Subject: [PATCH 05/13] [PM-23768] Public API - add restore and revoke member
endpoint (#6859)
* Add restore and revoke to public api
* Follow naming conventions
* Use POST instead of PUT
* hello claude
* Update test names
* Actually fix test names
* Add JsonConstructor attr
* Fix test
---
.../Public/Controllers/MembersController.cs | 67 ++++++++-
.../Public/Response/ErrorResponseModel.cs | 13 +-
.../Controllers/MembersControllerTests.cs | 134 ++++++++++++++++++
3 files changed, 206 insertions(+), 8 deletions(-)
diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs
index 58e5db18c2..220c812cae 100644
--- a/src/Api/AdminConsole/Public/Controllers/MembersController.cs
+++ b/src/Api/AdminConsole/Public/Controllers/MembersController.cs
@@ -2,12 +2,16 @@
using Bit.Api.AdminConsole.Public.Models.Request;
using Bit.Api.AdminConsole.Public.Models.Response;
using Bit.Api.Models.Public.Response;
+using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
+using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
+using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
+using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
@@ -30,6 +34,8 @@ public class MembersController : Controller
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
+ private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommandV2;
+ private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
public MembersController(
IOrganizationUserRepository organizationUserRepository,
@@ -42,7 +48,9 @@ public class MembersController : Controller
IOrganizationRepository organizationRepository,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
- IResendOrganizationInviteCommand resendOrganizationInviteCommand)
+ IResendOrganizationInviteCommand resendOrganizationInviteCommand,
+ IRevokeOrganizationUserCommand revokeOrganizationUserCommandV2,
+ IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
{
_organizationUserRepository = organizationUserRepository;
_groupRepository = groupRepository;
@@ -55,6 +63,8 @@ public class MembersController : Controller
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
+ _revokeOrganizationUserCommandV2 = revokeOrganizationUserCommandV2;
+ _restoreOrganizationUserCommand = restoreOrganizationUserCommand;
}
///
@@ -258,4 +268,59 @@ public class MembersController : Controller
await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId!.Value, null, id);
return new OkResult();
}
+
+ ///
+ /// Revoke a member's access to an organization.
+ ///
+ /// The ID of the member to be revoked.
+ [HttpPost("{id}/revoke")]
+ [ProducesResponseType((int)HttpStatusCode.OK)]
+ [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
+ [ProducesResponseType((int)HttpStatusCode.NotFound)]
+ public async Task Revoke(Guid id)
+ {
+ var organizationUser = await _organizationUserRepository.GetByIdAsync(id);
+ if (organizationUser == null || organizationUser.OrganizationId != _currentContext.OrganizationId)
+ {
+ return new NotFoundResult();
+ }
+
+ var request = new RevokeOrganizationUsersRequest(
+ _currentContext.OrganizationId!.Value,
+ [id],
+ new SystemUser(EventSystemUser.PublicApi)
+ );
+
+ var results = await _revokeOrganizationUserCommandV2.RevokeUsersAsync(request);
+ var result = results.Single();
+
+ return result.Result.Match(
+ error => new BadRequestObjectResult(new ErrorResponseModel(error.Message)),
+ _ => new OkResult()
+ );
+ }
+
+ ///
+ /// Restore a member.
+ ///
+ ///
+ /// Restores a previously revoked member of the organization.
+ ///
+ /// The identifier of the member to be restored.
+ [HttpPost("{id}/restore")]
+ [ProducesResponseType((int)HttpStatusCode.OK)]
+ [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
+ [ProducesResponseType((int)HttpStatusCode.NotFound)]
+ public async Task Restore(Guid id)
+ {
+ var organizationUser = await _organizationUserRepository.GetByIdAsync(id);
+ if (organizationUser == null || organizationUser.OrganizationId != _currentContext.OrganizationId)
+ {
+ return new NotFoundResult();
+ }
+
+ await _restoreOrganizationUserCommand.RestoreUserAsync(organizationUser, EventSystemUser.PublicApi);
+
+ return new OkResult();
+ }
}
diff --git a/src/Api/Models/Public/Response/ErrorResponseModel.cs b/src/Api/Models/Public/Response/ErrorResponseModel.cs
index c5bb06d02e..a40b0c9569 100644
--- a/src/Api/Models/Public/Response/ErrorResponseModel.cs
+++ b/src/Api/Models/Public/Response/ErrorResponseModel.cs
@@ -1,7 +1,5 @@
-// FIXME: Update this file to be null safe and then delete the line below
-#nullable disable
-
-using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Bit.Api.Models.Public.Response;
@@ -46,13 +44,14 @@ public class ErrorResponseModel : IResponseModel
{ }
public ErrorResponseModel(string errorKey, string errorValue)
- : this(errorKey, new string[] { errorValue })
+ : this(errorKey, [errorValue])
{ }
public ErrorResponseModel(string errorKey, IEnumerable errorValues)
: this(new Dictionary> { { errorKey, errorValues } })
{ }
+ [JsonConstructor]
public ErrorResponseModel(string message, Dictionary> errors)
{
Message = message;
@@ -70,10 +69,10 @@ public class ErrorResponseModel : IResponseModel
///
/// The request model is invalid.
[Required]
- public string Message { get; set; }
+ public string Message { get; init; }
///
/// If multiple errors occurred, they are listed in dictionary. Errors related to a specific
/// request parameter will include a dictionary key describing that parameter.
///
- public Dictionary> Errors { get; set; }
+ public Dictionary>? Errors { get; }
}
diff --git a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs
index 9f2512038e..e4bdbdb174 100644
--- a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs
+++ b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs
@@ -264,4 +264,138 @@ public class MembersControllerTests : IClassFixture, IAsy
new Permissions { CreateNewCollections = true, ManageScim = true, ManageGroups = true, ManageUsers = true },
orgUser.GetPermissions());
}
+
+ [Fact]
+ public async Task Revoke_Member_Success()
+ {
+ var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
+ _factory, _organization.Id, OrganizationUserType.User);
+
+ var response = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var updatedUser = await _factory.GetService()
+ .GetByIdAsync(orgUser.Id);
+ Assert.NotNull(updatedUser);
+ Assert.Equal(OrganizationUserStatusType.Revoked, updatedUser.Status);
+ }
+
+ [Fact]
+ public async Task Revoke_AlreadyRevoked_ReturnsBadRequest()
+ {
+ var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
+ _factory, _organization.Id, OrganizationUserType.User);
+
+ var revokeResponse = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null);
+ Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode);
+
+ var response = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ var error = await response.Content.ReadFromJsonAsync();
+ Assert.Equal("Already revoked.", error?.Message);
+ }
+
+ [Fact]
+ public async Task Revoke_NotFound_ReturnsNotFound()
+ {
+ var response = await _client.PostAsync($"/public/members/{Guid.NewGuid()}/revoke", null);
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Revoke_DifferentOrganization_ReturnsNotFound()
+ {
+ // Create a different organization
+ var ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
+ await _factory.LoginWithNewAccount(ownerEmail);
+ var (otherOrganization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually,
+ ownerEmail: ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card);
+
+ // Create a user in the other organization
+ var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
+ _factory, otherOrganization.Id, OrganizationUserType.User);
+
+ // Re-authenticate with the original organization
+ await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id);
+
+ // Try to revoke the user from the other organization
+ var response = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Restore_Member_Success()
+ {
+ // Invite a user to revoke
+ var email = $"integration-test{Guid.NewGuid()}@example.com";
+ var inviteRequest = new MemberCreateRequestModel
+ {
+ Email = email,
+ Type = OrganizationUserType.User,
+ };
+
+ var inviteResponse = await _client.PostAsync("/public/members", JsonContent.Create(inviteRequest));
+ Assert.Equal(HttpStatusCode.OK, inviteResponse.StatusCode);
+ var invitedMember = await inviteResponse.Content.ReadFromJsonAsync();
+ Assert.NotNull(invitedMember);
+
+ // Revoke the invited user
+ var revokeResponse = await _client.PostAsync($"/public/members/{invitedMember.Id}/revoke", null);
+ Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode);
+
+ // Restore the user
+ var response = await _client.PostAsync($"/public/members/{invitedMember.Id}/restore", null);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ // Verify user is restored to Invited state
+ var updatedUser = await _factory.GetService()
+ .GetByIdAsync(invitedMember.Id);
+ Assert.NotNull(updatedUser);
+ Assert.Equal(OrganizationUserStatusType.Invited, updatedUser.Status);
+ }
+
+ [Fact]
+ public async Task Restore_AlreadyActive_ReturnsBadRequest()
+ {
+ var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
+ _factory, _organization.Id, OrganizationUserType.User);
+
+ var response = await _client.PostAsync($"/public/members/{orgUser.Id}/restore", null);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ var error = await response.Content.ReadFromJsonAsync();
+ Assert.Equal("Already active.", error?.Message);
+ }
+
+ [Fact]
+ public async Task Restore_NotFound_ReturnsNotFound()
+ {
+ var response = await _client.PostAsync($"/public/members/{Guid.NewGuid()}/restore", null);
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Restore_DifferentOrganization_ReturnsNotFound()
+ {
+ // Create a different organization
+ var ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
+ await _factory.LoginWithNewAccount(ownerEmail);
+ var (otherOrganization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually,
+ ownerEmail: ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card);
+
+ // Create a user in the other organization
+ var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
+ _factory, otherOrganization.Id, OrganizationUserType.User);
+
+ // Re-authenticate with the original organization
+ await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id);
+
+ // Try to restore the user from the other organization
+ var response = await _client.PostAsync($"/public/members/{orgUser.Id}/restore", null);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
}
From 03fcdc2852c7390f5843e77c4fc0533195ee93cc Mon Sep 17 00:00:00 2001
From: Dave <3836813+enmande@users.noreply.github.com>
Date: Tue, 27 Jan 2026 14:26:07 -0500
Subject: [PATCH 06/13] feat(account-switching) [PM-5594]: Add Safari
account-switching feature flag. (#6829)
---
src/Core/Constants.cs | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index 56c26ba8c7..e229023e27 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -166,6 +166,7 @@ public static class FeatureFlagKeys
public const string OrganizationConfirmationEmail = "pm-28402-update-confirmed-to-org-email-template";
public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow";
public const string PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin";
+ public const string SafariAccountSwitching = "pm-5594-safari-account-switching";
public const string PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password";
/* Autofill Team */
From f578dab94f2e92d460a39b2b8d8f9db596fadae5 Mon Sep 17 00:00:00 2001
From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com>
Date: Tue, 27 Jan 2026 21:38:09 +0100
Subject: [PATCH 07/13] user reset password key can be empty string (#6871)
---
.../OrganizationUserRotationValidator.cs | 3 +-
.../OrganizationUserRotationValidatorTests.cs | 38 +++++++++++++++++++
2 files changed, 39 insertions(+), 2 deletions(-)
diff --git a/src/Api/KeyManagement/Validators/OrganizationUserRotationValidator.cs b/src/Api/KeyManagement/Validators/OrganizationUserRotationValidator.cs
index 5023521fe3..835965e2d6 100644
--- a/src/Api/KeyManagement/Validators/OrganizationUserRotationValidator.cs
+++ b/src/Api/KeyManagement/Validators/OrganizationUserRotationValidator.cs
@@ -34,8 +34,7 @@ public class OrganizationUserRotationValidator : IRotationValidator o.ResetPasswordKey != null).ToList();
-
+ existing = existing.Where(o => !string.IsNullOrEmpty(o.ResetPasswordKey)).ToList();
foreach (var ou in existing)
{
diff --git a/test/Api.Test/KeyManagement/Validators/OrganizationUserRotationValidatorTests.cs b/test/Api.Test/KeyManagement/Validators/OrganizationUserRotationValidatorTests.cs
index 964c801903..a939636fc2 100644
--- a/test/Api.Test/KeyManagement/Validators/OrganizationUserRotationValidatorTests.cs
+++ b/test/Api.Test/KeyManagement/Validators/OrganizationUserRotationValidatorTests.cs
@@ -69,6 +69,44 @@ public class OrganizationUserRotationValidatorTests
Assert.Empty(result);
}
+ [Theory]
+ [BitAutoData([null])]
+ [BitAutoData("")]
+ public async Task ValidateAsync_OrgUsersWithNullOrEmptyResetPasswordKey_FiltersOutInvalidKeys(
+ string? invalidResetPasswordKey,
+ SutProvider sutProvider, User user,
+ ResetPasswordWithOrgIdRequestModel validResetPasswordKey)
+ {
+ // Arrange
+ var existingUserResetPassword = new List
+ {
+ // Valid org user with reset password key
+ new OrganizationUser
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = validResetPasswordKey.OrganizationId,
+ ResetPasswordKey = validResetPasswordKey.ResetPasswordKey
+ },
+ // Invalid org user with null or empty reset password key - should be filtered out
+ new OrganizationUser
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = Guid.NewGuid(),
+ ResetPasswordKey = invalidResetPasswordKey
+ }
+ };
+ sutProvider.GetDependency().GetManyByUserAsync(user.Id)
+ .Returns(existingUserResetPassword);
+
+ // Act
+ var result = await sutProvider.Sut.ValidateAsync(user, new[] { validResetPasswordKey });
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result);
+ Assert.Equal(validResetPasswordKey.OrganizationId, result[0].OrganizationId);
+ }
+
[Theory]
[BitAutoData]
public async Task ValidateAsync_MissingResetPassword_Throws(
From edf694b8d4a13acfdc30b2f54a492ab3cc442b3e Mon Sep 17 00:00:00 2001
From: Matt Gibson
Date: Tue, 27 Jan 2026 12:55:04 -0800
Subject: [PATCH 08/13] Use Scene result for SingleUserScene (#6909)
* Scenes should return resulting data in the result object
The result is for data that cannot be known by the client requesting the scene and the mangle map used for mangling input values to enable parallelizing tests
* Fix filenames
* SingleUserScene now has a return value of various created User data
* 1/100 too frequent for false test failures
---
.../EnumerationProtectionHelpersTests.cs | 4 +--
...trollerTest.cs => QueryControllerTests.cs} | 0
...ntrollerTest.cs => SeedControllerTests.cs} | 2 +-
util/Seeder/Factories/UserSeeder.cs | 34 ++-----------------
util/Seeder/IScene.cs | 2 +-
util/Seeder/Scenes/SingleUserScene.cs | 34 +++++++++++++------
6 files changed, 30 insertions(+), 46 deletions(-)
rename test/SeederApi.IntegrationTest/{QueryControllerTest.cs => QueryControllerTests.cs} (100%)
rename test/SeederApi.IntegrationTest/{SeedControllerTest.cs => SeedControllerTests.cs} (99%)
diff --git a/test/Core.Test/Utilities/EnumerationProtectionHelpersTests.cs b/test/Core.Test/Utilities/EnumerationProtectionHelpersTests.cs
index 68ac8af5d0..0f8ac56c22 100644
--- a/test/Core.Test/Utilities/EnumerationProtectionHelpersTests.cs
+++ b/test/Core.Test/Utilities/EnumerationProtectionHelpersTests.cs
@@ -98,7 +98,7 @@ public class EnumerationProtectionHelpersTests
var hmacKey = RandomNumberGenerator.GetBytes(32);
var salt1 = "user1@example.com";
var salt2 = "user2@example.com";
- var range = 100;
+ var range = 10_000;
// Act
var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt1, range);
@@ -117,7 +117,7 @@ public class EnumerationProtectionHelpersTests
var hmacKey1 = RandomNumberGenerator.GetBytes(32);
var hmacKey2 = RandomNumberGenerator.GetBytes(32);
var salt = "test@example.com";
- var range = 100;
+ var range = 10_000;
// Act
var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey1, salt, range);
diff --git a/test/SeederApi.IntegrationTest/QueryControllerTest.cs b/test/SeederApi.IntegrationTest/QueryControllerTests.cs
similarity index 100%
rename from test/SeederApi.IntegrationTest/QueryControllerTest.cs
rename to test/SeederApi.IntegrationTest/QueryControllerTests.cs
diff --git a/test/SeederApi.IntegrationTest/SeedControllerTest.cs b/test/SeederApi.IntegrationTest/SeedControllerTests.cs
similarity index 99%
rename from test/SeederApi.IntegrationTest/SeedControllerTest.cs
rename to test/SeederApi.IntegrationTest/SeedControllerTests.cs
index 1d081d019e..39139903d8 100644
--- a/test/SeederApi.IntegrationTest/SeedControllerTest.cs
+++ b/test/SeederApi.IntegrationTest/SeedControllerTests.cs
@@ -45,7 +45,7 @@ public class SeedControllerTests : IClassFixture, I
Assert.NotNull(result);
Assert.NotNull(result.MangleMap);
- Assert.Null(result.Result);
+ Assert.NotNull(result.Result);
}
[Fact]
diff --git a/util/Seeder/Factories/UserSeeder.cs b/util/Seeder/Factories/UserSeeder.cs
index 4fc456981c..9b80dbef3c 100644
--- a/util/Seeder/Factories/UserSeeder.cs
+++ b/util/Seeder/Factories/UserSeeder.cs
@@ -1,5 +1,4 @@
-using System.Globalization;
-using Bit.Core.Entities;
+using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
using Bit.RustSDK;
@@ -10,13 +9,6 @@ namespace Bit.Seeder.Factories;
public struct UserData
{
public string Email;
- public Guid Id;
- public string? Key;
- public string? PublicKey;
- public string? PrivateKey;
- public string? ApiKey;
- public KdfType Kdf;
- public int KdfIterations;
}
public class UserSeeder(RustSdkService sdkService, IPasswordHasher passwordHasher, MangleId mangleId)
@@ -75,30 +67,8 @@ public class UserSeeder(RustSdkService sdkService, IPasswordHasher
{
- { expectedUserData.Email, MangleEmail(expectedUserData.Email) },
- { expectedUserData.Id.ToString(), user.Id.ToString() },
- { expectedUserData.Kdf.ToString(), user.Kdf.ToString() },
- { expectedUserData.KdfIterations.ToString(CultureInfo.InvariantCulture), user.KdfIterations.ToString(CultureInfo.InvariantCulture) }
+ { expectedUserData.Email, user.Email },
};
- if (expectedUserData.Key != null)
- {
- mangleMap[expectedUserData.Key] = user.Key;
- }
-
- if (expectedUserData.PublicKey != null)
- {
- mangleMap[expectedUserData.PublicKey] = user.PublicKey;
- }
-
- if (expectedUserData.PrivateKey != null)
- {
- mangleMap[expectedUserData.PrivateKey] = user.PrivateKey;
- }
-
- if (expectedUserData.ApiKey != null)
- {
- mangleMap[expectedUserData.ApiKey] = user.ApiKey;
- }
return mangleMap;
}
diff --git a/util/Seeder/IScene.cs b/util/Seeder/IScene.cs
index 6f513973ba..e6d38e3673 100644
--- a/util/Seeder/IScene.cs
+++ b/util/Seeder/IScene.cs
@@ -72,7 +72,7 @@ public interface IScene : IScene where TRequest : class
/// and entity tracking information. The explicit interface implementations allow dynamic invocation
/// while preserving type safety in the implementation.
///
-public interface IScene : IScene where TRequest : class where TResult : class
+public interface IScene : IScene where TRequest : class
{
///
/// Seeds data based on the provided strongly-typed request and returns typed result data.
diff --git a/util/Seeder/Scenes/SingleUserScene.cs b/util/Seeder/Scenes/SingleUserScene.cs
index df941c7f59..f7cec192fd 100644
--- a/util/Seeder/Scenes/SingleUserScene.cs
+++ b/util/Seeder/Scenes/SingleUserScene.cs
@@ -4,10 +4,22 @@ using Bit.Seeder.Factories;
namespace Bit.Seeder.Scenes;
+public struct SingleUserSceneResult
+{
+ public Guid UserId { get; init; }
+ public string Kdf { get; init; }
+ public int KdfIterations { get; init; }
+ public string Key { get; init; }
+ public string PublicKey { get; init; }
+ public string PrivateKey { get; init; }
+ public string ApiKey { get; init; }
+
+}
+
///
/// Creates a single user using the provided account details.
///
-public class SingleUserScene(UserSeeder userSeeder, IUserRepository userRepository) : IScene
+public class SingleUserScene(UserSeeder userSeeder, IUserRepository userRepository) : IScene
{
public class Request
{
@@ -17,22 +29,24 @@ public class SingleUserScene(UserSeeder userSeeder, IUserRepository userReposito
public bool Premium { get; set; } = false;
}
- public async Task SeedAsync(Request request)
+ public async Task> SeedAsync(Request request)
{
var user = userSeeder.CreateUser(request.Email, request.EmailVerified, request.Premium);
await userRepository.CreateAsync(user);
- return new SceneResult(mangleMap: userSeeder.GetMangleMap(user, new UserData
+ return new SceneResult(result: new SingleUserSceneResult
+ {
+ UserId = user.Id,
+ Kdf = user.Kdf.ToString(),
+ KdfIterations = user.KdfIterations,
+ Key = user.Key!,
+ PublicKey = user.PublicKey!,
+ PrivateKey = user.PrivateKey!,
+ ApiKey = user.ApiKey!,
+ }, mangleMap: userSeeder.GetMangleMap(user, new UserData
{
Email = request.Email,
- Id = user.Id,
- Key = user.Key,
- PublicKey = user.PublicKey,
- PrivateKey = user.PrivateKey,
- ApiKey = user.ApiKey,
- Kdf = user.Kdf,
- KdfIterations = user.KdfIterations,
}));
}
}
From 4403e036fd7448e822959a5dbef5ddff16d58c53 Mon Sep 17 00:00:00 2001
From: Todd Martin <106564991+trmartin4@users.noreply.github.com>
Date: Tue, 27 Jan 2026 16:46:43 -0500
Subject: [PATCH 09/13] chore(flags): Add pm-30529-webauthn-related-origins
feature flag
---
src/Core/Constants.cs | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index e229023e27..2f0a6f808a 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -228,6 +228,7 @@ public static class FeatureFlagKeys
public const string IpcChannelFramework = "ipc-channel-framework";
public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked";
public const string PushNotificationsWhenInactive = "pm-25130-receive-push-notifications-for-inactive-users";
+ public const string WebAuthnRelatedOrigins = "pm-30529-webauthn-related-origins";
/* Tools Team */
///
From 2c39e336e060b854aca69fe005b7591c91c50fc2 Mon Sep 17 00:00:00 2001
From: Todd Martin <106564991+trmartin4@users.noreply.github.com>
Date: Wed, 28 Jan 2026 08:25:46 -0500
Subject: [PATCH 10/13] chore(flags): [PM-31326] Rename ipc-channel-framework
feature flag
---
src/Core/Constants.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index 2f0a6f808a..d9a72582ab 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -225,7 +225,7 @@ public static class FeatureFlagKeys
/* Platform Team */
public const string WebPush = "web-push";
- public const string IpcChannelFramework = "ipc-channel-framework";
+ public const string ContentScriptIpcFramework = "content-script-ipc-channel-framework";
public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked";
public const string PushNotificationsWhenInactive = "pm-25130-receive-push-notifications-for-inactive-users";
public const string WebAuthnRelatedOrigins = "pm-30529-webauthn-related-origins";
From fa06fe41ab24c9b54bd27bf43cdee520457ea26c Mon Sep 17 00:00:00 2001
From: John Harrington <84741727+harr1424@users.noreply.github.com>
Date: Wed, 28 Jan 2026 07:13:25 -0700
Subject: [PATCH 11/13] [PM-30920] Server changes to encrypt send access email
list (#6867)
* models, entity, and stored procs updated to work with EmailHashes with migrations
* configure data protection for EmailHashes
* update SendAuthenticationQuery to use EmailHashes and perform validation
* respond to Claude's comments and update tests
* fix send.sql alignment
Co-authored-by: mkincaid-bw
---------
Co-authored-by: Alex Dragovich <46065570+itsadrago@users.noreply.github.com>
Co-authored-by: mkincaid-bw
---
src/Api/Tools/Controllers/SendsController.cs | 12 -
.../Tools/Models/Request/SendRequestModel.cs | 11 +-
src/Core/Tools/Entities/Send.cs | 9 +
.../Models/Data/SendAuthenticationTypes.cs | 2 +-
.../Queries/SendAuthenticationQuery.cs | 15 +-
.../Tools/Repositories/SendRepository.cs | 112 +-
.../Repositories/DatabaseContext.cs | 2 +
.../Tools/Stored Procedures/Send_Create.sql | 9 +-
.../Tools/Stored Procedures/Send_Update.sql | 6 +-
src/Sql/dbo/Tools/Tables/Send.sql | 22 +-
.../Tools/Controllers/SendsControllerTests.cs | 199 -
.../Services/SendAuthenticationQueryTests.cs | 228 +-
.../2026-01-17_00_Send_EmailHashes.sql | 148 +
...2026-01-17_00_Send_EmailHashes.Designer.cs | 3506 ++++++++++++++++
...17234040_2026-01-17_00_Send_EmailHashes.cs | 29 +
.../DatabaseContextModelSnapshot.cs | 4 +
...2026-01-17_00_Send_EmailHashes.Designer.cs | 3512 +++++++++++++++++
...17234031_2026-01-17_00_Send_EmailHashes.cs | 28 +
.../DatabaseContextModelSnapshot.cs | 4 +
...2026-01-17_00_Send_EmailHashes.Designer.cs | 3495 ++++++++++++++++
...17234036_2026-01-17_00_Send_EmailHashes.cs | 28 +
.../DatabaseContextModelSnapshot.cs | 4 +
22 files changed, 11125 insertions(+), 260 deletions(-)
create mode 100644 util/Migrator/DbScripts/2026-01-17_00_Send_EmailHashes.sql
create mode 100644 util/MySqlMigrations/Migrations/20260117234040_2026-01-17_00_Send_EmailHashes.Designer.cs
create mode 100644 util/MySqlMigrations/Migrations/20260117234040_2026-01-17_00_Send_EmailHashes.cs
create mode 100644 util/PostgresMigrations/Migrations/20260117234031_2026-01-17_00_Send_EmailHashes.Designer.cs
create mode 100644 util/PostgresMigrations/Migrations/20260117234031_2026-01-17_00_Send_EmailHashes.cs
create mode 100644 util/SqliteMigrations/Migrations/20260117234036_2026-01-17_00_Send_EmailHashes.Designer.cs
create mode 100644 util/SqliteMigrations/Migrations/20260117234036_2026-01-17_00_Send_EmailHashes.cs
diff --git a/src/Api/Tools/Controllers/SendsController.cs b/src/Api/Tools/Controllers/SendsController.cs
index f9f71d076d..61002a0168 100644
--- a/src/Api/Tools/Controllers/SendsController.cs
+++ b/src/Api/Tools/Controllers/SendsController.cs
@@ -239,12 +239,6 @@ public class SendsController : Controller
{
throw new BadRequestException("Could not locate send");
}
- if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
- send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
- send.DeletionDate < DateTime.UtcNow)
- {
- throw new NotFoundException();
- }
var sendResponse = new SendAccessResponseModel(send);
if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault())
@@ -272,12 +266,6 @@ public class SendsController : Controller
{
throw new BadRequestException("Could not locate send");
}
- if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
- send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
- send.DeletionDate < DateTime.UtcNow)
- {
- throw new NotFoundException();
- }
var url = await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId);
diff --git a/src/Api/Tools/Models/Request/SendRequestModel.cs b/src/Api/Tools/Models/Request/SendRequestModel.cs
index f3308dbd5a..00dcb6273f 100644
--- a/src/Api/Tools/Models/Request/SendRequestModel.cs
+++ b/src/Api/Tools/Models/Request/SendRequestModel.cs
@@ -102,9 +102,17 @@ public class SendRequestModel
/// Comma-separated list of emails that may access the send using OTP
/// authentication. Mutually exclusive with .
///
- [StringLength(4000)]
+ [EncryptedString]
+ [EncryptedStringLength(4000)]
public string Emails { get; set; }
+ ///
+ /// Comma-separated list of email **hashes** that may access the send using OTP
+ /// authentication. Mutually exclusive with .
+ ///
+ [StringLength(4000)]
+ public string EmailHashes { get; set; }
+
///
/// When , send access is disabled.
/// Defaults to .
@@ -253,6 +261,7 @@ public class SendRequestModel
// normalize encoding
var emails = Emails.Split(',', RemoveEmptyEntries | TrimEntries);
existingSend.Emails = string.Join(",", emails);
+ existingSend.EmailHashes = EmailHashes;
existingSend.Password = null;
existingSend.AuthType = Core.Tools.Enums.AuthType.Email;
}
diff --git a/src/Core/Tools/Entities/Send.cs b/src/Core/Tools/Entities/Send.cs
index 52b439c41e..c4398e212c 100644
--- a/src/Core/Tools/Entities/Send.cs
+++ b/src/Core/Tools/Entities/Send.cs
@@ -81,6 +81,15 @@ public class Send : ITableObject
[MaxLength(4000)]
public string? Emails { get; set; }
+ ///
+ /// Comma-separated list of email **hashes** for OTP authentication.
+ ///
+ ///
+ /// This field is mutually exclusive with
+ ///
+ [MaxLength(4000)]
+ public string? EmailHashes { get; set; }
+
///
/// The send becomes unavailable to API callers when
/// >= .
diff --git a/src/Core/Tools/Models/Data/SendAuthenticationTypes.cs b/src/Core/Tools/Models/Data/SendAuthenticationTypes.cs
index 9ce477ed0c..c90dba43a8 100644
--- a/src/Core/Tools/Models/Data/SendAuthenticationTypes.cs
+++ b/src/Core/Tools/Models/Data/SendAuthenticationTypes.cs
@@ -45,6 +45,6 @@ public record ResourcePassword(string Hash) : SendAuthenticationMethod;
/// Create a send claim by requesting a one time password (OTP) confirmation code.
///
///
-/// The list of email addresses permitted access to the send.
+/// The list of email address **hashes** permitted access to the send.
///
public record EmailOtp(string[] Emails) : SendAuthenticationMethod;
diff --git a/src/Core/Tools/SendFeatures/Queries/SendAuthenticationQuery.cs b/src/Core/Tools/SendFeatures/Queries/SendAuthenticationQuery.cs
index 97c2e64dc5..a82c27d0c3 100644
--- a/src/Core/Tools/SendFeatures/Queries/SendAuthenticationQuery.cs
+++ b/src/Core/Tools/SendFeatures/Queries/SendAuthenticationQuery.cs
@@ -37,8 +37,11 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery
SendAuthenticationMethod method = send switch
{
null => NEVER_AUTHENTICATE,
- var s when s.AccessCount >= s.MaxAccessCount => NEVER_AUTHENTICATE,
- var s when s.AuthType == AuthType.Email && s.Emails is not null => emailOtp(s.Emails),
+ var s when s.Disabled => NEVER_AUTHENTICATE,
+ var s when s.AccessCount >= s.MaxAccessCount.GetValueOrDefault(int.MaxValue) => NEVER_AUTHENTICATE,
+ var s when s.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow => NEVER_AUTHENTICATE,
+ var s when s.DeletionDate <= DateTime.UtcNow => NEVER_AUTHENTICATE,
+ var s when s.AuthType == AuthType.Email && s.EmailHashes is not null => EmailOtp(s.EmailHashes),
var s when s.AuthType == AuthType.Password && s.Password is not null => new ResourcePassword(s.Password),
_ => NOT_AUTHENTICATED
};
@@ -46,9 +49,13 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery
return method;
}
- private EmailOtp emailOtp(string emails)
+ private static EmailOtp EmailOtp(string? emailHashes)
{
- var list = emails.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (string.IsNullOrWhiteSpace(emailHashes))
+ {
+ return new EmailOtp([]);
+ }
+ var list = emailHashes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return new EmailOtp(list);
}
}
diff --git a/src/Infrastructure.Dapper/Tools/Repositories/SendRepository.cs b/src/Infrastructure.Dapper/Tools/Repositories/SendRepository.cs
index 81a94f0f7c..4c5d70340f 100644
--- a/src/Infrastructure.Dapper/Tools/Repositories/SendRepository.cs
+++ b/src/Infrastructure.Dapper/Tools/Repositories/SendRepository.cs
@@ -1,6 +1,7 @@
#nullable enable
using System.Data;
+using Bit.Core;
using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Settings;
using Bit.Core.Tools.Entities;
@@ -8,6 +9,7 @@ using Bit.Core.Tools.Repositories;
using Bit.Infrastructure.Dapper.Repositories;
using Bit.Infrastructure.Dapper.Tools.Helpers;
using Dapper;
+using Microsoft.AspNetCore.DataProtection;
using Microsoft.Data.SqlClient;
namespace Bit.Infrastructure.Dapper.Tools.Repositories;
@@ -15,13 +17,24 @@ namespace Bit.Infrastructure.Dapper.Tools.Repositories;
///
public class SendRepository : Repository, ISendRepository
{
- public SendRepository(GlobalSettings globalSettings)
- : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
+ private readonly IDataProtector _dataProtector;
+
+ public SendRepository(GlobalSettings globalSettings, IDataProtectionProvider dataProtectionProvider)
+ : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString, dataProtectionProvider)
{ }
- public SendRepository(string connectionString, string readOnlyConnectionString)
+ public SendRepository(string connectionString, string readOnlyConnectionString, IDataProtectionProvider dataProtectionProvider)
: base(connectionString, readOnlyConnectionString)
- { }
+ {
+ _dataProtector = dataProtectionProvider.CreateProtector(Constants.DatabaseFieldProtectorPurpose);
+ }
+
+ public override async Task GetByIdAsync(Guid id)
+ {
+ var send = await base.GetByIdAsync(id);
+ UnprotectData(send);
+ return send;
+ }
///
public async Task> GetManyByUserIdAsync(Guid userId)
@@ -33,7 +46,9 @@ public class SendRepository : Repository, ISendRepository
new { UserId = userId },
commandType: CommandType.StoredProcedure);
- return results.ToList();
+ var sends = results.ToList();
+ UnprotectData(sends);
+ return sends;
}
}
@@ -47,15 +62,35 @@ public class SendRepository : Repository, ISendRepository
new { DeletionDate = deletionDateBefore },
commandType: CommandType.StoredProcedure);
- return results.ToList();
+ var sends = results.ToList();
+ UnprotectData(sends);
+ return sends;
}
}
+ public override async Task CreateAsync(Send send)
+ {
+ await ProtectDataAndSaveAsync(send, async () => await base.CreateAsync(send));
+ return send;
+ }
+
+ public override async Task ReplaceAsync(Send send)
+ {
+ await ProtectDataAndSaveAsync(send, async () => await base.ReplaceAsync(send));
+ }
+
///
public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid userId, IEnumerable sends)
{
return async (connection, transaction) =>
{
+ // Protect all sends before bulk update
+ var sendsList = sends.ToList();
+ foreach (var send in sendsList)
+ {
+ ProtectData(send);
+ }
+
// Create temp table
var sqlCreateTemp = @"
SELECT TOP 0 *
@@ -71,7 +106,7 @@ public class SendRepository : Repository, ISendRepository
using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction))
{
bulkCopy.DestinationTableName = "#TempSend";
- var sendsTable = sends.ToDataTable();
+ var sendsTable = sendsList.ToDataTable();
foreach (DataColumn col in sendsTable.Columns)
{
bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName);
@@ -101,6 +136,69 @@ public class SendRepository : Repository, ISendRepository
cmd.Parameters.Add("@UserId", SqlDbType.UniqueIdentifier).Value = userId;
cmd.ExecuteNonQuery();
}
+
+ // Unprotect after save
+ foreach (var send in sendsList)
+ {
+ UnprotectData(send);
+ }
};
}
+
+ private async Task ProtectDataAndSaveAsync(Send send, Func saveTask)
+ {
+ if (send == null)
+ {
+ await saveTask();
+ return;
+ }
+
+ // Capture original value
+ var originalEmailHashes = send.EmailHashes;
+
+ // Protect value
+ ProtectData(send);
+
+ // Save
+ await saveTask();
+
+ // Restore original value
+ send.EmailHashes = originalEmailHashes;
+ }
+
+ private void ProtectData(Send send)
+ {
+ if (!send.EmailHashes?.StartsWith(Constants.DatabaseFieldProtectedPrefix) ?? false)
+ {
+ send.EmailHashes = string.Concat(Constants.DatabaseFieldProtectedPrefix,
+ _dataProtector.Protect(send.EmailHashes!));
+ }
+ }
+
+ private void UnprotectData(Send? send)
+ {
+ if (send == null)
+ {
+ return;
+ }
+
+ if (send.EmailHashes?.StartsWith(Constants.DatabaseFieldProtectedPrefix) ?? false)
+ {
+ send.EmailHashes = _dataProtector.Unprotect(
+ send.EmailHashes.Substring(Constants.DatabaseFieldProtectedPrefix.Length));
+ }
+ }
+
+ private void UnprotectData(IEnumerable sends)
+ {
+ if (sends == null)
+ {
+ return;
+ }
+
+ foreach (var send in sends)
+ {
+ UnprotectData(send);
+ }
+ }
}
diff --git a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs
index a0ee0376c0..3f638f88e5 100644
--- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs
+++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs
@@ -119,6 +119,7 @@ public class DatabaseContext : DbContext
var eOrganizationDomain = builder.Entity();
var aWebAuthnCredential = builder.Entity();
var eOrganizationMemberBaseDetail = builder.Entity();
+ var eSend = builder.Entity();
// Shadow property configurations go here
@@ -148,6 +149,7 @@ public class DatabaseContext : DbContext
var dataProtectionConverter = new DataProtectionConverter(dataProtector);
eUser.Property(c => c.Key).HasConversion(dataProtectionConverter);
eUser.Property(c => c.MasterPassword).HasConversion(dataProtectionConverter);
+ eSend.Property(c => c.EmailHashes).HasConversion(dataProtectionConverter);
if (Database.IsNpgsql())
{
diff --git a/src/Sql/dbo/Tools/Stored Procedures/Send_Create.sql b/src/Sql/dbo/Tools/Stored Procedures/Send_Create.sql
index 752f8fb496..e277174717 100644
--- a/src/Sql/dbo/Tools/Stored Procedures/Send_Create.sql
+++ b/src/Sql/dbo/Tools/Stored Procedures/Send_Create.sql
@@ -18,7 +18,8 @@
-- FIXME: remove null default value once this argument has been
-- in 2 server releases
@Emails NVARCHAR(4000) = NULL,
- @AuthType TINYINT = NULL
+ @AuthType TINYINT = NULL,
+ @EmailHashes NVARCHAR(4000) = NULL
AS
BEGIN
SET NOCOUNT ON
@@ -42,7 +43,8 @@ BEGIN
[HideEmail],
[CipherId],
[Emails],
- [AuthType]
+ [AuthType],
+ [EmailHashes]
)
VALUES
(
@@ -63,7 +65,8 @@ BEGIN
@HideEmail,
@CipherId,
@Emails,
- @AuthType
+ @AuthType,
+ @EmailHashes
)
IF @UserId IS NOT NULL
diff --git a/src/Sql/dbo/Tools/Stored Procedures/Send_Update.sql b/src/Sql/dbo/Tools/Stored Procedures/Send_Update.sql
index fba842d8d6..a2bcb0a24b 100644
--- a/src/Sql/dbo/Tools/Stored Procedures/Send_Update.sql
+++ b/src/Sql/dbo/Tools/Stored Procedures/Send_Update.sql
@@ -16,7 +16,8 @@
@HideEmail BIT,
@CipherId UNIQUEIDENTIFIER = NULL,
@Emails NVARCHAR(4000) = NULL,
- @AuthType TINYINT = NULL
+ @AuthType TINYINT = NULL,
+ @EmailHashes NVARCHAR(4000) = NULL
AS
BEGIN
SET NOCOUNT ON
@@ -40,7 +41,8 @@ BEGIN
[HideEmail] = @HideEmail,
[CipherId] = @CipherId,
[Emails] = @Emails,
- [AuthType] = @AuthType
+ [AuthType] = @AuthType,
+ [EmailHashes] = @EmailHashes
WHERE
[Id] = @Id
diff --git a/src/Sql/dbo/Tools/Tables/Send.sql b/src/Sql/dbo/Tools/Tables/Send.sql
index 94311d6328..59a42a2aa5 100644
--- a/src/Sql/dbo/Tools/Tables/Send.sql
+++ b/src/Sql/dbo/Tools/Tables/Send.sql
@@ -1,22 +1,24 @@
-CREATE TABLE [dbo].[Send] (
+CREATE TABLE [dbo].[Send]
+(
[Id] UNIQUEIDENTIFIER NOT NULL,
[UserId] UNIQUEIDENTIFIER NULL,
[OrganizationId] UNIQUEIDENTIFIER NULL,
[Type] TINYINT NOT NULL,
[Data] VARCHAR(MAX) NOT NULL,
- [Key] VARCHAR (MAX) NOT NULL,
- [Password] NVARCHAR (300) NULL,
- [Emails] NVARCHAR (4000) NULL,
+ [Key] VARCHAR(MAX) NOT NULL,
+ [Password] NVARCHAR(300) NULL,
+ [Emails] NVARCHAR(4000) NULL,
[MaxAccessCount] INT NULL,
[AccessCount] INT NOT NULL,
- [CreationDate] DATETIME2 (7) NOT NULL,
- [RevisionDate] DATETIME2 (7) NOT NULL,
- [ExpirationDate] DATETIME2 (7) NULL,
- [DeletionDate] DATETIME2 (7) NOT NULL,
+ [CreationDate] DATETIME2(7) NOT NULL,
+ [RevisionDate] DATETIME2(7) NOT NULL,
+ [ExpirationDate] DATETIME2(7) NULL,
+ [DeletionDate] DATETIME2(7) NOT NULL,
[Disabled] BIT NOT NULL,
[HideEmail] BIT NULL,
[CipherId] UNIQUEIDENTIFIER NULL,
[AuthType] TINYINT NULL,
+ [EmailHashes] NVARCHAR(4000) NULL,
CONSTRAINT [PK_Send] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_Send_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]),
CONSTRAINT [FK_Send_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]),
@@ -26,9 +28,9 @@
GO
CREATE NONCLUSTERED INDEX [IX_Send_UserId_OrganizationId]
- ON [dbo].[Send]([UserId] ASC, [OrganizationId] ASC);
+ ON [dbo].[Send] ([UserId] ASC, [OrganizationId] ASC);
GO
CREATE NONCLUSTERED INDEX [IX_Send_DeletionDate]
- ON [dbo].[Send]([DeletionDate] ASC);
+ ON [dbo].[Send] ([DeletionDate] ASC);
diff --git a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs
index e3a9ba4435..9322948037 100644
--- a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs
+++ b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs
@@ -981,205 +981,6 @@ public class SendsControllerTests : IDisposable
Assert.Equal(expectedUrl, response.Url);
}
- #region AccessUsingAuth Validation Tests
-
- [Theory, AutoData]
- public async Task AccessUsingAuth_WithExpiredSend_ThrowsNotFoundException(Guid sendId)
- {
- var send = new Send
- {
- Id = sendId,
- UserId = Guid.NewGuid(),
- Type = SendType.Text,
- Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
- DeletionDate = DateTime.UtcNow.AddDays(7),
- ExpirationDate = DateTime.UtcNow.AddDays(-1), // Expired yesterday
- Disabled = false,
- AccessCount = 0,
- MaxAccessCount = null
- };
- var user = CreateUserWithSendIdClaim(sendId);
- _sut.ControllerContext = CreateControllerContextWithUser(user);
- _sendRepository.GetByIdAsync(sendId).Returns(send);
-
- await Assert.ThrowsAsync(() => _sut.AccessUsingAuth());
-
- await _sendRepository.Received(1).GetByIdAsync(sendId);
- }
-
- [Theory, AutoData]
- public async Task AccessUsingAuth_WithDeletedSend_ThrowsNotFoundException(Guid sendId)
- {
- var send = new Send
- {
- Id = sendId,
- UserId = Guid.NewGuid(),
- Type = SendType.Text,
- Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
- DeletionDate = DateTime.UtcNow.AddDays(-1), // Should have been deleted yesterday
- ExpirationDate = null,
- Disabled = false,
- AccessCount = 0,
- MaxAccessCount = null
- };
- var user = CreateUserWithSendIdClaim(sendId);
- _sut.ControllerContext = CreateControllerContextWithUser(user);
- _sendRepository.GetByIdAsync(sendId).Returns(send);
-
- await Assert.ThrowsAsync(() => _sut.AccessUsingAuth());
-
- await _sendRepository.Received(1).GetByIdAsync(sendId);
- }
-
- [Theory, AutoData]
- public async Task AccessUsingAuth_WithDisabledSend_ThrowsNotFoundException(Guid sendId)
- {
- var send = new Send
- {
- Id = sendId,
- UserId = Guid.NewGuid(),
- Type = SendType.Text,
- Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
- DeletionDate = DateTime.UtcNow.AddDays(7),
- ExpirationDate = null,
- Disabled = true, // Disabled
- AccessCount = 0,
- MaxAccessCount = null
- };
- var user = CreateUserWithSendIdClaim(sendId);
- _sut.ControllerContext = CreateControllerContextWithUser(user);
- _sendRepository.GetByIdAsync(sendId).Returns(send);
-
- await Assert.ThrowsAsync(() => _sut.AccessUsingAuth());
-
- await _sendRepository.Received(1).GetByIdAsync(sendId);
- }
-
- [Theory, AutoData]
- public async Task AccessUsingAuth_WithAccessCountExceeded_ThrowsNotFoundException(Guid sendId)
- {
- var send = new Send
- {
- Id = sendId,
- UserId = Guid.NewGuid(),
- Type = SendType.Text,
- Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
- DeletionDate = DateTime.UtcNow.AddDays(7),
- ExpirationDate = null,
- Disabled = false,
- AccessCount = 5,
- MaxAccessCount = 5 // Limit reached
- };
- var user = CreateUserWithSendIdClaim(sendId);
- _sut.ControllerContext = CreateControllerContextWithUser(user);
- _sendRepository.GetByIdAsync(sendId).Returns(send);
-
- await Assert.ThrowsAsync(() => _sut.AccessUsingAuth());
-
- await _sendRepository.Received(1).GetByIdAsync(sendId);
- }
-
- #endregion
-
- #region GetSendFileDownloadDataUsingAuth Validation Tests
-
- [Theory, AutoData]
- public async Task GetSendFileDownloadDataUsingAuth_WithExpiredSend_ThrowsNotFoundException(
- Guid sendId, string fileId)
- {
- var send = new Send
- {
- Id = sendId,
- Type = SendType.File,
- Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
- DeletionDate = DateTime.UtcNow.AddDays(7),
- ExpirationDate = DateTime.UtcNow.AddDays(-1), // Expired
- Disabled = false,
- AccessCount = 0,
- MaxAccessCount = null
- };
- var user = CreateUserWithSendIdClaim(sendId);
- _sut.ControllerContext = CreateControllerContextWithUser(user);
- _sendRepository.GetByIdAsync(sendId).Returns(send);
-
- await Assert.ThrowsAsync(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
-
- await _sendRepository.Received(1).GetByIdAsync(sendId);
- }
-
- [Theory, AutoData]
- public async Task GetSendFileDownloadDataUsingAuth_WithDeletedSend_ThrowsNotFoundException(
- Guid sendId, string fileId)
- {
- var send = new Send
- {
- Id = sendId,
- Type = SendType.File,
- Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
- DeletionDate = DateTime.UtcNow.AddDays(-1), // Deleted
- ExpirationDate = null,
- Disabled = false,
- AccessCount = 0,
- MaxAccessCount = null
- };
- var user = CreateUserWithSendIdClaim(sendId);
- _sut.ControllerContext = CreateControllerContextWithUser(user);
- _sendRepository.GetByIdAsync(sendId).Returns(send);
-
- await Assert.ThrowsAsync(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
-
- await _sendRepository.Received(1).GetByIdAsync(sendId);
- }
-
- [Theory, AutoData]
- public async Task GetSendFileDownloadDataUsingAuth_WithDisabledSend_ThrowsNotFoundException(
- Guid sendId, string fileId)
- {
- var send = new Send
- {
- Id = sendId,
- Type = SendType.File,
- Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
- DeletionDate = DateTime.UtcNow.AddDays(7),
- ExpirationDate = null,
- Disabled = true, // Disabled
- AccessCount = 0,
- MaxAccessCount = null
- };
- var user = CreateUserWithSendIdClaim(sendId);
- _sut.ControllerContext = CreateControllerContextWithUser(user);
- _sendRepository.GetByIdAsync(sendId).Returns(send);
-
- await Assert.ThrowsAsync(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
-
- await _sendRepository.Received(1).GetByIdAsync(sendId);
- }
-
- [Theory, AutoData]
- public async Task GetSendFileDownloadDataUsingAuth_WithAccessCountExceeded_ThrowsNotFoundException(
- Guid sendId, string fileId)
- {
- var send = new Send
- {
- Id = sendId,
- Type = SendType.File,
- Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
- DeletionDate = DateTime.UtcNow.AddDays(7),
- ExpirationDate = null,
- Disabled = false,
- AccessCount = 10,
- MaxAccessCount = 10 // Limit reached
- };
- var user = CreateUserWithSendIdClaim(sendId);
- _sut.ControllerContext = CreateControllerContextWithUser(user);
- _sendRepository.GetByIdAsync(sendId).Returns(send);
-
- await Assert.ThrowsAsync(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
-
- await _sendRepository.Received(1).GetByIdAsync(sendId);
- }
-
- #endregion
#endregion
diff --git a/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs b/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs
index 7901b3c5c0..56b0f306cb 100644
--- a/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs
+++ b/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs
@@ -43,12 +43,12 @@ public class SendAuthenticationQueryTests
}
[Theory]
- [MemberData(nameof(EmailParsingTestCases))]
- public async Task GetAuthenticationMethod_WithEmails_ParsesEmailsCorrectly(string emailString, string[] expectedEmails)
+ [MemberData(nameof(EmailHashesParsingTestCases))]
+ public async Task GetAuthenticationMethod_WithEmailHashes_ParsesEmailHashesCorrectly(string emailHashString, string[] expectedEmailHashes)
{
// Arrange
var sendId = Guid.NewGuid();
- var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: emailString, password: null, AuthType.Email);
+ var send = CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: emailHashString, password: null, AuthType.Email);
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
@@ -56,15 +56,15 @@ public class SendAuthenticationQueryTests
// Assert
var emailOtp = Assert.IsType(result);
- Assert.Equal(expectedEmails, emailOtp.Emails);
+ Assert.Equal(expectedEmailHashes, emailOtp.Emails);
}
[Fact]
- public async Task GetAuthenticationMethod_WithBothEmailsAndPassword_ReturnsEmailOtp()
+ public async Task GetAuthenticationMethod_WithBothEmailHashesAndPassword_ReturnsEmailOtp()
{
// Arrange
var sendId = Guid.NewGuid();
- var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: "hashedpassword", AuthType.Email);
+ var send = CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: "hashedemail", password: "hashedpassword", AuthType.Email);
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
@@ -79,7 +79,7 @@ public class SendAuthenticationQueryTests
{
// Arrange
var sendId = Guid.NewGuid();
- var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null, AuthType.None);
+ var send = CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: null, password: null, AuthType.None);
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
@@ -106,32 +106,218 @@ public class SendAuthenticationQueryTests
public static IEnumerable