diff --git a/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs b/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs
index cabdca59ea..3d552a10de 100644
--- a/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs
+++ b/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs
@@ -1,9 +1,34 @@
namespace Bit.Core.KeyManagement.Models.Data;
-
+///
+/// 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.
+///
public class UserAccountKeysData
{
public required PublicKeyEncryptionKeyPairData PublicKeyEncryptionKeyPairData { get; set; }
public SignatureKeyPairData? SignatureKeyPairData { get; set; }
public SecurityStateData? SecurityStateData { get; set; }
+
+ ///
+ /// Checks whether the account cryptographic state is for a V1 encryption user or a V2 encryption user.
+ /// Throws if the state is invalid
+ ///
+ 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.");
+ }
+ }
}
diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs
index 22effb4329..7cdd159224 100644
--- a/src/Core/Repositories/IUserRepository.cs
+++ b/src/Core/Repositories/IUserRepository.cs
@@ -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
IEnumerable updateDataActions);
Task UpdateUserKeyAndEncryptedDataV2Async(User user,
IEnumerable updateDataActions);
+ ///
+ /// 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.
+ ///
+ Task SetV2AccountCryptographicStateAsync(
+ Guid userId,
+ UserAccountKeysData accountKeysData,
+ IEnumerable? updateUserDataActions = null);
Task DeleteManyAsync(IEnumerable users);
}
+
+public delegate Task UpdateUserData(Microsoft.Data.SqlClient.SqlConnection? connection = null,
+ Microsoft.Data.SqlClient.SqlTransaction? transaction = null);
diff --git a/src/Infrastructure.Dapper/Repositories/UserRepository.cs b/src/Infrastructure.Dapper/Repositories/UserRepository.cs
index 6b11d64cda..86ab063a5f 100644
--- a/src/Infrastructure.Dapper/Repositories/UserRepository.cs
+++ b/src/Infrastructure.Dapper/Repositories/UserRepository.cs
@@ -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, IUserRepository
@@ -288,6 +288,63 @@ public class UserRepository : Repository, IUserRepository
UnprotectData(user);
}
+ public async Task SetV2AccountCryptographicStateAsync(
+ Guid userId,
+ UserAccountKeysData accountKeysData,
+ IEnumerable? 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> GetManyAsync(IEnumerable ids)
{
using (var connection = new SqlConnection(ReadOnlyConnectionString))
diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs
index 809704edb7..a43c692be3 100644
--- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs
+++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs
@@ -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, IUserR
await transaction.CommitAsync();
}
+ public async Task SetV2AccountCryptographicStateAsync(
+ Guid userId,
+ UserAccountKeysData accountKeysData,
+ IEnumerable? 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> GetManyAsync(IEnumerable ids)
{
using (var scope = ServiceScopeFactory.CreateScope())
diff --git a/src/Sql/dbo/Stored Procedures/User_UpdateAccountCryptographicState.sql b/src/Sql/dbo/Stored Procedures/User_UpdateAccountCryptographicState.sql
new file mode 100644
index 0000000000..8f1fb664ea
--- /dev/null
+++ b/src/Sql/dbo/Stored Procedures/User_UpdateAccountCryptographicState.sql
@@ -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
diff --git a/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs
index 151bd47c44..37a3512d76 100644
--- a/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs
+++ b/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs
@@ -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 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);
+ }
}
diff --git a/util/Migrator/DbScripts/2025-12-08_00_User_UpdateAccountCryptographicState.sql b/util/Migrator/DbScripts/2025-12-08_00_User_UpdateAccountCryptographicState.sql
new file mode 100644
index 0000000000..259a126220
--- /dev/null
+++ b/util/Migrator/DbScripts/2025-12-08_00_User_UpdateAccountCryptographicState.sql
@@ -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