1
0
mirror of https://github.com/bitwarden/server synced 2026-02-27 01:43:46 +00:00

[PM-31052][PM-32469] Add V2UpgradeToken for key rotation without logout (#6995)

* User V2UpgradeToken for key rotation without logout

* reset old v2 upgrade token on manual key rotation

* sql migration fix

* missing table column

* missing view update

* tests for V2UpgradeToken clearing on manual key rotation

* V2 to V2 rotation causes logout. Updated wrapped key 1 to be a valid V2 encrypted string in tests.

* integration tests failures - increase assert recent for date time type from 2 to 5 seconds (usually for UpdatedAt assertions)

* repository test coverage

* migration script update

* new EF migration scripts

* broken EF migration scripts fixed

* refresh views due to User table alternation
This commit is contained in:
Maciej Zieniuk
2026-02-20 20:19:14 +01:00
committed by GitHub
parent a961626957
commit 6a7b8f5a89
35 changed files with 12395 additions and 242 deletions

View File

@@ -210,6 +210,7 @@ public static class FeatureFlagKeys
public const string EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration";
public const string SdkKeyRotation = "pm-30144-sdk-key-rotation";
public const string UnlockViaSdk = "unlock-via-sdk";
public const string NoLogoutOnKeyUpgradeRotation = "pm-31050-no-logout-key-upgrade-rotation";
public const string EnableAccountEncryptionV2JitPasswordRegistration = "enable-account-encryption-v2-jit-password-registration";
/* Mobile Team */

View File

@@ -105,6 +105,11 @@ public class User : ITableObject<Guid>, IStorableSubscriber, IRevisable, ITwoFac
public DateTime? LastKeyRotationDate { get; set; }
public DateTime? LastEmailChangeDate { get; set; }
public bool VerifyDevices { get; set; } = true;
/// <summary>
/// V2 upgrade token stored as JSON containing two wrapped user keys.
/// Allows clients to unlock vault after V1 to V2 key rotation without logout.
/// </summary>
public string? V2UpgradeToken { get; set; }
// PM-28827 Uncomment below line.
// public string? MasterPasswordSalt { get; set; }

View File

@@ -2,5 +2,6 @@
public enum PushNotificationLogOutReason : byte
{
KdfChange = 0
KdfChange = 0,
KeyRotation = 1
}

View File

@@ -15,4 +15,10 @@ public class UserDecryptionResponseModel
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public WebAuthnPrfDecryptionOption[]? WebAuthnPrfOptions { get; set; }
/// <summary>
/// V2 upgrade token returned when available, allowing unlock after V1→V2 upgrade.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public V2UpgradeTokenResponseModel? V2UpgradeToken { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace Bit.Core.KeyManagement.Models.Api.Response;
public class V2UpgradeTokenResponseModel
{
public required string WrappedUserKey1 { get; set; }
public required string WrappedUserKey2 { get; set; }
}

View File

@@ -20,6 +20,7 @@ public class RotateUserAccountKeysData
public required IReadOnlyList<OrganizationUser> OrganizationUsers { get; set; }
public required IEnumerable<WebAuthnLoginRotateKeyData> WebAuthnKeys { get; set; }
public required IEnumerable<Device> DeviceKeys { get; set; }
public V2UpgradeTokenData? V2UpgradeToken { get; set; }
// User vault data encrypted by the userkey
public required IEnumerable<Cipher> Ciphers { get; set; }

View File

@@ -0,0 +1,31 @@
using System.Text.Json;
namespace Bit.Core.KeyManagement.Models.Data;
public class V2UpgradeTokenData
{
public required string WrappedUserKey1 { get; init; }
public required string WrappedUserKey2 { get; init; }
public string ToJson()
{
return JsonSerializer.Serialize(this);
}
public static V2UpgradeTokenData? FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
return JsonSerializer.Deserialize<V2UpgradeTokenData>(json);
}
catch (JsonException)
{
return null;
}
}
}

View File

@@ -90,7 +90,19 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
var now = DateTime.UtcNow;
user.RevisionDate = user.AccountRevisionDate = now;
user.LastKeyRotationDate = now;
user.SecurityStamp = Guid.NewGuid().ToString();
// V2UpgradeToken is only valid for V1 users transitioning to V2.
// For V2 users the token is semantically invalid — discard it and perform a full logout.
var shouldPersistV2UpgradeToken = model.V2UpgradeToken != null && !IsV2EncryptionUserAsync(user);
if (shouldPersistV2UpgradeToken)
{
user.V2UpgradeToken = model.V2UpgradeToken!.ToJson();
}
else
{
user.V2UpgradeToken = null;
user.SecurityStamp = Guid.NewGuid().ToString();
}
List<UpdateEncryptedDataForKeyRotation> saveEncryptedDataActions = [];
@@ -99,7 +111,17 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
UpdateUserData(model, user, saveEncryptedDataActions);
await _userRepository.UpdateUserKeyAndEncryptedDataV2Async(user, saveEncryptedDataActions);
await _pushService.PushLogOutAsync(user.Id);
if (shouldPersistV2UpgradeToken)
{
await _pushService.PushLogOutAsync(user.Id,
reason: PushNotificationLogOutReason.KeyRotation);
}
else
{
await _pushService.PushLogOutAsync(user.Id);
}
return IdentityResult.Success;
}