From b03e3c3b8ccc38cb660d156db26105d7b55ef1ab Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Mon, 17 Mar 2025 08:48:30 -0400 Subject: [PATCH] Innovation/pm 18992/add credential table (#5499) * feat(OPAQUE-KE): added entity * innovation(opaque-ke) : inital database changes * innovation(opaque-ke) : dapper implementation. Key rotation WIP. * Updating credential repository * feat : updating service to use repository to save credential * Fix table creation and make registration work --------- Co-authored-by: Bernd Schoolmann --- .../OpaqueKeyExchangeController.cs | 20 +- .../Entities/OpaqueKeyExchangeCredential.cs | 39 ++++ .../Opaque/OpaqueLoginFinishRequest.cs | 0 .../Request/Opaque/OpaqueLoginStartRequest.cs | 0 .../Opaque/OpaqueRegistrationFinishRequest.cs | 6 +- .../Opaque/OpaqueRegistrationStartRequest.cs | 11 +- .../Opaque/OpaqueLoginStartResponse.cs | 0 .../Opaque/OpaqueRegistrationStartResponse.cs | 4 +- .../Data/OpaqueKeyExchangeCredentialBlob.cs | 6 + .../Data/OpaqueKeyExchangeRotateKeyData.cs | 22 ++ .../IOpaqueKeyExchangeCredentialRepository.cs | 15 ++ .../Services/IOpaqueKeyExchangeService.cs | 13 +- .../OpaqueKeyExchangeService.cs | 191 ++++++++++-------- .../Services/Implementations/UserService.cs | 8 +- .../OpaqueKeyExchangeCredentialRepository.cs | 73 +++++++ .../DapperServiceCollectionExtensions.cs | 2 +- .../OpaqueKeyExchangeCredential_Create.sql | 36 ++++ ...OpaqueKeyExchangeCredential_DeleteById.sql | 10 + .../OpaqueKeyExchangeCredential_ReadById.sql | 13 ++ ...aqueKeyExchangeCredential_ReadByUserId.sql | 13 ++ .../OpaqueKeyExchangeCredential_Update.sql | 26 +++ .../Tables/OpaqueKeyExchangeCredential.sql | 18 ++ .../dbo/Stored Procedures/User_DeleteById.sql | 15 +- ...2_00_CreateOpaqueKeyExchangeCredential.sql | 130 ++++++++++++ 24 files changed, 554 insertions(+), 117 deletions(-) create mode 100644 src/Core/Auth/Entities/OpaqueKeyExchangeCredential.cs rename src/{Api/Auth/Models => Core/Auth/Models/Api}/Request/Opaque/OpaqueLoginFinishRequest.cs (100%) rename src/{Api/Auth/Models => Core/Auth/Models/Api}/Request/Opaque/OpaqueLoginStartRequest.cs (100%) rename src/{Api/Auth/Models => Core/Auth/Models/Api}/Request/Opaque/OpaqueRegistrationFinishRequest.cs (74%) rename src/{Api/Auth/Models => Core/Auth/Models/Api}/Request/Opaque/OpaqueRegistrationStartRequest.cs (83%) rename src/{Api/Auth/Models => Core/Auth/Models/Api}/Response/Opaque/OpaqueLoginStartResponse.cs (100%) rename src/{Api/Auth/Models => Core/Auth/Models/Api}/Response/Opaque/OpaqueRegistrationStartResponse.cs (78%) create mode 100644 src/Core/Auth/Models/Data/OpaqueKeyExchangeCredentialBlob.cs create mode 100644 src/Core/Auth/Models/Data/OpaqueKeyExchangeRotateKeyData.cs create mode 100644 src/Core/Auth/Repositories/IOpaqueKeyExchangeCredentialRepository.cs create mode 100644 src/Infrastructure.Dapper/Auth/Repositories/OpaqueKeyExchangeCredentialRepository.cs create mode 100644 src/Sql/Auth/dbo/Stored Procedures/OpaqueKeyExchangeCredential_Create.sql create mode 100644 src/Sql/Auth/dbo/Stored Procedures/OpaqueKeyExchangeCredential_DeleteById.sql create mode 100644 src/Sql/Auth/dbo/Stored Procedures/OpaqueKeyExchangeCredential_ReadById.sql create mode 100644 src/Sql/Auth/dbo/Stored Procedures/OpaqueKeyExchangeCredential_ReadByUserId.sql create mode 100644 src/Sql/Auth/dbo/Stored Procedures/OpaqueKeyExchangeCredential_Update.sql create mode 100644 src/Sql/Auth/dbo/Tables/OpaqueKeyExchangeCredential.sql create mode 100644 util/Migrator/DbScripts/2025-03-12_00_CreateOpaqueKeyExchangeCredential.sql diff --git a/src/Api/Auth/Controllers/OpaqueKeyExchangeController.cs b/src/Api/Auth/Controllers/OpaqueKeyExchangeController.cs index dd59d2d973..38bf276795 100644 --- a/src/Api/Auth/Controllers/OpaqueKeyExchangeController.cs +++ b/src/Api/Auth/Controllers/OpaqueKeyExchangeController.cs @@ -1,5 +1,5 @@ -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; using Microsoft.AspNetCore.Authorization; @@ -12,7 +12,7 @@ namespace Bit.Api.Auth.Controllers; public class OpaqueKeyExchangeController : Controller { private readonly IOpaqueKeyExchangeService _opaqueKeyExchangeService; - IUserService _userService; + private readonly IUserService _userService; public OpaqueKeyExchangeController( IOpaqueKeyExchangeService opaqueKeyExchangeService, @@ -27,8 +27,8 @@ public class OpaqueKeyExchangeController : Controller public async Task StartRegistrationAsync([FromBody] OpaqueRegistrationStartRequest request) { var user = await _userService.GetUserByPrincipalAsync(User); - var result = await _opaqueKeyExchangeService.StartRegistration(Convert.FromBase64String(request.RegistrationRequest), user, request.CipherConfiguration.ToNativeConfiguration()); - return new OpaqueRegistrationStartResponse(result.Item1, Convert.ToBase64String(result.Item2)); + var result = await _opaqueKeyExchangeService.StartRegistration(Convert.FromBase64String(request.RegistrationRequest), user, request.CipherConfiguration); + return result; } @@ -36,25 +36,23 @@ public class OpaqueKeyExchangeController : Controller public async void FinishRegistrationAsync([FromBody] OpaqueRegistrationFinishRequest request) { var user = await _userService.GetUserByPrincipalAsync(User); - _opaqueKeyExchangeService.FinishRegistration(request.SessionId, Convert.FromBase64String(request.RegistrationUpload), user); + await _opaqueKeyExchangeService.FinishRegistration(request.SessionId, Convert.FromBase64String(request.RegistrationUpload), user, request.KeySet); } // TODO: Remove and move to token endpoint [HttpPost("~/opaque/start-login")] - public async Task StartLoginAsync([FromBody] OpaqueLoginStartRequest request) + public async Task StartLoginAsync([FromBody] Models.Request.Opaque.OpaqueLoginStartRequest request) { var result = await _opaqueKeyExchangeService.StartLogin(Convert.FromBase64String(request.CredentialRequest), request.Email); - return new OpaqueLoginStartResponse(result.Item1, Convert.ToBase64String(result.Item2)); + return new Models.Response.Opaque.OpaqueLoginStartResponse(result.Item1, Convert.ToBase64String(result.Item2)); } // TODO: Remove and move to token endpoint [HttpPost("~/opaque/finish-login")] - public async Task FinishLoginAsync([FromBody] OpaqueLoginFinishRequest request) + public async Task FinishLoginAsync([FromBody] Models.Request.Opaque.OpaqueLoginFinishRequest request) { var result = await _opaqueKeyExchangeService.FinishLogin(request.SessionId, Convert.FromBase64String(request.CredentialFinalization)); return result; } - } - diff --git a/src/Core/Auth/Entities/OpaqueKeyExchangeCredential.cs b/src/Core/Auth/Entities/OpaqueKeyExchangeCredential.cs new file mode 100644 index 0000000000..24a37d156b --- /dev/null +++ b/src/Core/Auth/Entities/OpaqueKeyExchangeCredential.cs @@ -0,0 +1,39 @@ +using Bit.Core.Entities; +using Bit.Core.Utilities; + +namespace Bit.Core.Auth.Entities; + +public class OpaqueKeyExchangeCredential : ITableObject +{ + /// + /// Identity column + /// + public Guid Id { get; set; } + /// + /// User who owns the credential + /// + public Guid UserId { get; set; } + /// + /// This describes the cipher configuration that both the server and client know. + /// This is returned on the /prelogin api call for the user. + /// + public string CipherConfiguration { get; set; } + /// + /// This contains Credential specific information. Storing as a blob gives us flexibility for future + /// iterations of the specifics of the OPAQUE implementation. + /// + public string CredentialBlob { get; set; } + public string EncryptedPublicKey { get; set; } + public string EncryptedPrivateKey { get; set; } + + public string EncryptedUserKey { get; set; } + /// + /// Date credential was created. When we update we are creating a new key set so in effect we are creating a new credential. + /// + public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; + + public void SetNewId() + { + Id = CoreHelpers.GenerateComb(); + } +} diff --git a/src/Api/Auth/Models/Request/Opaque/OpaqueLoginFinishRequest.cs b/src/Core/Auth/Models/Api/Request/Opaque/OpaqueLoginFinishRequest.cs similarity index 100% rename from src/Api/Auth/Models/Request/Opaque/OpaqueLoginFinishRequest.cs rename to src/Core/Auth/Models/Api/Request/Opaque/OpaqueLoginFinishRequest.cs diff --git a/src/Api/Auth/Models/Request/Opaque/OpaqueLoginStartRequest.cs b/src/Core/Auth/Models/Api/Request/Opaque/OpaqueLoginStartRequest.cs similarity index 100% rename from src/Api/Auth/Models/Request/Opaque/OpaqueLoginStartRequest.cs rename to src/Core/Auth/Models/Api/Request/Opaque/OpaqueLoginStartRequest.cs diff --git a/src/Api/Auth/Models/Request/Opaque/OpaqueRegistrationFinishRequest.cs b/src/Core/Auth/Models/Api/Request/Opaque/OpaqueRegistrationFinishRequest.cs similarity index 74% rename from src/Api/Auth/Models/Request/Opaque/OpaqueRegistrationFinishRequest.cs rename to src/Core/Auth/Models/Api/Request/Opaque/OpaqueRegistrationFinishRequest.cs index cb15e7c37b..0e52907b4b 100644 --- a/src/Api/Auth/Models/Request/Opaque/OpaqueRegistrationFinishRequest.cs +++ b/src/Core/Auth/Models/Api/Request/Opaque/OpaqueRegistrationFinishRequest.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Bit.Api.Auth.Models.Request.Opaque; +namespace Bit.Core.Auth.Models.Api.Request.Opaque; public class OpaqueRegistrationFinishRequest { @@ -9,10 +9,10 @@ public class OpaqueRegistrationFinishRequest [Required] public Guid SessionId { get; set; } - public RotateableKeyset KeySet { get; set; } + public RotateableOpaqueKeyset KeySet { get; set; } } -public class RotateableKeyset +public class RotateableOpaqueKeyset { [Required] public string EncryptedUserKey { get; set; } diff --git a/src/Api/Auth/Models/Request/Opaque/OpaqueRegistrationStartRequest.cs b/src/Core/Auth/Models/Api/Request/Opaque/OpaqueRegistrationStartRequest.cs similarity index 83% rename from src/Api/Auth/Models/Request/Opaque/OpaqueRegistrationStartRequest.cs rename to src/Core/Auth/Models/Api/Request/Opaque/OpaqueRegistrationStartRequest.cs index d0e2fb9843..355fda3ed0 100644 --- a/src/Api/Auth/Models/Request/Opaque/OpaqueRegistrationStartRequest.cs +++ b/src/Core/Auth/Models/Api/Request/Opaque/OpaqueRegistrationStartRequest.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Bit.Api.Auth.Models.Request.Opaque; +namespace Bit.Core.Auth.Models.Api.Request.Opaque; public class OpaqueRegistrationStartRequest @@ -17,6 +17,7 @@ public class CipherConfiguration [Required] public string CipherSuite { get; set; } + [Required] public Argon2KsfParameters Argon2Parameters { get; set; } public Bitwarden.OPAQUE.CipherConfiguration ToNativeConfiguration() @@ -28,7 +29,7 @@ public class CipherConfiguration OprfCS = Bitwarden.OPAQUE.OprfCS.Ristretto255, KeGroup = Bitwarden.OPAQUE.KeGroup.Ristretto255, KeyExchange = Bitwarden.OPAQUE.KeyExchange.TripleDH, - KSF = new Bitwarden.OPAQUE.Argon2id(Argon2Parameters.iterations, Argon2Parameters.memory, Argon2Parameters.parallelism) + KSF = new Bitwarden.OPAQUE.Argon2id(Argon2Parameters.Iterations, Argon2Parameters.Memory, Argon2Parameters.Parallelism) }; } else @@ -42,9 +43,9 @@ public class Argon2KsfParameters { // Memory in KiB [Required] - public int memory; + public int Memory { get; set; } [Required] - public int iterations; + public int Iterations { get; set; } [Required] - public int parallelism; + public int Parallelism { get; set; } } diff --git a/src/Api/Auth/Models/Response/Opaque/OpaqueLoginStartResponse.cs b/src/Core/Auth/Models/Api/Response/Opaque/OpaqueLoginStartResponse.cs similarity index 100% rename from src/Api/Auth/Models/Response/Opaque/OpaqueLoginStartResponse.cs rename to src/Core/Auth/Models/Api/Response/Opaque/OpaqueLoginStartResponse.cs diff --git a/src/Api/Auth/Models/Response/Opaque/OpaqueRegistrationStartResponse.cs b/src/Core/Auth/Models/Api/Response/Opaque/OpaqueRegistrationStartResponse.cs similarity index 78% rename from src/Api/Auth/Models/Response/Opaque/OpaqueRegistrationStartResponse.cs rename to src/Core/Auth/Models/Api/Response/Opaque/OpaqueRegistrationStartResponse.cs index 0560d4440e..78e101dc06 100644 --- a/src/Api/Auth/Models/Response/Opaque/OpaqueRegistrationStartResponse.cs +++ b/src/Core/Auth/Models/Api/Response/Opaque/OpaqueRegistrationStartResponse.cs @@ -1,6 +1,6 @@ using Bit.Core.Models.Api; -namespace Bit.Api.Auth.Models.Response.Opaque; +namespace Bit.Core.Auth.Models.Api.Response.Opaque; public class OpaqueRegistrationStartResponse : ResponseModel { @@ -11,7 +11,7 @@ public class OpaqueRegistrationStartResponse : ResponseModel SessionId = sessionId; } - public String RegistrationResponse { get; set; } + public string RegistrationResponse { get; set; } public Guid SessionId { get; set; } } diff --git a/src/Core/Auth/Models/Data/OpaqueKeyExchangeCredentialBlob.cs b/src/Core/Auth/Models/Data/OpaqueKeyExchangeCredentialBlob.cs new file mode 100644 index 0000000000..8c1b571ef1 --- /dev/null +++ b/src/Core/Auth/Models/Data/OpaqueKeyExchangeCredentialBlob.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Auth.Models.Data; +public class OpaqueKeyExchangeCredentialBlob +{ + public byte[] PasswordFile { get; set; } + public byte[] ServerSetup { get; set; } +} diff --git a/src/Core/Auth/Models/Data/OpaqueKeyExchangeRotateKeyData.cs b/src/Core/Auth/Models/Data/OpaqueKeyExchangeRotateKeyData.cs new file mode 100644 index 0000000000..f33f931797 --- /dev/null +++ b/src/Core/Auth/Models/Data/OpaqueKeyExchangeRotateKeyData.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Utilities; + +namespace Bit.Core.Auth.Models.Data; + +// TODO implement +public class OpaqueKeyExchangeRotateKeyData +{ + [Required] + public Guid Id { get; set; } + + [Required] + [EncryptedString] + [EncryptedStringLength(2000)] + public string EncryptedUserKey { get; set; } + + [Required] + [EncryptedString] + [EncryptedStringLength(2000)] + public string EncryptedPublicKey { get; set; } + +} diff --git a/src/Core/Auth/Repositories/IOpaqueKeyExchangeCredentialRepository.cs b/src/Core/Auth/Repositories/IOpaqueKeyExchangeCredentialRepository.cs new file mode 100644 index 0000000000..656fefea53 --- /dev/null +++ b/src/Core/Auth/Repositories/IOpaqueKeyExchangeCredentialRepository.cs @@ -0,0 +1,15 @@ +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Data; +using Bit.Core.KeyManagement.UserKey; +using Bit.Core.Repositories; + +#nullable enable + +namespace Bit.Core.Auth.Repositories; + +public interface IOpaqueKeyExchangeCredentialRepository : IRepository +{ + Task GetByUserIdAsync(Guid userId); + //TODO implement rotation + UpdateEncryptedDataForKeyRotation UpdateKeysForRotationAsync(Guid userId, IEnumerable credentials); +} diff --git a/src/Core/Auth/Services/IOpaqueKeyExchangeService.cs b/src/Core/Auth/Services/IOpaqueKeyExchangeService.cs index d5930141d3..3e410d10f3 100644 --- a/src/Core/Auth/Services/IOpaqueKeyExchangeService.cs +++ b/src/Core/Auth/Services/IOpaqueKeyExchangeService.cs @@ -1,14 +1,15 @@ -using Bit.Core.Entities; -using Bitwarden.OPAQUE; +using Bit.Core.Auth.Models.Api.Request.Opaque; +using Bit.Core.Auth.Models.Api.Response.Opaque; +using Bit.Core.Entities; namespace Bit.Core.Auth.Services; public interface IOpaqueKeyExchangeService { - public Task<(Guid, byte[])> StartRegistration(byte[] request, User user, CipherConfiguration cipherConfiguration); - public void FinishRegistration(Guid sessionId, byte[] registrationUpload, User user); + public Task StartRegistration(byte[] request, User user, CipherConfiguration cipherConfiguration); + public Task FinishRegistration(Guid sessionId, byte[] registrationUpload, User user, RotateableOpaqueKeyset keyset); public Task<(Guid, byte[])> StartLogin(byte[] request, string email); public Task FinishLogin(Guid sessionId, byte[] finishCredential); - public void SetActive(Guid sessionId, User user); - public void Unenroll(User user); + public Task SetActive(Guid sessionId, User user); + public Task Unenroll(User user); } diff --git a/src/Core/Auth/Services/Implementations/OpaqueKeyExchangeService.cs b/src/Core/Auth/Services/Implementations/OpaqueKeyExchangeService.cs index 8e14d35c5a..1011c69b4c 100644 --- a/src/Core/Auth/Services/Implementations/OpaqueKeyExchangeService.cs +++ b/src/Core/Auth/Services/Implementations/OpaqueKeyExchangeService.cs @@ -1,5 +1,14 @@ -using Bit.Core.Entities; +using System.Text; +using System.Text.Json; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Api.Request.Opaque; +using Bit.Core.Auth.Models.Api.Response.Opaque; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; +using Bit.Core.Entities; +using Bit.Core.Repositories; using Bitwarden.OPAQUE; +using Microsoft.Extensions.Caching.Distributed; namespace Bit.Core.Auth.Services; @@ -7,36 +16,48 @@ namespace Bit.Core.Auth.Services; public class OpaqueKeyExchangeService : IOpaqueKeyExchangeService { - private readonly BitwardenOpaqueServer _bitwardenOpaque; + private readonly IOpaqueKeyExchangeCredentialRepository _opaqueKeyExchangeCredentialRepository; + private readonly IDistributedCache _distributedCache; + private readonly IUserRepository _userRepository; public OpaqueKeyExchangeService( - ) + IOpaqueKeyExchangeCredentialRepository opaqueKeyExchangeCredentialRepository, + IDistributedCache distributedCache, + IUserRepository userRepository + ) { _bitwardenOpaque = new BitwardenOpaqueServer(); + _opaqueKeyExchangeCredentialRepository = opaqueKeyExchangeCredentialRepository; + _distributedCache = distributedCache; + _userRepository = userRepository; } - - public async Task<(Guid, byte[])> StartRegistration(byte[] request, User user, CipherConfiguration cipherConfiguration) + public async Task StartRegistration(byte[] request, User user, Models.Api.Request.Opaque.CipherConfiguration cipherConfiguration) { return await Task.Run(() => { - var registrationResponse = _bitwardenOpaque.StartRegistration(cipherConfiguration, null, request, user.Id.ToString()); + var registrationRequest = _bitwardenOpaque.StartRegistration(cipherConfiguration.ToNativeConfiguration(), null, request, user.Id.ToString()); + var registrationReseponse = registrationRequest.registrationResponse; + var serverSetup = registrationRequest.serverSetup; + // persist server setup var sessionId = Guid.NewGuid(); - SessionStore.RegisterSessions.Add(sessionId, new RegisterSession() { sessionId = sessionId, serverSetup = registrationResponse.serverSetup, cipherConfiguration = cipherConfiguration, userId = user.Id }); - return (sessionId, registrationResponse.registrationResponse); + SessionStore.RegisterSessions.Add(sessionId, new RegisterSession() { SessionId = sessionId, ServerSetup = serverSetup, CipherConfiguration = cipherConfiguration, UserId = user.Id }); + return new OpaqueRegistrationStartResponse(sessionId, Convert.ToBase64String(registrationReseponse)); }); } - public async void FinishRegistration(Guid sessionId, byte[] registrationUpload, User user) + public async Task FinishRegistration(Guid sessionId, byte[] registrationUpload, User user, RotateableOpaqueKeyset keyset) { await Task.Run(() => { - var cipherConfiguration = SessionStore.RegisterSessions[sessionId].cipherConfiguration; + var cipherConfiguration = SessionStore.RegisterSessions[sessionId].CipherConfiguration; try { - var registrationFinish = _bitwardenOpaque.FinishRegistration(cipherConfiguration, registrationUpload); - SessionStore.RegisterSessions[sessionId].serverRegistration = registrationFinish.serverRegistration; + var registrationFinish = _bitwardenOpaque.FinishRegistration(cipherConfiguration.ToNativeConfiguration(), registrationUpload); + SessionStore.RegisterSessions[sessionId].PasswordFile = registrationFinish.serverRegistration; + + SessionStore.RegisterSessions[sessionId].KeySet = Encoding.ASCII.GetBytes(JsonSerializer.Serialize(keyset)); } catch (Exception e) { @@ -48,109 +69,130 @@ public class OpaqueKeyExchangeService : IOpaqueKeyExchangeService public async Task<(Guid, byte[])> StartLogin(byte[] request, string email) { - return await Task.Run(() => + var user = await _userRepository.GetByEmailAsync(email); + if (user == null) { - var credential = PersistentStore.Credentials.First(x => x.Value.email == email); - if (credential.Value == null) - { - // generate fake credential - throw new InvalidOperationException("User not found"); - } + // todo don't allow user enumeration + throw new InvalidOperationException("User not found"); + } - var cipherConfiguration = credential.Value.cipherConfiguration; - var serverSetup = credential.Value.serverSetup; - var serverRegistration = credential.Value.serverRegistration; + var credential = await _opaqueKeyExchangeCredentialRepository.GetByUserIdAsync(user.Id); + if (credential == null) + { + // generate fake credential + throw new InvalidOperationException("Credential not found"); + } - var loginResponse = _bitwardenOpaque.StartLogin(cipherConfiguration, serverSetup, serverRegistration, request, credential.Key.ToString()); - var sessionId = Guid.NewGuid(); - SessionStore.LoginSessions.Add(sessionId, new LoginSession() { userId = credential.Key, loginState = loginResponse.state }); - return (sessionId, loginResponse.credentialResponse); - }); + 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(); + SessionStore.LoginSessions.Add(sessionId, new LoginSession() { UserId = user.Id, LoginState = loginResponse.state, CipherConfiguration = cipherConfiguration }); + return (sessionId, loginResponse.credentialResponse); } public async Task FinishLogin(Guid sessionId, byte[] credentialFinalization) { - return await Task.Run(() => + return await Task.Run(async () => { if (!SessionStore.LoginSessions.ContainsKey(sessionId)) { throw new InvalidOperationException("Session not found"); } - var credential = PersistentStore.Credentials[SessionStore.LoginSessions[sessionId].userId]; + + var userId = SessionStore.LoginSessions[sessionId].UserId; + var credential = await _opaqueKeyExchangeCredentialRepository.GetByUserIdAsync(userId); if (credential == null) { - throw new InvalidOperationException("User not found"); + throw new InvalidOperationException("Credential not found"); } - var loginState = SessionStore.LoginSessions[sessionId].loginState; - var cipherConfiguration = credential.cipherConfiguration; + var loginState = SessionStore.LoginSessions[sessionId].LoginState; + var cipherConfiguration = SessionStore.LoginSessions[sessionId].CipherConfiguration; + SessionStore.LoginSessions.Remove(sessionId); try { - var success = _bitwardenOpaque.FinishLogin(cipherConfiguration, loginState, credentialFinalization); - SessionStore.LoginSessions.Remove(sessionId); + var success = _bitwardenOpaque.FinishLogin(cipherConfiguration.ToNativeConfiguration(), loginState, credentialFinalization); return true; } catch (Exception e) { // print Console.WriteLine(e.Message); - SessionStore.LoginSessions.Remove(sessionId); return false; } }); } - public async void SetActive(Guid sessionId, User user) + public async Task SetActive(Guid sessionId, User user) { - await Task.Run(() => + var session = SessionStore.RegisterSessions[sessionId]; + SessionStore.RegisterSessions.Remove(sessionId); + + if (session.UserId != user.Id) { - var session = SessionStore.RegisterSessions[sessionId]; - if (session.userId != user.Id) - { - throw new InvalidOperationException("Session does not belong to user"); - } - if (session.serverRegistration == null) - { - throw new InvalidOperationException("Session did not complete registration"); - } - SessionStore.RegisterSessions.Remove(sessionId); + throw new InvalidOperationException("Session does not belong to user"); + } + if (session.PasswordFile == null) + { + throw new InvalidOperationException("Session did not complete registration"); + } + if (session.KeySet == null) + { + throw new InvalidOperationException("Session did not complete registration"); + } - // to be copied to the persistent DB - var cipherConfiguration = session.cipherConfiguration; - var serverRegistration = session.serverRegistration; - var serverSetup = session.serverSetup; + var keyset = JsonSerializer.Deserialize(Encoding.ASCII.GetString(session.KeySet))!; + var credentialBlob = new OpaqueKeyExchangeCredentialBlob() + { + PasswordFile = session.PasswordFile, + ServerSetup = session.ServerSetup + }; - if (PersistentStore.Credentials.ContainsKey(user.Id)) - { - PersistentStore.Credentials.Remove(user.Id); - } - PersistentStore.Credentials.Add(user.Id, new Credential() { serverRegistration = serverRegistration, serverSetup = serverSetup, cipherConfiguration = cipherConfiguration, email = user.Email }); - }); + var credential = new OpaqueKeyExchangeCredential() + { + UserId = user.Id, + CipherConfiguration = JsonSerializer.Serialize(session.CipherConfiguration), + CredentialBlob = JsonSerializer.Serialize(credentialBlob), + EncryptedPrivateKey = keyset.EncryptedPrivateKey, + EncryptedPublicKey = keyset.EncryptedPublicKey, + EncryptedUserKey = keyset.EncryptedUserKey, + CreationDate = DateTime.UtcNow + }; + + await Unenroll(user); + await _opaqueKeyExchangeCredentialRepository.CreateAsync(credential); } - public async void Unenroll(User user) + public async Task Unenroll(User user) { - await Task.Run(() => + var credential = await _opaqueKeyExchangeCredentialRepository.GetByUserIdAsync(user.Id); + if (credential != null) { - PersistentStore.Credentials.Remove(user.Id); - }); + await _opaqueKeyExchangeCredentialRepository.DeleteAsync(credential); + } } } public class RegisterSession { - public required Guid sessionId { get; set; } - public required byte[] serverSetup { get; set; } - public required CipherConfiguration cipherConfiguration { get; set; } - public required Guid userId { get; set; } - public byte[]? serverRegistration { get; set; } + public required Guid SessionId { get; set; } + public required byte[] ServerSetup { get; set; } + public required Models.Api.Request.Opaque.CipherConfiguration CipherConfiguration { get; set; } + public required Guid UserId { get; set; } + public byte[]? PasswordFile { get; set; } + public byte[]? KeySet { get; set; } } public class LoginSession { - public required Guid userId { get; set; } - public required byte[] loginState { get; set; } + public required Guid UserId { get; set; } + public required byte[] LoginState { get; set; } + public required Models.Api.Request.Opaque.CipherConfiguration CipherConfiguration { get; set; } } public class SessionStore() @@ -158,16 +200,3 @@ public class SessionStore() public static Dictionary RegisterSessions = new Dictionary(); public static Dictionary LoginSessions = new Dictionary(); } - -public class Credential -{ - public required byte[] serverRegistration { get; set; } - public required byte[] serverSetup { get; set; } - public required CipherConfiguration cipherConfiguration { get; set; } - public required string email { get; set; } -} - -public class PersistentStore() -{ - public static Dictionary Credentials = new Dictionary(); -} diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 36f227035c..aa251369a4 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -670,11 +670,11 @@ public class UserService : UserManager, IUserService, IDisposable if (opaqueSessionId != null) { - _opaqueKeyExchangeService.SetActive((Guid)opaqueSessionId, user); + await _opaqueKeyExchangeService.SetActive((Guid)opaqueSessionId, user); } else { - _opaqueKeyExchangeService.Unenroll(user); + await _opaqueKeyExchangeService.Unenroll(user); } await _userRepository.ReplaceAsync(user); await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword); @@ -817,7 +817,7 @@ public class UserService : UserManager, IUserService, IDisposable user.Key = key; // TODO: Add support - _opaqueKeyExchangeService.Unenroll(user); + await _opaqueKeyExchangeService.Unenroll(user); await _userRepository.ReplaceAsync(user); await _mailService.SendAdminResetPasswordEmailAsync(user.Email, user.Name, org.DisplayName()); await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_AdminResetPassword); @@ -845,7 +845,7 @@ public class UserService : UserManager, IUserService, IDisposable user.MasterPasswordHint = hint; // TODO: Add support - _opaqueKeyExchangeService.Unenroll(user); + await _opaqueKeyExchangeService.Unenroll(user); await _userRepository.ReplaceAsync(user); await _mailService.SendUpdatedTempPasswordEmailAsync(user.Email, user.Name); await _eventService.LogUserEventAsync(user.Id, EventType.User_UpdatedTempPassword); diff --git a/src/Infrastructure.Dapper/Auth/Repositories/OpaqueKeyExchangeCredentialRepository.cs b/src/Infrastructure.Dapper/Auth/Repositories/OpaqueKeyExchangeCredentialRepository.cs new file mode 100644 index 0000000000..e9d1092260 --- /dev/null +++ b/src/Infrastructure.Dapper/Auth/Repositories/OpaqueKeyExchangeCredentialRepository.cs @@ -0,0 +1,73 @@ +using System.Data; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; +using Bit.Core.KeyManagement.UserKey; +using Bit.Core.Settings; +using Bit.Infrastructure.Dapper.Repositories; +using Dapper; +using Microsoft.Data.SqlClient; + +#nullable enable + + +namespace Bit.Infrastructure.Dapper.Auth.Repositories; + +public class OpaqueKeyExchangeCredentialRepository : Repository, IOpaqueKeyExchangeCredentialRepository +{ + public OpaqueKeyExchangeCredentialRepository(GlobalSettings globalSettings) + : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) + { } + + public OpaqueKeyExchangeCredentialRepository(string connectionString, string readOnlyConnectionString) + : base(connectionString, readOnlyConnectionString) + { } + + public async Task GetByUserIdAsync(Guid userId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[{Table}_ReadByUserId]", + new { UserId = userId }, + commandType: CommandType.StoredProcedure); + + return results.FirstOrDefault(); + } + } + + // TODO - How do we want to handle rotation? + public UpdateEncryptedDataForKeyRotation UpdateKeysForRotationAsync(Guid userId, IEnumerable credentials) + { + throw new NotImplementedException(); + // return async (SqlConnection connection, SqlTransaction transaction) => + // { + // const string sql = @" + // UPDATE WC + // SET + // WC.[EncryptedPublicKey] = UW.[encryptedPublicKey], + // WC.[EncryptedUserKey] = UW.[encryptedUserKey] + // FROM + // [dbo].[WebAuthnCredential] WC + // INNER JOIN + // OPENJSON(@JsonCredentials) + // WITH ( + // id UNIQUEIDENTIFIER, + // encryptedPublicKey NVARCHAR(MAX), + // encryptedUserKey NVARCHAR(MAX) + // ) UW + // ON UW.id = WC.Id + // WHERE + // WC.[UserId] = @UserId"; + + // var jsonCredentials = CoreHelpers.ClassToJsonData(credentials); + + // await connection.ExecuteAsync( + // sql, + // new { UserId = userId, JsonCredentials = jsonCredentials }, + // transaction: transaction, + // commandType: CommandType.Text); + // }; + } + +} diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs index 26abf5632c..bfe6ff575b 100644 --- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs +++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs @@ -65,7 +65,7 @@ public static class DapperServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - + services.AddSingleton(); if (selfHosted) { services.AddSingleton(); diff --git a/src/Sql/Auth/dbo/Stored Procedures/OpaqueKeyExchangeCredential_Create.sql b/src/Sql/Auth/dbo/Stored Procedures/OpaqueKeyExchangeCredential_Create.sql new file mode 100644 index 0000000000..bcc324ae86 --- /dev/null +++ b/src/Sql/Auth/dbo/Stored Procedures/OpaqueKeyExchangeCredential_Create.sql @@ -0,0 +1,36 @@ +CREATE PROCEDURE [dbo].[OpaqueKeyExchangeCredential_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @UserId UNIQUEIDENTIFIER, + @CipherConfiguration VARCHAR(MAX) NOT NULL, + @CredentialBlob VARCHAR(MAX) NOT NULL, + @EncryptedPublicKey VARCHAR(MAX) NOT NULL, + @EncryptedPrivateKey VARCHAR(MAX) NOT NULL, + @EncryptedUserKey VARCHAR(MAX) NOT NULL, + @CreationDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OpaqueKeyExchangeCredential] + ( + [Id], + [UserId], + [CipherConfiguration], + [CredentialBlob], + [EncryptedPublicKey], + [EncryptedPrivateKey], + [EncryptedUserKey], + [CreationDate] + ) + VALUES + ( + @Id, + @UserId, + @CipherConfiguration, + @CredentialBlob, + @EncryptedPublicKey, + @EncryptedPrivateKey, + @EncryptedUserKey, + @CreationDate + ) +END diff --git a/src/Sql/Auth/dbo/Stored Procedures/OpaqueKeyExchangeCredential_DeleteById.sql b/src/Sql/Auth/dbo/Stored Procedures/OpaqueKeyExchangeCredential_DeleteById.sql new file mode 100644 index 0000000000..f2634da1cf --- /dev/null +++ b/src/Sql/Auth/dbo/Stored Procedures/OpaqueKeyExchangeCredential_DeleteById.sql @@ -0,0 +1,10 @@ +CREATE PROCEDURE [dbo].[OpaqueKeyExchangeCredential_DeleteById] + @Id UNIQUEIDENTIFIER, +AS +BEGIN + DELETE + FROM + [dbo].[OpaqueKeyExchangeCredential] + WHERE + [Id] = @Id +END \ No newline at end of file diff --git a/src/Sql/Auth/dbo/Stored Procedures/OpaqueKeyExchangeCredential_ReadById.sql b/src/Sql/Auth/dbo/Stored Procedures/OpaqueKeyExchangeCredential_ReadById.sql new file mode 100644 index 0000000000..dfc7d4bee0 --- /dev/null +++ b/src/Sql/Auth/dbo/Stored Procedures/OpaqueKeyExchangeCredential_ReadById.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[OpaqueKeyExchangeCredential_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[OpaqueKeyExchangeCredential] + WHERE + [Id] = @Id +END diff --git a/src/Sql/Auth/dbo/Stored Procedures/OpaqueKeyExchangeCredential_ReadByUserId.sql b/src/Sql/Auth/dbo/Stored Procedures/OpaqueKeyExchangeCredential_ReadByUserId.sql new file mode 100644 index 0000000000..20adfbfbd4 --- /dev/null +++ b/src/Sql/Auth/dbo/Stored Procedures/OpaqueKeyExchangeCredential_ReadByUserId.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[OpaqueKeyExchangeCredential_ReadByUserId] + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[OpaqueKeyExchangeCredential] + WHERE + [UserId] = @UserId +END diff --git a/src/Sql/Auth/dbo/Stored Procedures/OpaqueKeyExchangeCredential_Update.sql b/src/Sql/Auth/dbo/Stored Procedures/OpaqueKeyExchangeCredential_Update.sql new file mode 100644 index 0000000000..37499942e2 --- /dev/null +++ b/src/Sql/Auth/dbo/Stored Procedures/OpaqueKeyExchangeCredential_Update.sql @@ -0,0 +1,26 @@ +-- Used for Key Rotation and Password Update +CREATE PROCEDURE [dbo].[OpaqueKeyExchangeCredential_Update] + @Id UNIQUEIDENTIFIER OUTPUT, + @UserId UNIQUEIDENTIFIER, + @CipherConfiguration VARCHAR(MAX), + @CredentialBlob VARCHAR(MAX), + @EncryptedPublicKey VARCHAR(MAX), + @EncryptedPrivateKey VARCHAR(MAX), + @EncryptedUserKey VARCHAR(MAX), + @CreationDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[OpaqueKeyExchangeCredential] +SET + [CipherConfiguration] = @CipherConfiguration, + [CredentialBlob] = @CredentialBlob, + [EncryptedPublicKey] = @EncryptedPublicKey, + [EncryptedPrivateKey] = @EncryptedPrivateKey, + [EncryptedUserKey] = @EncryptedUserKey, + [CreationDate] = @CreationDate +WHERE + [Id] = @Id AND [UserId] = @UserId +END diff --git a/src/Sql/Auth/dbo/Tables/OpaqueKeyExchangeCredential.sql b/src/Sql/Auth/dbo/Tables/OpaqueKeyExchangeCredential.sql new file mode 100644 index 0000000000..8e7f23f124 --- /dev/null +++ b/src/Sql/Auth/dbo/Tables/OpaqueKeyExchangeCredential.sql @@ -0,0 +1,18 @@ +CREATE TABLE [dbo].[OpaqueKeyExchangeCredential] +( + [Id] UNIQUEIDENTIFIER NOT NULL, + [UserId] UNIQUEIDENTIFIER NOT NULL, + [CipherConfiguration] VARCHAR(MAX) NOT NULL, + [CredentialBlob] VARCHAR(MAX) NOT NULL, + [EncryptedPublicKey] VARCHAR(MAX) NOT NULL, + [EncryptedPrivateKey] VARCHAR(MAX) NOT NULL, + [EncryptedUserKey] VARCHAR(MAX) NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_OpaqueKeyExchangeCredential] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_OpaqueKeyExchangeCredential_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) +); + +GO + +CREATE NONCLUSTERED INDEX [IX_OpaqueKeyExchangeCredential_UserId] + ON [dbo].[OpaqueKeyExchangeCredential]([UserId] ASC); diff --git a/src/Sql/dbo/Stored Procedures/User_DeleteById.sql b/src/Sql/dbo/Stored Procedures/User_DeleteById.sql index 0608982e37..55797e57a2 100644 --- a/src/Sql/dbo/Stored Procedures/User_DeleteById.sql +++ b/src/Sql/dbo/Stored Procedures/User_DeleteById.sql @@ -1,6 +1,7 @@ CREATE PROCEDURE [dbo].[User_DeleteById] @Id UNIQUEIDENTIFIER -WITH RECOMPILE +WITH + RECOMPILE AS BEGIN SET NOCOUNT ON @@ -23,6 +24,12 @@ BEGIN END BEGIN TRANSACTION User_DeleteById + -- Delete OpaqueKeyExchangeCredentials + DELETE + FROM + [dbo].[OpaqueKeyExchangeCredential] + WHERE + [UserId] = @UserId -- Delete WebAuthnCredentials DELETE @@ -42,7 +49,7 @@ BEGIN DELETE FROM [dbo].[AuthRequest] - WHERE + WHERE [UserId] = @Id -- Delete devices @@ -116,7 +123,7 @@ BEGIN DELETE FROM [dbo].[Send] - WHERE + WHERE [UserId] = @Id -- Delete Notification Status @@ -132,7 +139,7 @@ BEGIN [dbo].[Notification] WHERE [UserId] = @Id - + -- Finally, delete the user DELETE FROM diff --git a/util/Migrator/DbScripts/2025-03-12_00_CreateOpaqueKeyExchangeCredential.sql b/util/Migrator/DbScripts/2025-03-12_00_CreateOpaqueKeyExchangeCredential.sql new file mode 100644 index 0000000000..a64d7e6a48 --- /dev/null +++ b/util/Migrator/DbScripts/2025-03-12_00_CreateOpaqueKeyExchangeCredential.sql @@ -0,0 +1,130 @@ +CREATE TABLE [dbo].[OpaqueKeyExchangeCredential] +( + [Id] UNIQUEIDENTIFIER NOT NULL, + [UserId] UNIQUEIDENTIFIER NOT NULL, + [CipherConfiguration] VARCHAR(MAX) NOT NULL, + [CredentialBlob] VARCHAR(MAX) NOT NULL, + [EncryptedPublicKey] VARCHAR(MAX) NOT NULL, + [EncryptedPrivateKey] VARCHAR(MAX) NOT NULL, + [EncryptedUserKey] VARCHAR(MAX) NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_OpaqueKeyExchangeCredential] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_OpaqueKeyExchangeCredential_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) +); + +GO + +CREATE NONCLUSTERED INDEX [IX_OpaqueKeyExchangeCredential_UserId] + ON [dbo].[OpaqueKeyExchangeCredential]([UserId] ASC); + +GO + +CREATE OR ALTER PROCEDURE [dbo].[OpaqueKeyExchangeCredential_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @UserId UNIQUEIDENTIFIER, + @CipherConfiguration VARCHAR(MAX), + @CredentialBlob VARCHAR(MAX), + @EncryptedPublicKey VARCHAR(MAX), + @EncryptedPrivateKey TINYINT, + @EncryptedUserKey VARCHAR(MAX), + @CreationDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OpaqueKeyExchangeCredential] + ( + [Id], + [UserId], + [CipherConfiguration], + [CredentialBlob], + [EncryptedPublicKey], + [EncryptedPrivateKey], + [EncryptedUserKey], + [CreationDate] + ) + VALUES + ( + @Id, + @UserId, + @CipherConfiguration, + @CredentialBlob, + @EncryptedPublicKey, + @EncryptedPrivateKey, + @EncryptedUserKey, + @CreationDate + ) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OpaqueKeyExchangeCredential_Update] + @Id UNIQUEIDENTIFIER OUTPUT, + @UserId UNIQUEIDENTIFIER, + @CipherConfiguration VARCHAR(MAX), + @CredentialBlob VARCHAR(MAX), + @EncryptedPublicKey VARCHAR(MAX), + @EncryptedPrivateKey VARCHAR(MAX), + @EncryptedUserKey VARCHAR(MAX), + @CreationDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[OpaqueKeyExchangeCredential] +SET + [CipherConfiguration] = @CipherConfiguration, + [CredentialBlob] = @CredentialBlob, + [EncryptedPublicKey] = @EncryptedPublicKey, + [EncryptedPrivateKey] = @EncryptedPrivateKey, + [EncryptedUserKey] = @EncryptedUserKey, + [CreationDate] = @CreationDate +WHERE + [Id] = @Id AND [UserId] = @UserId +END + +GO + +CREATE OR ALTER PROCEDURE [dbo].[OpaqueKeyExchangeCredential_DeleteById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + DELETE + FROM + [dbo].[OpaqueKeyExchangeCredential] + WHERE + [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OpaqueKeyExchangeCredential_ReadByUserId] + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[OpaqueKeyExchangeCredential] + WHERE + [UserId] = @UserId +END + +GO + +CREATE OR ALTER PROCEDURE [dbo].[OpaqueKeyExchangeCredential_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[OpaqueKeyExchangeCredential] + WHERE + [Id] = @Id +END + +GO \ No newline at end of file