From 9848d5368396811871ef79d8638d086a5ec683da Mon Sep 17 00:00:00 2001 From: Ike Kottlowski Date: Wed, 19 Mar 2025 22:54:23 -0400 Subject: [PATCH] feat : fix database script; add comments. --- .../OpaqueKeyExchangeController.cs | 10 ++-- .../Opaque/OpaqueRegistrationStartRequest.cs | 21 +++++---- .../Services/IOpaqueKeyExchangeService.cs | 47 ++++++++++++++++++- .../OpaqueKeyExchangeService.cs | 26 +++++----- .../Services/Implementations/UserService.cs | 4 +- .../Controllers/AccountsController.cs | 21 ++++++--- .../Accounts/PreloginResponseModel.cs | 4 +- .../OpaqueKeyExchangeCredentialRepository.cs | 14 +++--- ...2_00_CreateOpaqueKeyExchangeCredential.sql | 2 +- 9 files changed, 103 insertions(+), 46 deletions(-) diff --git a/src/Api/Auth/Controllers/OpaqueKeyExchangeController.cs b/src/Api/Auth/Controllers/OpaqueKeyExchangeController.cs index 38bf276795..f307d0d2c9 100644 --- a/src/Api/Auth/Controllers/OpaqueKeyExchangeController.cs +++ b/src/Api/Auth/Controllers/OpaqueKeyExchangeController.cs @@ -1,4 +1,6 @@ -using Bit.Core.Auth.Models.Api.Request.Opaque; +using Bit.Api.Auth.Models.Request.Opaque; +using Bit.Api.Auth.Models.Response.Opaque; +using Bit.Core.Auth.Models.Api.Request.Opaque; using Bit.Core.Auth.Models.Api.Response.Opaque; using Bit.Core.Auth.Services; using Bit.Core.Services; @@ -42,15 +44,15 @@ public class OpaqueKeyExchangeController : Controller // TODO: Remove and move to token endpoint [HttpPost("~/opaque/start-login")] - public async Task StartLoginAsync([FromBody] Models.Request.Opaque.OpaqueLoginStartRequest request) + public async Task StartLoginAsync([FromBody] OpaqueLoginStartRequest request) { var result = await _opaqueKeyExchangeService.StartLogin(Convert.FromBase64String(request.CredentialRequest), request.Email); - return new Models.Response.Opaque.OpaqueLoginStartResponse(result.Item1, Convert.ToBase64String(result.Item2)); + return new OpaqueLoginStartResponse(result.Item1, Convert.ToBase64String(result.Item2)); } // TODO: Remove and move to token endpoint [HttpPost("~/opaque/finish-login")] - public async Task FinishLoginAsync([FromBody] Models.Request.Opaque.OpaqueLoginFinishRequest request) + public async Task FinishLoginAsync([FromBody] OpaqueLoginFinishRequest request) { var result = await _opaqueKeyExchangeService.FinishLogin(request.SessionId, Convert.FromBase64String(request.CredentialFinalization)); return result; diff --git a/src/Core/Auth/Models/Api/Request/Opaque/OpaqueRegistrationStartRequest.cs b/src/Core/Auth/Models/Api/Request/Opaque/OpaqueRegistrationStartRequest.cs index 37aa337fdb..a3dd805eda 100644 --- a/src/Core/Auth/Models/Api/Request/Opaque/OpaqueRegistrationStartRequest.cs +++ b/src/Core/Auth/Models/Api/Request/Opaque/OpaqueRegistrationStartRequest.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Bitwarden.Opaque; namespace Bit.Core.Auth.Models.Api.Request.Opaque; @@ -8,10 +9,10 @@ public class OpaqueRegistrationStartRequest [Required] public string RegistrationRequest { get; set; } [Required] - public CipherConfiguration CipherConfiguration { get; set; } + public OpaqueKeyExchangeCipherConfiguration CipherConfiguration { get; set; } } -public class CipherConfiguration +public class OpaqueKeyExchangeCipherConfiguration { static string OpaqueKe3Ristretto3DHArgonSuite = "OPAQUE_3_RISTRETTO255_OPRF_RISTRETTO255_KEGROUP_3DH_KEX_ARGON2ID13_KSF"; @@ -20,20 +21,20 @@ public class CipherConfiguration [Required] public Argon2KsfParameters Argon2Parameters { get; set; } - public Bitwarden.Opaque.CipherConfiguration ToNativeConfiguration() + public CipherConfiguration ToNativeConfiguration() { if (CipherSuite == OpaqueKe3Ristretto3DHArgonSuite) { - return new Bitwarden.Opaque.CipherConfiguration + return new CipherConfiguration { OpaqueVersion = 3, - OprfCs = Bitwarden.Opaque.OprfCs.Ristretto255, - KeGroup = Bitwarden.Opaque.KeGroup.Ristretto255, - KeyExchange = Bitwarden.Opaque.KeyExchange.TripleDH, - Ksf = new Bitwarden.Opaque.Ksf + OprfCs = OprfCs.Ristretto255, + KeGroup = KeGroup.Ristretto255, + KeyExchange = KeyExchange.TripleDH, + Ksf = new Ksf { - Algorithm = Bitwarden.Opaque.KsfAlgorithm.Argon2id, - Parameters = new Bitwarden.Opaque.KsfParameters + Algorithm = KsfAlgorithm.Argon2id, + Parameters = new KsfParameters { Iterations = Argon2Parameters.Iterations, Memory = Argon2Parameters.Memory, diff --git a/src/Core/Auth/Services/IOpaqueKeyExchangeService.cs b/src/Core/Auth/Services/IOpaqueKeyExchangeService.cs index 3e410d10f3..bc67fce2a1 100644 --- a/src/Core/Auth/Services/IOpaqueKeyExchangeService.cs +++ b/src/Core/Auth/Services/IOpaqueKeyExchangeService.cs @@ -4,12 +4,57 @@ using Bit.Core.Entities; namespace Bit.Core.Auth.Services; +/// +/// Service that exposes methods enabling the use of the Opaque Key Exchange extension. +/// public interface IOpaqueKeyExchangeService { - public Task StartRegistration(byte[] request, User user, CipherConfiguration cipherConfiguration); + /// + /// Begin registering a user's Opaque Key Exchange Credential. We write to the distributed cache so since there is some back and forth between the client and server. + /// + /// unsure what this byte array is for. + /// user being acted on + /// configuration shared between the client and server to ensure the proper crypto-algorithms are being utilized. + /// void + public Task StartRegistration(byte[] request, User user, OpaqueKeyExchangeCipherConfiguration cipherConfiguration); + /// + /// This doesn't actually finish registration. It updates the cache with the server setup and cipher configuration so that the clearly named "SetActive" method can finish registration. + /// + /// Cache Id + /// Byte Array for Rust Magic + /// User being acted on + /// Key Pair that can be used for vault decryption + /// void public Task FinishRegistration(Guid sessionId, byte[] registrationUpload, User user, RotateableOpaqueKeyset keyset); + /// + /// Returns server crypto material for the client to consume and reply with a login request to the identity/token endpoint. + /// To protect against account enumeration we will always return a deterministic response based on the user's email. + /// + /// client crypto material + /// user email trying to login + /// tuple(login SessionId for cache lookup, Server crypto material) public Task<(Guid, byte[])> StartLogin(byte[] request, string email); + /// + /// Accepts the client's login request and validates it against the server's crypto material. If successful then the user is logged in. + /// If using a fake account we will return a standard failed login. If the account does have a legitimate credential but is still invalid + /// we will return a failed login. + /// + /// + /// + /// public Task FinishLogin(Guid sessionId, byte[] finishCredential); + /// + /// This is where registration really finishes. This method writes the Credential to the database. If a credential already exists then it will be removed before the new one is added. + /// A user can only have one credential. + /// + /// cache value + /// user being acted on + /// void public Task SetActive(Guid sessionId, User user); + /// + /// Removes the credential for the user. + /// + /// user being acted on + /// void public Task Unenroll(User user); } diff --git a/src/Core/Auth/Services/Implementations/OpaqueKeyExchangeService.cs b/src/Core/Auth/Services/Implementations/OpaqueKeyExchangeService.cs index fe94f65d31..4b9b4bce96 100644 --- a/src/Core/Auth/Services/Implementations/OpaqueKeyExchangeService.cs +++ b/src/Core/Auth/Services/Implementations/OpaqueKeyExchangeService.cs @@ -36,12 +36,12 @@ public class OpaqueKeyExchangeService : IOpaqueKeyExchangeService _userRepository = userRepository; } - public async Task StartRegistration(byte[] request, User user, Models.Api.Request.Opaque.CipherConfiguration cipherConfiguration) + public async Task StartRegistration(byte[] request, User user, OpaqueKeyExchangeCipherConfiguration cipherConfiguration) { var registrationRequest = _bitwardenOpaque.StartRegistration(cipherConfiguration.ToNativeConfiguration(), null, request, user.Id.ToString()); var sessionId = Guid.NewGuid(); - var registerSession = new RegisterSession() { SessionId = sessionId, ServerSetup = registrationRequest.serverSetup, CipherConfiguration = cipherConfiguration, UserId = user.Id }; + var registerSession = new OpaqueKeyExchangeRegisterSession() { SessionId = sessionId, ServerSetup = registrationRequest.serverSetup, CipherConfiguration = cipherConfiguration, UserId = user.Id }; await _distributedCache.SetAsync(string.Format(REGISTER_SESSION_KEY, sessionId), Encoding.ASCII.GetBytes(JsonSerializer.Serialize(registerSession))); return new OpaqueRegistrationStartResponse(sessionId, Convert.ToBase64String(registrationRequest.registrationResponse)); @@ -57,7 +57,7 @@ public class OpaqueKeyExchangeService : IOpaqueKeyExchangeService try { - var registerSession = JsonSerializer.Deserialize(Encoding.ASCII.GetString(serializedRegisterSession))!; + var registerSession = JsonSerializer.Deserialize(Encoding.ASCII.GetString(serializedRegisterSession))!; var registrationFinish = _bitwardenOpaque.FinishRegistration(registerSession.CipherConfiguration.ToNativeConfiguration(), registrationUpload); registerSession.PasswordFile = registrationFinish.serverRegistration; registerSession.KeySet = Encoding.ASCII.GetBytes(JsonSerializer.Serialize(keyset)); @@ -86,14 +86,18 @@ public class OpaqueKeyExchangeService : IOpaqueKeyExchangeService throw new InvalidOperationException("Credential not found"); } - var cipherConfiguration = JsonSerializer.Deserialize(credential.CipherConfiguration)!; + var cipherConfiguration = JsonSerializer.Deserialize(credential.CipherConfiguration)!; var credentialBlob = JsonSerializer.Deserialize(credential.CredentialBlob)!; var serverSetup = credentialBlob.ServerSetup; var serverRegistration = credentialBlob.PasswordFile; var loginResponse = _bitwardenOpaque.StartLogin(cipherConfiguration.ToNativeConfiguration(), serverSetup, serverRegistration, request, user.Id.ToString()); var sessionId = Guid.NewGuid(); - var loginSession = new LoginSession() { UserId = user.Id, LoginState = loginResponse.state, CipherConfiguration = cipherConfiguration }; + var loginSession = new OpaqueKeyExchangeLoginSession() { + UserId = user.Id, + LoginState = loginResponse.state, + CipherConfiguration = cipherConfiguration + }; await _distributedCache.SetAsync(string.Format(LOGIN_SESSION_KEY, sessionId), Encoding.ASCII.GetBytes(JsonSerializer.Serialize(loginSession))); return (sessionId, loginResponse.credentialResponse); } @@ -105,7 +109,7 @@ public class OpaqueKeyExchangeService : IOpaqueKeyExchangeService { throw new InvalidOperationException("Session not found"); } - var loginSession = JsonSerializer.Deserialize(Encoding.ASCII.GetString(serializedLoginSession))!; + var loginSession = JsonSerializer.Deserialize(Encoding.ASCII.GetString(serializedLoginSession))!; var credential = await _opaqueKeyExchangeCredentialRepository.GetByUserIdAsync(loginSession.UserId); if (credential == null) @@ -137,7 +141,7 @@ public class OpaqueKeyExchangeService : IOpaqueKeyExchangeService { throw new InvalidOperationException("Session not found"); } - var session = JsonSerializer.Deserialize(Encoding.ASCII.GetString(serializedRegisterSession))!; + var session = JsonSerializer.Deserialize(Encoding.ASCII.GetString(serializedRegisterSession))!; if (session.UserId != user.Id) { @@ -184,19 +188,19 @@ public class OpaqueKeyExchangeService : IOpaqueKeyExchangeService } } -public class RegisterSession +public class OpaqueKeyExchangeRegisterSession { public required Guid SessionId { get; set; } public required byte[] ServerSetup { get; set; } - public required Models.Api.Request.Opaque.CipherConfiguration CipherConfiguration { get; set; } + public required OpaqueKeyExchangeCipherConfiguration CipherConfiguration { get; set; } public required Guid UserId { get; set; } public byte[]? PasswordFile { get; set; } public byte[]? KeySet { get; set; } } -public class LoginSession +public class OpaqueKeyExchangeLoginSession { public required Guid UserId { get; set; } public required byte[] LoginState { get; set; } - public required Models.Api.Request.Opaque.CipherConfiguration CipherConfiguration { get; set; } + public required OpaqueKeyExchangeCipherConfiguration CipherConfiguration { get; set; } } diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index aa251369a4..6793b982eb 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -816,7 +816,7 @@ public class UserService : UserManager, IUserService, IDisposable user.ForcePasswordReset = true; user.Key = key; - // TODO: Add support + // TODO: Add Opaque-KE support await _opaqueKeyExchangeService.Unenroll(user); await _userRepository.ReplaceAsync(user); await _mailService.SendAdminResetPasswordEmailAsync(user.Email, user.Name, org.DisplayName()); @@ -844,7 +844,7 @@ public class UserService : UserManager, IUserService, IDisposable user.Key = key; user.MasterPasswordHint = hint; - // TODO: Add support + // TODO: Add Opaque-KE support await _opaqueKeyExchangeService.Unenroll(user); await _userRepository.ReplaceAsync(user); await _mailService.SendUpdatedTempPasswordEmailAsync(user.Email, user.Name); diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index 0ca5d50c5d..6e66ecef6a 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -1,5 +1,7 @@ using System.Diagnostics; using System.Text; +using Bit.Api.Auth.Models.Request.Opaque; +using Bit.Api.Auth.Models.Response.Opaque; using System.Text.Json; using Bit.Core; using Bit.Core.Auth.Enums; @@ -48,6 +50,7 @@ public class AccountsController : Controller private readonly IReferenceEventService _referenceEventService; private readonly IFeatureService _featureService; private readonly IDataProtectorTokenFactory _registrationEmailVerificationTokenDataFactory; + private readonly IOpaqueKeyExchangeService _opaqueKeyExchangeService; private readonly IOpaqueKeyExchangeCredentialRepository _opaqueKeyExchangeCredentialRepository; private readonly byte[] _defaultKdfHmacKey = null; @@ -98,6 +101,7 @@ public class AccountsController : Controller IFeatureService featureService, IDataProtectorTokenFactory registrationEmailVerificationTokenDataFactory, IOpaqueKeyExchangeCredentialRepository opaqueKeyExchangeCredentialRepository, + IOpaqueKeyExchangeService opaqueKeyExchangeService, GlobalSettings globalSettings ) { @@ -112,6 +116,7 @@ public class AccountsController : Controller _referenceEventService = referenceEventService; _featureService = featureService; _registrationEmailVerificationTokenDataFactory = registrationEmailVerificationTokenDataFactory; + _opaqueKeyExchangeService = opaqueKeyExchangeService; _opaqueKeyExchangeCredentialRepository = opaqueKeyExchangeCredentialRepository; if (CoreHelpers.SettingHasValue(globalSettings.KdfDefaultHashKey)) @@ -204,33 +209,27 @@ public class AccountsController : Controller model.EmailVerificationToken); return await ProcessRegistrationResult(identityResult, user, delaysEnabled); - break; case RegisterFinishTokenType.OrganizationInvite: identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash, model.OrgInviteToken, model.OrganizationUserId); return await ProcessRegistrationResult(identityResult, user, delaysEnabled); - break; case RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan: identityResult = await _registerUserCommand.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, model.MasterPasswordHash, model.OrgSponsoredFreeFamilyPlanToken); return await ProcessRegistrationResult(identityResult, user, delaysEnabled); - break; case RegisterFinishTokenType.EmergencyAccessInvite: Debug.Assert(model.AcceptEmergencyAccessId.HasValue); identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken(user, model.MasterPasswordHash, model.AcceptEmergencyAccessInviteToken, model.AcceptEmergencyAccessId.Value); return await ProcessRegistrationResult(identityResult, user, delaysEnabled); - break; case RegisterFinishTokenType.ProviderInvite: Debug.Assert(model.ProviderUserId.HasValue); identityResult = await _registerUserCommand.RegisterUserViaProviderInviteToken(user, model.MasterPasswordHash, model.ProviderInviteToken, model.ProviderUserId.Value); return await ProcessRegistrationResult(identityResult, user, delaysEnabled); - break; - default: throw new BadRequestException("Invalid registration finish request"); } @@ -270,7 +269,7 @@ public class AccountsController : Controller var credential = await _opaqueKeyExchangeCredentialRepository.GetByUserIdAsync(user.Id); if (credential != null) { - return new PreloginResponseModel(kdfInformation, JsonSerializer.Deserialize(credential.CipherConfiguration)!); + return new PreloginResponseModel(kdfInformation, JsonSerializer.Deserialize(credential.CipherConfiguration)!); } else { @@ -293,6 +292,14 @@ public class AccountsController : Controller }; } + [HttpPost("opaque-ke/start-login")] + [RequireFeature(FeatureFlagKeys.OpaqueKeyExchange)] + public async Task GetOpaqueKeyExchangeStartLoginMaterial([FromBody] OpaqueLoginStartRequest request) + { + var result = await _opaqueKeyExchangeService.StartLogin(Convert.FromBase64String(request.CredentialRequest), request.Email); + return new OpaqueLoginStartResponse(result.Item1, Convert.ToBase64String(result.Item2)); + } + private UserKdfInformation GetDefaultKdf(string email) { if (_defaultKdfHmacKey == null) diff --git a/src/Identity/Models/Response/Accounts/PreloginResponseModel.cs b/src/Identity/Models/Response/Accounts/PreloginResponseModel.cs index 46c988edd6..02a973a9ac 100644 --- a/src/Identity/Models/Response/Accounts/PreloginResponseModel.cs +++ b/src/Identity/Models/Response/Accounts/PreloginResponseModel.cs @@ -6,7 +6,7 @@ namespace Bit.Identity.Models.Response.Accounts; public class PreloginResponseModel { - public PreloginResponseModel(UserKdfInformation kdfInformation, CipherConfiguration opaqueConfiguration) + public PreloginResponseModel(UserKdfInformation kdfInformation, OpaqueKeyExchangeCipherConfiguration opaqueConfiguration) { Kdf = kdfInformation.Kdf; KdfIterations = kdfInformation.KdfIterations; @@ -19,5 +19,5 @@ public class PreloginResponseModel public int KdfIterations { get; set; } public int? KdfMemory { get; set; } public int? KdfParallelism { get; set; } - public CipherConfiguration OpaqueConfiguration { get; set; } + public OpaqueKeyExchangeCipherConfiguration OpaqueConfiguration { get; set; } } diff --git a/src/Infrastructure.Dapper/Auth/Repositories/OpaqueKeyExchangeCredentialRepository.cs b/src/Infrastructure.Dapper/Auth/Repositories/OpaqueKeyExchangeCredentialRepository.cs index e9d1092260..9abb1a8a82 100644 --- a/src/Infrastructure.Dapper/Auth/Repositories/OpaqueKeyExchangeCredentialRepository.cs +++ b/src/Infrastructure.Dapper/Auth/Repositories/OpaqueKeyExchangeCredentialRepository.cs @@ -25,15 +25,13 @@ public class OpaqueKeyExchangeCredentialRepository : Repository GetByUserIdAsync(Guid userId) { - using (var connection = new SqlConnection(ConnectionString)) - { - var results = await connection.QueryAsync( - $"[{Schema}].[{Table}_ReadByUserId]", - new { UserId = userId }, - commandType: CommandType.StoredProcedure); + using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[{Table}_ReadByUserId]", + new { UserId = userId }, + commandType: CommandType.StoredProcedure); - return results.FirstOrDefault(); - } + return results.FirstOrDefault(); } // TODO - How do we want to handle rotation? diff --git a/util/Migrator/DbScripts/2025-03-12_00_CreateOpaqueKeyExchangeCredential.sql b/util/Migrator/DbScripts/2025-03-12_00_CreateOpaqueKeyExchangeCredential.sql index 1a92b87b51..cae56162f5 100644 --- a/util/Migrator/DbScripts/2025-03-12_00_CreateOpaqueKeyExchangeCredential.sql +++ b/util/Migrator/DbScripts/2025-03-12_00_CreateOpaqueKeyExchangeCredential.sql @@ -8,7 +8,7 @@ CREATE TABLE [dbo].[OpaqueKeyExchangeCredential] [EncryptedPrivateKey] VARCHAR(MAX) NOT NULL, [EncryptedUserKey] VARCHAR(MAX) NULL, [CreationDate] DATETIME2 (7) NOT NULL, - CONSTRAINT [PK_OpaqueKeyExchangeCredential] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [PK_OpaqueKeyExchangeCredential] PRIMARY KEY CLUSTERED ([UserId]), -- using this as the primary key ensure users only have one credential CONSTRAINT [FK_OpaqueKeyExchangeCredential_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) );