1
0
mirror of https://github.com/bitwarden/server synced 2026-01-29 15:53:36 +00:00

PM-2035: PRF Unlock (#6401)

* Initial refactor

* Add WebauthnPRFOptions to syncResponse

* MAYBE: Use KM owned ResponseModel?

* REVERT ^- Keep using PrfUnlockOptions for simplicity

This reverts commit 5a34e7dfa8.

* UserDecryptionOptions: Only send one credential

* format

* Update UserDecryptionOptions.cs

* format

* Added feature flag (#6600)
This commit is contained in:
Anders Åberg
2026-01-26 16:18:42 +01:00
committed by GitHub
parent c8124667ee
commit 40e293117d
7 changed files with 50 additions and 7 deletions

View File

@@ -6,6 +6,7 @@ using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Context;
using Bit.Core.Entities;
@@ -44,6 +45,7 @@ public class SyncController : Controller
private readonly IFeatureService _featureService;
private readonly IApplicationCacheService _applicationCacheService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository;
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
public SyncController(
@@ -61,6 +63,7 @@ public class SyncController : Controller
IFeatureService featureService,
IApplicationCacheService applicationCacheService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IWebAuthnCredentialRepository webAuthnCredentialRepository,
IUserAccountKeysQuery userAccountKeysQuery)
{
_userService = userService;
@@ -77,6 +80,7 @@ public class SyncController : Controller
_featureService = featureService;
_applicationCacheService = applicationCacheService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_webAuthnCredentialRepository = webAuthnCredentialRepository;
_userAccountKeysQuery = userAccountKeysQuery;
}
@@ -120,6 +124,9 @@ public class SyncController : Controller
var organizationIdsClaimingActiveUser = organizationClaimingActiveUser.Select(o => o.Id);
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
var webAuthnCredentials = _featureService.IsEnabled(FeatureFlagKeys.PM2035PasskeyUnlock)
? await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id)
: [];
UserAccountKeysData userAccountKeys = null;
// JIT TDE users and some broken/old users may not have a private key.
@@ -130,7 +137,7 @@ public class SyncController : Controller
var response = new SyncResponseModel(_globalSettings, user, userAccountKeys, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities,
organizationIdsClaimingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails,
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends, webAuthnCredentials);
return response;
}

View File

@@ -6,6 +6,9 @@ using Bit.Api.Models.Response;
using Bit.Api.Tools.Models.Response;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Entities;
using Bit.Core.KeyManagement.Models.Api.Response;
using Bit.Core.KeyManagement.Models.Data;
@@ -39,7 +42,8 @@ public class SyncResponseModel() : ResponseModel("sync")
IDictionary<Guid, IGrouping<Guid, CollectionCipher>> collectionCiphersDict,
bool excludeDomains,
IEnumerable<Policy> policies,
IEnumerable<Send> sends)
IEnumerable<Send> sends,
IEnumerable<WebAuthnCredential> webAuthnCredentials)
: this()
{
Profile = new ProfileResponseModel(user, userAccountKeysData, organizationUserDetails, providerUserDetails,
@@ -57,6 +61,16 @@ public class SyncResponseModel() : ResponseModel("sync")
Domains = excludeDomains ? null : new DomainsResponseModel(user, false);
Policies = policies?.Select(p => new PolicyResponseModel(p)) ?? new List<PolicyResponseModel>();
Sends = sends.Select(s => new SendResponseModel(s));
var webAuthnPrfOptions = webAuthnCredentials
.Where(c => c.GetPrfStatus() == WebAuthnPrfStatus.Enabled)
.Select(c => new WebAuthnPrfDecryptionOption(
c.EncryptedPrivateKey,
c.EncryptedUserKey,
c.CredentialId,
[] // transports as empty array
))
.ToArray();
UserDecryption = new UserDecryptionResponseModel
{
MasterPasswordUnlock = user.HasMasterPassword()
@@ -72,7 +86,8 @@ public class SyncResponseModel() : ResponseModel("sync")
MasterKeyEncryptedUserKey = user.Key!,
Salt = user.Email.ToLowerInvariant()
}
: null
: null,
WebAuthnPrfOptions = webAuthnPrfOptions.Length > 0 ? webAuthnPrfOptions : null
};
}

View File

@@ -45,13 +45,19 @@ public class WebAuthnPrfDecryptionOption
{
public string EncryptedPrivateKey { get; }
public string EncryptedUserKey { get; }
public string CredentialId { get; }
public string[] Transports { get; }
public WebAuthnPrfDecryptionOption(
string encryptedPrivateKey,
string encryptedUserKey)
string encryptedUserKey,
string credentialId,
string[]? transports = null)
{
EncryptedPrivateKey = encryptedPrivateKey;
EncryptedUserKey = encryptedUserKey;
CredentialId = credentialId;
Transports = transports ?? [];
}
}

View File

@@ -160,6 +160,7 @@ public static class FeatureFlagKeys
public const string PM24579_PreventSsoOnExistingNonCompliantUsers = "pm-24579-prevent-sso-on-existing-non-compliant-users";
public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods";
public const string MJMLBasedEmailTemplates = "mjml-based-email-templates";
public const string PM2035PasskeyUnlock = "pm-2035-passkey-unlock";
public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email";
public const string OrganizationConfirmationEmail = "pm-28402-update-confirmed-to-org-email-template";
public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow";

View File

@@ -1,4 +1,7 @@
namespace Bit.Core.KeyManagement.Models.Api.Response;
using System.Text.Json.Serialization;
using Bit.Core.Auth.Models.Api.Response;
namespace Bit.Core.KeyManagement.Models.Api.Response;
public class UserDecryptionResponseModel
{
@@ -6,4 +9,10 @@ public class UserDecryptionResponseModel
/// Returns the unlock data when the user has a master password that can be used to decrypt their vault.
/// </summary>
public MasterPasswordUnlockResponseModel? MasterPasswordUnlock { get; set; }
/// <summary>
/// Gets or sets the WebAuthn PRF decryption keys.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public WebAuthnPrfDecryptionOption[]? WebAuthnPrfOptions { get; set; }
}

View File

@@ -64,8 +64,12 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
{
if (credential.GetPrfStatus() == WebAuthnPrfStatus.Enabled)
{
_options.WebAuthnPrfOption =
new WebAuthnPrfDecryptionOption(credential.EncryptedPrivateKey, credential.EncryptedUserKey);
_options.WebAuthnPrfOption = new WebAuthnPrfDecryptionOption(
credential.EncryptedPrivateKey,
credential.EncryptedUserKey,
credential.CredentialId,
[] // Stored credentials currently lack Transports, just send an empty array for now
);
}
return this;

View File

@@ -60,6 +60,7 @@ public class UserDecryptionOptionsBuilderTests
{
Assert.NotNull(result.WebAuthnPrfOption);
Assert.Equal(credential.EncryptedPrivateKey, result.WebAuthnPrfOption!.EncryptedPrivateKey);
Assert.Equal(credential.CredentialId, result.WebAuthnPrfOption!.CredentialId);
Assert.Equal(credential.EncryptedUserKey, result.WebAuthnPrfOption!.EncryptedUserKey);
}
else