mirror of
https://github.com/bitwarden/server
synced 2026-01-28 15:23:38 +00:00
Merge branch 'main' into auth/pm-27084/register-accepts-new-data-types
This commit is contained in:
9
.github/ISSUE_TEMPLATE/bw-lite.yml
vendored
9
.github/ISSUE_TEMPLATE/bw-lite.yml
vendored
@@ -70,15 +70,6 @@ body:
|
||||
mariadb:10
|
||||
# Postgres Example
|
||||
postgres:14
|
||||
- type: textarea
|
||||
id: epic-label
|
||||
attributes:
|
||||
label: Issue-Link
|
||||
description: Link to our pinned issue, tracking all Bitwarden lite
|
||||
value: |
|
||||
https://github.com/bitwarden/server/issues/2480
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: issue-tracking-info
|
||||
attributes:
|
||||
|
||||
14
.github/workflows/repository-management.yml
vendored
14
.github/workflows/repository-management.yml
vendored
@@ -22,9 +22,7 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
@@ -32,6 +30,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
branch: ${{ steps.set-branch.outputs.branch }}
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Set branch
|
||||
id: set-branch
|
||||
@@ -89,6 +88,7 @@ jobs:
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||
permission-contents: write
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -212,6 +212,7 @@ jobs:
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||
permission-contents: write
|
||||
|
||||
- name: Check out target ref
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -240,10 +241,5 @@ jobs:
|
||||
move_edd_db_scripts:
|
||||
name: Move EDD database scripts
|
||||
needs: cut_branch
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
permissions: {}
|
||||
uses: ./.github/workflows/_move_edd_db_scripts.yml
|
||||
secrets: inherit
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -26,5 +26,6 @@ public static class KeyManagementServiceCollectionExtensions
|
||||
private static void AddKeyManagementQueries(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IUserAccountKeysQuery, UserAccountKeysQuery>();
|
||||
services.AddScoped<IKeyConnectorConfirmationDetailsQuery, KeyConnectorConfirmationDetailsQuery>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Bit.Core.KeyManagement.Models.Data;
|
||||
|
||||
public class KeyConnectorConfirmationDetails
|
||||
{
|
||||
public required string OrganizationName { get; set; }
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ using System.Net;
|
||||
using Bit.Api.IntegrationTest.Factories;
|
||||
using Bit.Api.IntegrationTest.Helpers;
|
||||
using Bit.Api.KeyManagement.Models.Requests;
|
||||
using Bit.Api.KeyManagement.Models.Responses;
|
||||
using Bit.Api.Tools.Models.Request;
|
||||
using Bit.Api.Vault.Models;
|
||||
using Bit.Api.Vault.Models.Request;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
@@ -286,20 +288,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
|
||||
public async Task PostSetKeyConnectorKeyAsync_Success(string organizationSsoIdentifier,
|
||||
SetKeyConnectorKeyRequestModel request)
|
||||
{
|
||||
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory,
|
||||
PlanType.EnterpriseAnnually, _ownerEmail, passwordManagerSeats: 10,
|
||||
paymentMethod: PaymentMethodType.Card);
|
||||
organization.UseKeyConnector = true;
|
||||
organization.UseSso = true;
|
||||
organization.Identifier = organizationSsoIdentifier;
|
||||
await _organizationRepository.ReplaceAsync(organization);
|
||||
|
||||
var ssoUserEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
|
||||
await _factory.LoginWithNewAccount(ssoUserEmail);
|
||||
await _loginHelper.LoginAsync(ssoUserEmail);
|
||||
|
||||
await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, ssoUserEmail,
|
||||
OrganizationUserType.User, userStatusType: OrganizationUserStatusType.Invited);
|
||||
var (ssoUserEmail, organization) = await SetupKeyConnectorTestAsync(OrganizationUserStatusType.Invited, organizationSsoIdentifier);
|
||||
|
||||
var ssoUser = await _userRepository.GetByEmailAsync(ssoUserEmail);
|
||||
Assert.NotNull(ssoUser);
|
||||
@@ -340,19 +329,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
|
||||
[Fact]
|
||||
public async Task PostConvertToKeyConnectorAsync_Success()
|
||||
{
|
||||
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory,
|
||||
PlanType.EnterpriseAnnually, _ownerEmail, passwordManagerSeats: 10,
|
||||
paymentMethod: PaymentMethodType.Card);
|
||||
organization.UseKeyConnector = true;
|
||||
organization.UseSso = true;
|
||||
await _organizationRepository.ReplaceAsync(organization);
|
||||
|
||||
var ssoUserEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
|
||||
await _factory.LoginWithNewAccount(ssoUserEmail);
|
||||
await _loginHelper.LoginAsync(ssoUserEmail);
|
||||
|
||||
await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, ssoUserEmail,
|
||||
OrganizationUserType.User, userStatusType: OrganizationUserStatusType.Accepted);
|
||||
var (ssoUserEmail, organization) = await SetupKeyConnectorTestAsync(OrganizationUserStatusType.Accepted);
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/accounts/convert-to-key-connector", new { });
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -556,4 +533,41 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
|
||||
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfMemory, userNewState.KdfMemory);
|
||||
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfParallelism, userNewState.KdfParallelism);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetKeyConnectorConfirmationDetailsAsync_Success()
|
||||
{
|
||||
var (ssoUserEmail, organization) = await SetupKeyConnectorTestAsync(OrganizationUserStatusType.Invited);
|
||||
|
||||
await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, ssoUserEmail,
|
||||
OrganizationUserType.User, userStatusType: OrganizationUserStatusType.Accepted);
|
||||
|
||||
var response = await _client.GetAsync($"/accounts/key-connector/confirmation-details/{organization.Identifier}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<KeyConnectorConfirmationDetailsResponseModel>();
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(organization.Name, result.OrganizationName);
|
||||
}
|
||||
|
||||
private async Task<(string, Organization)> SetupKeyConnectorTestAsync(OrganizationUserStatusType userStatusType,
|
||||
string organizationSsoIdentifier = "test-sso-identifier")
|
||||
{
|
||||
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory,
|
||||
PlanType.EnterpriseAnnually, _ownerEmail, passwordManagerSeats: 10,
|
||||
paymentMethod: PaymentMethodType.Card);
|
||||
organization.UseKeyConnector = true;
|
||||
organization.UseSso = true;
|
||||
organization.Identifier = organizationSsoIdentifier;
|
||||
await _organizationRepository.ReplaceAsync(organization);
|
||||
|
||||
var ssoUserEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
|
||||
await _factory.LoginWithNewAccount(ssoUserEmail);
|
||||
await _loginHelper.LoginAsync(ssoUserEmail);
|
||||
|
||||
await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, ssoUserEmail,
|
||||
OrganizationUserType.User, userStatusType: userStatusType);
|
||||
|
||||
return (ssoUserEmail, organization);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Commands.Interfaces;
|
||||
using Bit.Core.KeyManagement.Models.Api.Request;
|
||||
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;
|
||||
@@ -363,4 +364,39 @@ public class AccountsKeyManagementControllerTests
|
||||
await sutProvider.GetDependency<IUserService>().Received(1)
|
||||
.ConvertToKeyConnectorAsync(Arg.Is(expectedUser));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetKeyConnectorConfirmationDetailsAsync_NoUser_Throws(
|
||||
SutProvider<AccountsKeyManagementController> sutProvider, string orgSsoIdentifier)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
|
||||
.ReturnsNull();
|
||||
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
|
||||
sutProvider.Sut.GetKeyConnectorConfirmationDetailsAsync(orgSsoIdentifier));
|
||||
|
||||
await sutProvider.GetDependency<IKeyConnectorConfirmationDetailsQuery>().ReceivedWithAnyArgs(0)
|
||||
.Run(Arg.Any<string>(), Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetKeyConnectorConfirmationDetailsAsync_Success(
|
||||
SutProvider<AccountsKeyManagementController> sutProvider, User expectedUser, string orgSsoIdentifier)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
|
||||
.Returns(expectedUser);
|
||||
sutProvider.GetDependency<IKeyConnectorConfirmationDetailsQuery>().Run(orgSsoIdentifier, expectedUser.Id)
|
||||
.Returns(
|
||||
new KeyConnectorConfirmationDetails { OrganizationName = "test" }
|
||||
);
|
||||
|
||||
var result = await sutProvider.Sut.GetKeyConnectorConfirmationDetailsAsync(orgSsoIdentifier);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("test", result.OrganizationName);
|
||||
await sutProvider.GetDependency<IKeyConnectorConfirmationDetailsQuery>().Received(1)
|
||||
.Run(orgSsoIdentifier, expectedUser.Id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1909,4 +1909,237 @@ public class CiphersControllerTests
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.PostPurge(model, organizationId));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task PutShare_WithNullFolderAndFalseFavorite_UpdatesFieldsCorrectly(
|
||||
Guid cipherId,
|
||||
Guid userId,
|
||||
Guid organizationId,
|
||||
Guid folderId,
|
||||
SutProvider<CiphersController> sutProvider)
|
||||
{
|
||||
var user = new User { Id = userId };
|
||||
var userIdKey = userId.ToString().ToUpperInvariant();
|
||||
|
||||
var existingCipher = new Cipher
|
||||
{
|
||||
Id = cipherId,
|
||||
UserId = userId,
|
||||
Type = CipherType.Login,
|
||||
Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }),
|
||||
Folders = JsonSerializer.Serialize(new Dictionary<string, object> { { userIdKey, folderId.ToString().ToUpperInvariant() } }),
|
||||
Favorites = JsonSerializer.Serialize(new Dictionary<string, object> { { userIdKey, true } })
|
||||
};
|
||||
|
||||
// Clears folder and favorite when sharing
|
||||
var model = new CipherShareRequestModel
|
||||
{
|
||||
Cipher = new CipherRequestModel
|
||||
{
|
||||
Type = CipherType.Login,
|
||||
OrganizationId = organizationId.ToString(),
|
||||
Name = "SharedCipher",
|
||||
Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }),
|
||||
FolderId = null,
|
||||
Favorite = false,
|
||||
EncryptedFor = userId
|
||||
},
|
||||
CollectionIds = [Guid.NewGuid().ToString()]
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
|
||||
.Returns(user);
|
||||
|
||||
sutProvider.GetDependency<ICipherRepository>()
|
||||
.GetByIdAsync(cipherId)
|
||||
.Returns(existingCipher);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>()
|
||||
.OrganizationUser(organizationId)
|
||||
.Returns(true);
|
||||
|
||||
var sharedCipher = new CipherDetails
|
||||
{
|
||||
Id = cipherId,
|
||||
OrganizationId = organizationId,
|
||||
Type = CipherType.Login,
|
||||
Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }),
|
||||
FolderId = null,
|
||||
Favorite = false
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ICipherRepository>()
|
||||
.GetByIdAsync(cipherId, userId)
|
||||
.Returns(sharedCipher);
|
||||
|
||||
sutProvider.GetDependency<IApplicationCacheService>()
|
||||
.GetOrganizationAbilitiesAsync()
|
||||
.Returns(new Dictionary<Guid, OrganizationAbility>
|
||||
{
|
||||
{ organizationId, new OrganizationAbility { Id = organizationId } }
|
||||
});
|
||||
|
||||
var result = await sutProvider.Sut.PutShare(cipherId, model);
|
||||
|
||||
Assert.Null(result.FolderId);
|
||||
Assert.False(result.Favorite);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task PutShare_WithFolderAndFavoriteSet_AddsUserSpecificFields(
|
||||
Guid cipherId,
|
||||
Guid userId,
|
||||
Guid organizationId,
|
||||
Guid folderId,
|
||||
SutProvider<CiphersController> sutProvider)
|
||||
{
|
||||
var user = new User { Id = userId };
|
||||
var userIdKey = userId.ToString().ToUpperInvariant();
|
||||
|
||||
var existingCipher = new Cipher
|
||||
{
|
||||
Id = cipherId,
|
||||
UserId = userId,
|
||||
Type = CipherType.Login,
|
||||
Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }),
|
||||
Folders = null,
|
||||
Favorites = null
|
||||
};
|
||||
|
||||
// Sets folder and favorite when sharing
|
||||
var model = new CipherShareRequestModel
|
||||
{
|
||||
Cipher = new CipherRequestModel
|
||||
{
|
||||
Type = CipherType.Login,
|
||||
OrganizationId = organizationId.ToString(),
|
||||
Name = "SharedCipher",
|
||||
Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }),
|
||||
FolderId = folderId.ToString(),
|
||||
Favorite = true,
|
||||
EncryptedFor = userId
|
||||
},
|
||||
CollectionIds = [Guid.NewGuid().ToString()]
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
|
||||
.Returns(user);
|
||||
|
||||
sutProvider.GetDependency<ICipherRepository>()
|
||||
.GetByIdAsync(cipherId)
|
||||
.Returns(existingCipher);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>()
|
||||
.OrganizationUser(organizationId)
|
||||
.Returns(true);
|
||||
|
||||
var sharedCipher = new CipherDetails
|
||||
{
|
||||
Id = cipherId,
|
||||
OrganizationId = organizationId,
|
||||
Type = CipherType.Login,
|
||||
Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }),
|
||||
Folders = JsonSerializer.Serialize(new Dictionary<string, object> { { userIdKey, folderId.ToString().ToUpperInvariant() } }),
|
||||
Favorites = JsonSerializer.Serialize(new Dictionary<string, object> { { userIdKey, true } }),
|
||||
FolderId = folderId,
|
||||
Favorite = true
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ICipherRepository>()
|
||||
.GetByIdAsync(cipherId, userId)
|
||||
.Returns(sharedCipher);
|
||||
|
||||
sutProvider.GetDependency<IApplicationCacheService>()
|
||||
.GetOrganizationAbilitiesAsync()
|
||||
.Returns(new Dictionary<Guid, OrganizationAbility>
|
||||
{
|
||||
{ organizationId, new OrganizationAbility { Id = organizationId } }
|
||||
});
|
||||
|
||||
var result = await sutProvider.Sut.PutShare(cipherId, model);
|
||||
|
||||
Assert.Equal(folderId, result.FolderId);
|
||||
Assert.True(result.Favorite);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task PutShare_UpdateExistingFolderAndFavorite_UpdatesUserSpecificFields(
|
||||
Guid cipherId,
|
||||
Guid userId,
|
||||
Guid organizationId,
|
||||
Guid oldFolderId,
|
||||
Guid newFolderId,
|
||||
SutProvider<CiphersController> sutProvider)
|
||||
{
|
||||
var user = new User { Id = userId };
|
||||
var userIdKey = userId.ToString().ToUpperInvariant();
|
||||
|
||||
// Existing cipher with old folder and not favorited
|
||||
var existingCipher = new Cipher
|
||||
{
|
||||
Id = cipherId,
|
||||
UserId = userId,
|
||||
Type = CipherType.Login,
|
||||
Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }),
|
||||
Folders = JsonSerializer.Serialize(new Dictionary<string, object> { { userIdKey, oldFolderId.ToString().ToUpperInvariant() } }),
|
||||
Favorites = null
|
||||
};
|
||||
|
||||
var model = new CipherShareRequestModel
|
||||
{
|
||||
Cipher = new CipherRequestModel
|
||||
{
|
||||
Type = CipherType.Login,
|
||||
OrganizationId = organizationId.ToString(),
|
||||
Name = "SharedCipher",
|
||||
Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }),
|
||||
FolderId = newFolderId.ToString(), // Update to new folder
|
||||
Favorite = true, // Add favorite
|
||||
EncryptedFor = userId
|
||||
},
|
||||
CollectionIds = [Guid.NewGuid().ToString()]
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
|
||||
.Returns(user);
|
||||
|
||||
sutProvider.GetDependency<ICipherRepository>()
|
||||
.GetByIdAsync(cipherId)
|
||||
.Returns(existingCipher);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>()
|
||||
.OrganizationUser(organizationId)
|
||||
.Returns(true);
|
||||
|
||||
var sharedCipher = new CipherDetails
|
||||
{
|
||||
Id = cipherId,
|
||||
OrganizationId = organizationId,
|
||||
Type = CipherType.Login,
|
||||
Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }),
|
||||
Folders = JsonSerializer.Serialize(new Dictionary<string, object> { { userIdKey, newFolderId.ToString().ToUpperInvariant() } }),
|
||||
Favorites = JsonSerializer.Serialize(new Dictionary<string, object> { { userIdKey, true } }),
|
||||
FolderId = newFolderId,
|
||||
Favorite = true
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ICipherRepository>()
|
||||
.GetByIdAsync(cipherId, userId)
|
||||
.Returns(sharedCipher);
|
||||
|
||||
sutProvider.GetDependency<IApplicationCacheService>()
|
||||
.GetOrganizationAbilitiesAsync()
|
||||
.Returns(new Dictionary<Guid, OrganizationAbility>
|
||||
{
|
||||
{ organizationId, new OrganizationAbility { Id = organizationId } }
|
||||
});
|
||||
|
||||
var result = await sutProvider.Sut.PutShare(cipherId, model);
|
||||
|
||||
Assert.Equal(newFolderId, result.FolderId);
|
||||
Assert.True(result.Favorite);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Queries;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.KeyManagement.Queries;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class KeyConnectorConfirmationDetailsQueryTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task Run_OrganizationNotFound_Throws(SutProvider<KeyConnectorConfirmationDetailsQuery> sutProvider,
|
||||
Guid userId, string orgSsoIdentifier)
|
||||
{
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.Run(orgSsoIdentifier, userId));
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.ReceivedWithAnyArgs(0)
|
||||
.GetByOrganizationAsync(Arg.Any<Guid>(), Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task Run_OrganizationNotKeyConnector_Throws(
|
||||
SutProvider<KeyConnectorConfirmationDetailsQuery> sutProvider,
|
||||
Guid userId, string orgSsoIdentifier, Organization org)
|
||||
{
|
||||
org.Identifier = orgSsoIdentifier;
|
||||
org.UseKeyConnector = false;
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdentifierAsync(orgSsoIdentifier).Returns(org);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.Run(orgSsoIdentifier, userId));
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.ReceivedWithAnyArgs(0)
|
||||
.GetByOrganizationAsync(Arg.Any<Guid>(), Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task Run_OrganizationUserNotFound_Throws(SutProvider<KeyConnectorConfirmationDetailsQuery> sutProvider,
|
||||
Guid userId, string orgSsoIdentifier
|
||||
, Organization org)
|
||||
{
|
||||
org.Identifier = orgSsoIdentifier;
|
||||
org.UseKeyConnector = true;
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdentifierAsync(orgSsoIdentifier).Returns(org);
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(Arg.Any<Guid>(), Arg.Any<Guid>()).Returns(Task.FromResult<OrganizationUser>(null));
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.Run(orgSsoIdentifier, userId));
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.GetByOrganizationAsync(org.Id, userId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task Run_Success(SutProvider<KeyConnectorConfirmationDetailsQuery> sutProvider, Guid userId,
|
||||
string orgSsoIdentifier
|
||||
, Organization org, OrganizationUser orgUser)
|
||||
{
|
||||
org.Identifier = orgSsoIdentifier;
|
||||
org.UseKeyConnector = true;
|
||||
orgUser.OrganizationId = org.Id;
|
||||
orgUser.UserId = userId;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdentifierAsync(orgSsoIdentifier).Returns(org);
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationAsync(org.Id, userId)
|
||||
.Returns(orgUser);
|
||||
|
||||
var result = await sutProvider.Sut.Run(orgSsoIdentifier, userId);
|
||||
|
||||
Assert.Equal(org.Name, result.OrganizationName);
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.GetByOrganizationAsync(org.Id, userId);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.KeyManagement.Enums;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Test.AutoFixture.Attributes;
|
||||
using Bit.Infrastructure.EFIntegration.Test.AutoFixture;
|
||||
@@ -313,4 +315,66 @@ public class UserRepositoryTests
|
||||
Assert.Equal(sqlUser.MasterPasswordHint, updatedUser.MasterPasswordHint);
|
||||
Assert.Equal(sqlUser.Email, updatedUser.Email);
|
||||
}
|
||||
|
||||
[CiSkippedTheory, EfUserAutoData]
|
||||
public async Task UpdateAccountCryptographicStateAsync_Works_DataMatches(
|
||||
User user,
|
||||
List<EfRepo.UserRepository> suts,
|
||||
SqlRepo.UserRepository sqlUserRepo)
|
||||
{
|
||||
// Test for V1 user (no signature key pair or security state)
|
||||
var accountKeysDataV1 = new UserAccountKeysData
|
||||
{
|
||||
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
|
||||
wrappedPrivateKey: "v1-wrapped-private-key",
|
||||
publicKey: "v1-public-key"
|
||||
)
|
||||
};
|
||||
|
||||
foreach (var sut in suts)
|
||||
{
|
||||
var createdUser = await sut.CreateAsync(user);
|
||||
sut.ClearChangeTracking();
|
||||
|
||||
await sut.SetV2AccountCryptographicStateAsync(createdUser.Id, accountKeysDataV1);
|
||||
sut.ClearChangeTracking();
|
||||
|
||||
var updatedUser = await sut.GetByIdAsync(createdUser.Id);
|
||||
Assert.Equal("v1-public-key", updatedUser.PublicKey);
|
||||
Assert.Equal("v1-wrapped-private-key", updatedUser.PrivateKey);
|
||||
Assert.Null(updatedUser.SignedPublicKey);
|
||||
Assert.Null(updatedUser.SecurityState);
|
||||
Assert.Null(updatedUser.SecurityVersion);
|
||||
}
|
||||
|
||||
// Test for V2 user (with signature key pair and security state)
|
||||
var accountKeysDataV2 = new UserAccountKeysData
|
||||
{
|
||||
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
|
||||
wrappedPrivateKey: "v2-wrapped-private-key",
|
||||
publicKey: "v2-public-key",
|
||||
signedPublicKey: "v2-signed-public-key"
|
||||
),
|
||||
SignatureKeyPairData = new SignatureKeyPairData(
|
||||
signatureAlgorithm: SignatureAlgorithm.Ed25519,
|
||||
wrappedSigningKey: "v2-wrapped-signing-key",
|
||||
verifyingKey: "v2-verifying-key"
|
||||
),
|
||||
SecurityStateData = new SecurityStateData
|
||||
{
|
||||
SecurityState = "v2-security-state",
|
||||
SecurityVersion = 2
|
||||
}
|
||||
};
|
||||
|
||||
var sqlUser = await sqlUserRepo.CreateAsync(user);
|
||||
await sqlUserRepo.SetV2AccountCryptographicStateAsync(sqlUser.Id, accountKeysDataV2);
|
||||
|
||||
var updatedSqlUser = await sqlUserRepo.GetByIdAsync(sqlUser.Id);
|
||||
Assert.Equal("v2-public-key", updatedSqlUser.PublicKey);
|
||||
Assert.Equal("v2-wrapped-private-key", updatedSqlUser.PrivateKey);
|
||||
Assert.Equal("v2-signed-public-key", updatedSqlUser.SignedPublicKey);
|
||||
Assert.Equal("v2-security-state", updatedSqlUser.SecurityState);
|
||||
Assert.Equal(2, updatedSqlUser.SecurityVersion);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,4 +225,58 @@ public class CipherRepositoryTests
|
||||
Assert.True(savedCipher == null);
|
||||
}
|
||||
}
|
||||
|
||||
[CiSkippedTheory, EfOrganizationCipherCustomize, BitAutoData]
|
||||
public async Task ReplaceAsync_WithCollections_UpdatesFoldersFavoritesRepromptAndArchivedDateAsync(
|
||||
Cipher cipher,
|
||||
User user,
|
||||
Organization org,
|
||||
Collection collection,
|
||||
List<EfVaultRepo.CipherRepository> suts,
|
||||
List<EfRepo.UserRepository> efUserRepos,
|
||||
List<EfRepo.OrganizationRepository> efOrgRepos,
|
||||
List<EfRepo.CollectionRepository> efCollectionRepos)
|
||||
{
|
||||
foreach (var sut in suts)
|
||||
{
|
||||
var i = suts.IndexOf(sut);
|
||||
|
||||
var postEfOrg = await efOrgRepos[i].CreateAsync(org);
|
||||
efOrgRepos[i].ClearChangeTracking();
|
||||
var postEfUser = await efUserRepos[i].CreateAsync(user);
|
||||
efUserRepos[i].ClearChangeTracking();
|
||||
|
||||
collection.OrganizationId = postEfOrg.Id;
|
||||
var postEfCollection = await efCollectionRepos[i].CreateAsync(collection);
|
||||
efCollectionRepos[i].ClearChangeTracking();
|
||||
|
||||
cipher.UserId = postEfUser.Id;
|
||||
cipher.OrganizationId = null;
|
||||
cipher.Folders = $"{{\"{postEfUser.Id}\":\"some-folder-id\"}}";
|
||||
cipher.Favorites = $"{{\"{postEfUser.Id}\":true}}";
|
||||
cipher.Reprompt = Core.Vault.Enums.CipherRepromptType.Password;
|
||||
|
||||
var createdCipher = await sut.CreateAsync(cipher);
|
||||
sut.ClearChangeTracking();
|
||||
|
||||
var updatedCipher = await sut.GetByIdAsync(createdCipher.Id);
|
||||
updatedCipher.UserId = postEfUser.Id;
|
||||
updatedCipher.OrganizationId = postEfOrg.Id;
|
||||
updatedCipher.Folders = $"{{\"{postEfUser.Id}\":\"new-folder-id\"}}";
|
||||
updatedCipher.Favorites = $"{{\"{postEfUser.Id}\":true}}";
|
||||
updatedCipher.Reprompt = Core.Vault.Enums.CipherRepromptType.Password;
|
||||
|
||||
await sut.ReplaceAsync(updatedCipher, new List<Guid> { postEfCollection.Id });
|
||||
sut.ClearChangeTracking();
|
||||
|
||||
|
||||
var savedCipher = await sut.GetByIdAsync(createdCipher.Id);
|
||||
Assert.NotNull(savedCipher);
|
||||
Assert.Null(savedCipher.UserId);
|
||||
Assert.Equal(postEfOrg.Id, savedCipher.OrganizationId);
|
||||
Assert.Equal($"{{\"{postEfUser.Id}\":\"new-folder-id\"}}", savedCipher.Folders);
|
||||
Assert.Equal($"{{\"{postEfUser.Id}\":true}}", savedCipher.Favorites);
|
||||
Assert.Equal(Core.Vault.Enums.CipherRepromptType.Password, savedCipher.Reprompt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
IF OBJECT_ID('[dbo].[User_UpdateAccountCryptographicState]') IS NOT NULL
|
||||
BEGIN
|
||||
DROP PROCEDURE [dbo].[User_UpdateAccountCryptographicState]
|
||||
END
|
||||
GO
|
||||
|
||||
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
|
||||
GO
|
||||
@@ -0,0 +1,62 @@
|
||||
CREATE OR ALTER PROCEDURE [dbo].[Cipher_UpdateWithCollections]
|
||||
@Id UNIQUEIDENTIFIER,
|
||||
@UserId UNIQUEIDENTIFIER,
|
||||
@OrganizationId UNIQUEIDENTIFIER,
|
||||
@Type TINYINT,
|
||||
@Data NVARCHAR(MAX),
|
||||
@Favorites NVARCHAR(MAX),
|
||||
@Folders NVARCHAR(MAX),
|
||||
@Attachments NVARCHAR(MAX),
|
||||
@CreationDate DATETIME2(7),
|
||||
@RevisionDate DATETIME2(7),
|
||||
@DeletedDate DATETIME2(7),
|
||||
@Reprompt TINYINT,
|
||||
@Key VARCHAR(MAX) = NULL,
|
||||
@CollectionIds AS [dbo].[GuidIdArray] READONLY,
|
||||
@ArchivedDate DATETIME2(7) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
BEGIN TRANSACTION Cipher_UpdateWithCollections
|
||||
|
||||
DECLARE @UpdateCollectionsSuccess INT
|
||||
EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds
|
||||
|
||||
IF @UpdateCollectionsSuccess < 0
|
||||
BEGIN
|
||||
COMMIT TRANSACTION Cipher_UpdateWithCollections
|
||||
SELECT -1 -- -1 = Failure
|
||||
RETURN
|
||||
END
|
||||
|
||||
UPDATE
|
||||
[dbo].[Cipher]
|
||||
SET
|
||||
[UserId] = NULL,
|
||||
[OrganizationId] = @OrganizationId,
|
||||
[Data] = @Data,
|
||||
[Attachments] = @Attachments,
|
||||
[RevisionDate] = @RevisionDate,
|
||||
[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
|
||||
|
||||
COMMIT TRANSACTION Cipher_UpdateWithCollections
|
||||
|
||||
IF @Attachments IS NOT NULL
|
||||
BEGIN
|
||||
EXEC [dbo].[Organization_UpdateStorage] @OrganizationId
|
||||
EXEC [dbo].[User_UpdateStorage] @UserId
|
||||
END
|
||||
|
||||
EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
|
||||
|
||||
SELECT 0 -- 0 = Success
|
||||
END
|
||||
Reference in New Issue
Block a user