1
0
mirror of https://github.com/bitwarden/server synced 2026-02-18 02:19:06 +00:00

Merge branch 'main' into auth/pm-27084/register-accepts-new-data-types

This commit is contained in:
Patrick-Pimentel-Bitwarden
2025-12-11 17:48:08 -05:00
committed by GitHub
28 changed files with 1037 additions and 57 deletions

View File

@@ -1,8 +1,8 @@
#nullable enable
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request;
using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Api.KeyManagement.Models.Requests;
using Bit.Api.KeyManagement.Models.Responses;
using Bit.Api.KeyManagement.Validators;
using Bit.Api.Tools.Models.Request;
using Bit.Api.Vault.Models.Request;
@@ -14,6 +14,7 @@ using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Commands.Interfaces;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -45,11 +46,13 @@ public class AccountsKeyManagementController : Controller
private readonly IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>
_webauthnKeyValidator;
private readonly IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> _deviceValidator;
private readonly IKeyConnectorConfirmationDetailsQuery _keyConnectorConfirmationDetailsQuery;
public AccountsKeyManagementController(IUserService userService,
IFeatureService featureService,
IOrganizationUserRepository organizationUserRepository,
IEmergencyAccessRepository emergencyAccessRepository,
IKeyConnectorConfirmationDetailsQuery keyConnectorConfirmationDetailsQuery,
IRegenerateUserAsymmetricKeysCommand regenerateUserAsymmetricKeysCommand,
IRotateUserAccountKeysCommand rotateUserKeyCommandV2,
IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> cipherValidator,
@@ -75,6 +78,7 @@ public class AccountsKeyManagementController : Controller
_organizationUserValidator = organizationUserValidator;
_webauthnKeyValidator = webAuthnKeyValidator;
_deviceValidator = deviceValidator;
_keyConnectorConfirmationDetailsQuery = keyConnectorConfirmationDetailsQuery;
}
[HttpPost("key-management/regenerate-keys")]
@@ -178,4 +182,17 @@ public class AccountsKeyManagementController : Controller
throw new BadRequestException(ModelState);
}
[HttpGet("key-connector/confirmation-details/{orgSsoIdentifier}")]
public async Task<KeyConnectorConfirmationDetailsResponseModel> GetKeyConnectorConfirmationDetailsAsync(string orgSsoIdentifier)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var details = await _keyConnectorConfirmationDetailsQuery.Run(orgSsoIdentifier, user.Id);
return new KeyConnectorConfirmationDetailsResponseModel(details);
}
}

View File

@@ -0,0 +1,24 @@
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Models.Api;
namespace Bit.Api.KeyManagement.Models.Responses;
public class KeyConnectorConfirmationDetailsResponseModel : ResponseModel
{
private const string _objectName = "keyConnectorConfirmationDetails";
public KeyConnectorConfirmationDetailsResponseModel(KeyConnectorConfirmationDetails details,
string obj = _objectName) : base(obj)
{
ArgumentNullException.ThrowIfNull(details);
OrganizationName = details.OrganizationName;
}
public KeyConnectorConfirmationDetailsResponseModel() : base(_objectName)
{
OrganizationName = string.Empty;
}
public string OrganizationName { get; set; }
}

View File

@@ -760,7 +760,7 @@ public class CiphersController : Controller
ValidateClientVersionForFido2CredentialSupport(cipher);
var original = cipher.Clone();
await _cipherService.ShareAsync(original, model.Cipher.ToCipher(cipher), new Guid(model.Cipher.OrganizationId),
await _cipherService.ShareAsync(original, model.Cipher.ToCipher(cipher, user.Id), new Guid(model.Cipher.OrganizationId),
model.CollectionIds.Select(c => new Guid(c)), user.Id, model.Cipher.LastKnownRevisionDate);
var sharedCipher = await GetByIdAsync(id, user.Id);

View File

@@ -84,7 +84,7 @@ public class CipherRequestModel
return existingCipher;
}
public Cipher ToCipher(Cipher existingCipher)
public Cipher ToCipher(Cipher existingCipher, Guid? userId = null)
{
// If Data field is provided, use it directly
if (!string.IsNullOrWhiteSpace(Data))
@@ -124,9 +124,12 @@ public class CipherRequestModel
}
}
var userIdKey = userId.HasValue ? userId.ToString().ToUpperInvariant() : null;
existingCipher.Reprompt = Reprompt;
existingCipher.Key = Key;
existingCipher.ArchivedDate = ArchivedDate;
existingCipher.Folders = UpdateUserSpecificJsonField(existingCipher.Folders, userIdKey, FolderId);
existingCipher.Favorites = UpdateUserSpecificJsonField(existingCipher.Favorites, userIdKey, Favorite);
var hasAttachments2 = (Attachments2?.Count ?? 0) > 0;
var hasAttachments = (Attachments?.Count ?? 0) > 0;
@@ -291,6 +294,37 @@ public class CipherRequestModel
KeyFingerprint = SSHKey.KeyFingerprint,
};
}
/// <summary>
/// Updates a JSON string representing a dictionary by adding, updating, or removing a key-value pair
/// based on the provided userIdKey and newValue.
/// </summary>
private static string UpdateUserSpecificJsonField(string existingJson, string userIdKey, object newValue)
{
if (userIdKey == null)
{
return existingJson;
}
var jsonDict = string.IsNullOrWhiteSpace(existingJson)
? new Dictionary<string, object>()
: JsonSerializer.Deserialize<Dictionary<string, object>>(existingJson) ?? new Dictionary<string, object>();
var shouldRemove = newValue == null ||
(newValue is string strValue && string.IsNullOrWhiteSpace(strValue)) ||
(newValue is bool boolValue && !boolValue);
if (shouldRemove)
{
jsonDict.Remove(userIdKey);
}
else
{
jsonDict[userIdKey] = newValue is string str ? str.ToUpperInvariant() : newValue;
}
return jsonDict.Count == 0 ? null : JsonSerializer.Serialize(jsonDict);
}
}
public class CipherWithIdRequestModel : CipherRequestModel

View File

@@ -11,7 +11,7 @@
<ProjectReference Include="..\Core\Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MarkDig" Version="0.41.3" />
<PackageReference Include="MarkDig" Version="0.44.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
</ItemGroup>

View File

@@ -81,6 +81,8 @@ public enum EventType : int
Organization_CollectionManagement_LimitItemDeletionDisabled = 1615,
Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsEnabled = 1616,
Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsDisabled = 1617,
Organization_ItemOrganization_Accepted = 1618,
Organization_ItemOrganization_Declined = 1619,
Policy_Updated = 1700,

View File

@@ -26,5 +26,6 @@ public static class KeyManagementServiceCollectionExtensions
private static void AddKeyManagementQueries(this IServiceCollection services)
{
services.AddScoped<IUserAccountKeysQuery, UserAccountKeysQuery>();
services.AddScoped<IKeyConnectorConfirmationDetailsQuery, KeyConnectorConfirmationDetailsQuery>();
}
}

View File

@@ -0,0 +1,6 @@
namespace Bit.Core.KeyManagement.Models.Data;
public class KeyConnectorConfirmationDetails
{
public required string OrganizationName { get; set; }
}

View File

@@ -1,9 +1,34 @@
namespace Bit.Core.KeyManagement.Models.Data;
/// <summary>
/// Represents an expanded account cryptographic state for a user. Expanded here means
/// that it does not only contain the (wrapped) private / signing key, but also the public
/// key / verifying key. The client side only needs a subset of this data to unlock
/// their vault and the public parts can be derived.
/// </summary>
public class UserAccountKeysData
{
public required PublicKeyEncryptionKeyPairData PublicKeyEncryptionKeyPairData { get; set; }
public SignatureKeyPairData? SignatureKeyPairData { get; set; }
public SecurityStateData? SecurityStateData { get; set; }
/// <summary>
/// Checks whether the account cryptographic state is for a V1 encryption user or a V2 encryption user.
/// Throws if the state is invalid
/// </summary>
public bool IsV2Encryption()
{
if (PublicKeyEncryptionKeyPairData.SignedPublicKey != null && SignatureKeyPairData != null && SecurityStateData != null)
{
return true;
}
else if (PublicKeyEncryptionKeyPairData.SignedPublicKey == null && SignatureKeyPairData == null && SecurityStateData == null)
{
return false;
}
else
{
throw new InvalidOperationException("Invalid account cryptographic state: V2 encryption fields must be either all present or all absent.");
}
}
}

View File

@@ -0,0 +1,8 @@
using Bit.Core.KeyManagement.Models.Data;
namespace Bit.Core.KeyManagement.Queries.Interfaces;
public interface IKeyConnectorConfirmationDetailsQuery
{
public Task<KeyConnectorConfirmationDetails> Run(string orgSsoIdentifier, Guid userId);
}

View File

@@ -0,0 +1,35 @@
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.Repositories;
namespace Bit.Core.KeyManagement.Queries;
public class KeyConnectorConfirmationDetailsQuery : IKeyConnectorConfirmationDetailsQuery
{
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
public KeyConnectorConfirmationDetailsQuery(IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
}
public async Task<KeyConnectorConfirmationDetails> Run(string orgSsoIdentifier, Guid userId)
{
var org = await _organizationRepository.GetByIdentifierAsync(orgSsoIdentifier);
if (org is not { UseKeyConnector: true })
{
throw new NotFoundException();
}
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, userId);
if (orgUser == null)
{
throw new NotFoundException();
}
return new KeyConnectorConfirmationDetails { OrganizationName = org.Name, };
}
}

View File

@@ -1,4 +1,5 @@
using Bit.Core.Entities;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Models.Data;
@@ -44,5 +45,17 @@ public interface IUserRepository : IRepository<User, Guid>
IEnumerable<UpdateEncryptedDataForKeyRotation> updateDataActions);
Task UpdateUserKeyAndEncryptedDataV2Async(User user,
IEnumerable<UpdateEncryptedDataForKeyRotation> updateDataActions);
/// <summary>
/// Sets the account cryptographic state to a user in a single transaction. The provided
/// MUST be a V2 encryption state. Passing in a V1 encryption state will throw.
/// Extra actions can be passed in case other user data needs to be updated in the same transaction.
/// </summary>
Task SetV2AccountCryptographicStateAsync(
Guid userId,
UserAccountKeysData accountKeysData,
IEnumerable<UpdateUserData>? updateUserDataActions = null);
Task DeleteManyAsync(IEnumerable<User> users);
}
public delegate Task UpdateUserData(Microsoft.Data.SqlClient.SqlConnection? connection = null,
Microsoft.Data.SqlClient.SqlTransaction? transaction = null);

View File

@@ -57,14 +57,16 @@ public class AzureQueueHostedService : IHostedService, IDisposable
private async Task ExecuteAsync(CancellationToken cancellationToken)
{
var storageConnectionString = _configuration["azureStorageConnectionString"];
if (string.IsNullOrWhiteSpace(storageConnectionString))
var queueName = _configuration["azureQueueServiceQueueName"];
if (string.IsNullOrWhiteSpace(storageConnectionString) ||
string.IsNullOrWhiteSpace(queueName))
{
return;
}
var repo = new Core.Repositories.TableStorage.EventRepository(storageConnectionString);
_eventWriteService = new RepositoryEventWriteService(repo);
_queueClient = new QueueClient(storageConnectionString, "event");
_queueClient = new QueueClient(storageConnectionString, queueName);
while (!cancellationToken.IsCancellationRequested)
{

View File

@@ -2,16 +2,16 @@
using System.Text.Json;
using Bit.Core;
using Bit.Core.Entities;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Dapper;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Data.SqlClient;
#nullable enable
namespace Bit.Infrastructure.Dapper.Repositories;
public class UserRepository : Repository<User, Guid>, IUserRepository
@@ -288,6 +288,63 @@ public class UserRepository : Repository<User, Guid>, IUserRepository
UnprotectData(user);
}
public async Task SetV2AccountCryptographicStateAsync(
Guid userId,
UserAccountKeysData accountKeysData,
IEnumerable<UpdateUserData>? updateUserDataActions = null)
{
if (!accountKeysData.IsV2Encryption())
{
throw new ArgumentException("Provided account keys data is not valid V2 encryption data.", nameof(accountKeysData));
}
var timestamp = DateTime.UtcNow;
var signatureKeyPairId = CoreHelpers.GenerateComb();
await using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync();
await using var transaction = connection.BeginTransaction();
try
{
await connection.ExecuteAsync(
"[dbo].[User_UpdateAccountCryptographicState]",
new
{
Id = userId,
PublicKey = accountKeysData.PublicKeyEncryptionKeyPairData.PublicKey,
PrivateKey = accountKeysData.PublicKeyEncryptionKeyPairData.WrappedPrivateKey,
SignedPublicKey = accountKeysData.PublicKeyEncryptionKeyPairData.SignedPublicKey,
SecurityState = accountKeysData.SecurityStateData!.SecurityState,
SecurityVersion = accountKeysData.SecurityStateData!.SecurityVersion,
SignatureKeyPairId = signatureKeyPairId,
SignatureAlgorithm = accountKeysData.SignatureKeyPairData!.SignatureAlgorithm,
SigningKey = accountKeysData.SignatureKeyPairData!.WrappedSigningKey,
VerifyingKey = accountKeysData.SignatureKeyPairData!.VerifyingKey,
RevisionDate = timestamp,
AccountRevisionDate = timestamp
},
transaction: transaction,
commandType: CommandType.StoredProcedure);
// Update user data that depends on cryptographic state
if (updateUserDataActions != null)
{
foreach (var action in updateUserDataActions)
{
await action(connection, transaction);
}
}
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
public async Task<IEnumerable<User>> GetManyAsync(IEnumerable<Guid> ids)
{
using (var connection = new SqlConnection(ReadOnlyConnectionString))

View File

@@ -1,4 +1,5 @@
using AutoMapper;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
@@ -241,6 +242,80 @@ public class UserRepository : Repository<Core.Entities.User, User, Guid>, IUserR
await transaction.CommitAsync();
}
public async Task SetV2AccountCryptographicStateAsync(
Guid userId,
UserAccountKeysData accountKeysData,
IEnumerable<UpdateUserData>? updateUserDataActions = null)
{
if (!accountKeysData.IsV2Encryption())
{
throw new ArgumentException("Provided account keys data is not valid V2 encryption data.", nameof(accountKeysData));
}
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
await using var transaction = await dbContext.Database.BeginTransactionAsync();
// Update user
var userEntity = await dbContext.Users.FindAsync(userId);
if (userEntity == null)
{
throw new ArgumentException("User not found", nameof(userId));
}
// Update public key encryption key pair
var timestamp = DateTime.UtcNow;
userEntity.RevisionDate = timestamp;
userEntity.AccountRevisionDate = timestamp;
// V1 + V2 user crypto changes
userEntity.PublicKey = accountKeysData.PublicKeyEncryptionKeyPairData.PublicKey;
userEntity.PrivateKey = accountKeysData.PublicKeyEncryptionKeyPairData.WrappedPrivateKey;
userEntity.SecurityState = accountKeysData.SecurityStateData!.SecurityState;
userEntity.SecurityVersion = accountKeysData.SecurityStateData.SecurityVersion;
userEntity.SignedPublicKey = accountKeysData.PublicKeyEncryptionKeyPairData.SignedPublicKey;
// Replace existing keypair if it exists
var existingKeyPair = await dbContext.UserSignatureKeyPairs
.FirstOrDefaultAsync(x => x.UserId == userId);
if (existingKeyPair != null)
{
existingKeyPair.SignatureAlgorithm = accountKeysData.SignatureKeyPairData!.SignatureAlgorithm;
existingKeyPair.SigningKey = accountKeysData.SignatureKeyPairData.WrappedSigningKey;
existingKeyPair.VerifyingKey = accountKeysData.SignatureKeyPairData.VerifyingKey;
existingKeyPair.RevisionDate = timestamp;
}
else
{
var newKeyPair = new UserSignatureKeyPair
{
UserId = userId,
SignatureAlgorithm = accountKeysData.SignatureKeyPairData!.SignatureAlgorithm,
SigningKey = accountKeysData.SignatureKeyPairData.WrappedSigningKey,
VerifyingKey = accountKeysData.SignatureKeyPairData.VerifyingKey,
CreationDate = timestamp,
RevisionDate = timestamp
};
newKeyPair.SetNewId();
await dbContext.UserSignatureKeyPairs.AddAsync(newKeyPair);
}
await dbContext.SaveChangesAsync();
// Update additional user data within the same transaction
if (updateUserDataActions != null)
{
foreach (var action in updateUserDataActions)
{
await action();
}
}
await transaction.CommitAsync();
}
public async Task<IEnumerable<Core.Entities.User>> GetManyAsync(IEnumerable<Guid> ids)
{
using (var scope = ServiceScopeFactory.CreateScope())

View File

@@ -704,6 +704,9 @@ public class CipherRepository : Repository<Core.Vault.Entities.Cipher, Cipher, G
trackedCipher.RevisionDate = cipher.RevisionDate;
trackedCipher.DeletedDate = cipher.DeletedDate;
trackedCipher.Key = cipher.Key;
trackedCipher.Folders = cipher.Folders;
trackedCipher.Favorites = cipher.Favorites;
trackedCipher.Reprompt = cipher.Reprompt;
await transaction.CommitAsync();

View File

@@ -0,0 +1,65 @@
CREATE PROCEDURE [dbo].[User_UpdateAccountCryptographicState]
@Id UNIQUEIDENTIFIER,
@PublicKey NVARCHAR(MAX),
@PrivateKey NVARCHAR(MAX),
@SignedPublicKey NVARCHAR(MAX) = NULL,
@SecurityState NVARCHAR(MAX) = NULL,
@SecurityVersion INT = NULL,
@SignatureKeyPairId UNIQUEIDENTIFIER = NULL,
@SignatureAlgorithm TINYINT = NULL,
@SigningKey VARCHAR(MAX) = NULL,
@VerifyingKey VARCHAR(MAX) = NULL,
@RevisionDate DATETIME2(7),
@AccountRevisionDate DATETIME2(7)
AS
BEGIN
SET NOCOUNT ON
UPDATE
[dbo].[User]
SET
[PublicKey] = @PublicKey,
[PrivateKey] = @PrivateKey,
[SignedPublicKey] = @SignedPublicKey,
[SecurityState] = @SecurityState,
[SecurityVersion] = @SecurityVersion,
[RevisionDate] = @RevisionDate,
[AccountRevisionDate] = @AccountRevisionDate
WHERE
[Id] = @Id
IF EXISTS (SELECT 1 FROM [dbo].[UserSignatureKeyPair] WHERE [UserId] = @Id)
BEGIN
UPDATE [dbo].[UserSignatureKeyPair]
SET
[SignatureAlgorithm] = @SignatureAlgorithm,
[SigningKey] = @SigningKey,
[VerifyingKey] = @VerifyingKey,
[RevisionDate] = @RevisionDate
WHERE
[UserId] = @Id
END
ELSE
BEGIN
INSERT INTO [dbo].[UserSignatureKeyPair]
(
[Id],
[UserId],
[SignatureAlgorithm],
[SigningKey],
[VerifyingKey],
[CreationDate],
[RevisionDate]
)
VALUES
(
@SignatureKeyPairId,
@Id,
@SignatureAlgorithm,
@SigningKey,
@VerifyingKey,
@RevisionDate,
@RevisionDate
)
END
END

View File

@@ -38,8 +38,13 @@ BEGIN
[Data] = @Data,
[Attachments] = @Attachments,
[RevisionDate] = @RevisionDate,
[DeletedDate] = @DeletedDate, [Key] = @Key, [ArchivedDate] = @ArchivedDate
-- No need to update CreationDate, Favorites, Folders, or Type since that data will not change
[DeletedDate] = @DeletedDate,
[Key] = @Key,
[ArchivedDate] = @ArchivedDate,
[Folders] = @Folders,
[Favorites] = @Favorites,
[Reprompt] = @Reprompt
-- No need to update CreationDate or Type since that data will not change
WHERE
[Id] = @Id