From ce4f025a0c2f24c051d4e3d6baeeaeb69b45b730 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Wed, 5 May 2021 16:14:49 +0200 Subject: [PATCH] U2F (#1304) * Delete U2F tokens alongside WebAuthn * Bring back u2f apis --- src/Api/Controllers/TwoFactorController.cs | 42 +++++ .../Api/Request/TwoFactorRequestModels.cs | 21 +++ .../TwoFactor/TwoFactorU2fResponseModel.cs | 59 +++++++ src/Core/Models/Business/U2fRegistration.cs | 9 + src/Core/Services/IUserService.cs | 3 + .../Services/Implementations/UserService.cs | 157 ++++++++++++++++++ test/Core.Test/Services/UserServiceTests.cs | 3 + 7 files changed, 294 insertions(+) create mode 100644 src/Core/Models/Api/Response/TwoFactor/TwoFactorU2fResponseModel.cs create mode 100644 src/Core/Models/Business/U2fRegistration.cs diff --git a/src/Api/Controllers/TwoFactorController.cs b/src/Api/Controllers/TwoFactorController.cs index 1f94c04f0d..7ff375609e 100644 --- a/src/Api/Controllers/TwoFactorController.cs +++ b/src/Api/Controllers/TwoFactorController.cs @@ -220,6 +220,48 @@ namespace Bit.Api.Controllers return response; } + [HttpPost("get-u2f")] + public async Task GetU2f([FromBody]TwoFactorRequestModel model) + { + var user = await CheckAsync(model.MasterPasswordHash, true); + var response = new TwoFactorU2fResponseModel(user); + return response; + } + + [HttpPost("get-u2f-challenge")] + public async Task GetU2fChallenge( + [FromBody]TwoFactorRequestModel model) + { + var user = await CheckAsync(model.MasterPasswordHash, true); + var reg = await _userService.StartU2fRegistrationAsync(user); + var challenge = new TwoFactorU2fResponseModel.ChallengeModel(user, reg); + return challenge; + } + + [HttpPut("u2f")] + [HttpPost("u2f")] + public async Task PutU2f([FromBody]TwoFactorU2fRequestModel model) + { + var user = await CheckAsync(model.MasterPasswordHash, true); + var success = await _userService.CompleteU2fRegistrationAsync( + user, model.Id.Value, model.Name, model.DeviceResponse); + if (!success) + { + throw new BadRequestException("Unable to complete U2F key registration."); + } + var response = new TwoFactorU2fResponseModel(user); + return response; + } + + [HttpDelete("u2f")] + public async Task DeleteU2f([FromBody]TwoFactorU2fDeleteRequestModel model) + { + var user = await CheckAsync(model.MasterPasswordHash, true); + await _userService.DeleteU2fKeyAsync(user, model.Id.Value); + var response = new TwoFactorU2fResponseModel(user); + return response; + } + [HttpPost("get-webauthn")] public async Task GetWebAuthn([FromBody]TwoFactorRequestModel model) { diff --git a/src/Core/Models/Api/Request/TwoFactorRequestModels.cs b/src/Core/Models/Api/Request/TwoFactorRequestModels.cs index e6bf6bacc4..b491a9287d 100644 --- a/src/Core/Models/Api/Request/TwoFactorRequestModels.cs +++ b/src/Core/Models/Api/Request/TwoFactorRequestModels.cs @@ -224,6 +224,27 @@ namespace Bit.Core.Models.Api } } + public class TwoFactorU2fRequestModel : TwoFactorU2fDeleteRequestModel + { + [Required] + public string DeviceResponse { get; set; } + public string Name { get; set; } + } + + public class TwoFactorU2fDeleteRequestModel : TwoFactorRequestModel, IValidatableObject + { + [Required] + public int? Id { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (!Id.HasValue || Id < 0 || Id > 5) + { + yield return new ValidationResult("Invalid Key Id", new string[] { nameof(Id) }); + } + } + } + public class TwoFactorWebAuthnRequestModel : TwoFactorWebAuthnDeleteRequestModel { [Required] diff --git a/src/Core/Models/Api/Response/TwoFactor/TwoFactorU2fResponseModel.cs b/src/Core/Models/Api/Response/TwoFactor/TwoFactorU2fResponseModel.cs new file mode 100644 index 0000000000..121be3d4be --- /dev/null +++ b/src/Core/Models/Api/Response/TwoFactor/TwoFactorU2fResponseModel.cs @@ -0,0 +1,59 @@ +using System; +using Bit.Core.Models.Table; +using Bit.Core.Models.Business; +using Bit.Core.Enums; +using System.Collections.Generic; +using System.Linq; + +namespace Bit.Core.Models.Api +{ + public class TwoFactorU2fResponseModel : ResponseModel + { + public TwoFactorU2fResponseModel(User user) + : base("twoFactorU2f") + { + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.U2f); + Enabled = provider?.Enabled ?? false; + Keys = provider?.MetaData?.Select(k => new KeyModel(k.Key, + new TwoFactorProvider.U2fMetaData((dynamic)k.Value))); + } + + public bool Enabled { get; set; } + public IEnumerable Keys { get; set; } + + public class KeyModel + { + public KeyModel(string id, TwoFactorProvider.U2fMetaData data) + { + Name = data.Name; + Id = Convert.ToInt32(id.Replace("Key", string.Empty)); + Compromised = data.Compromised; + } + + public string Name { get; set; } + public int Id { get; set; } + public bool Compromised { get; set; } + } + + public class ChallengeModel + { + public ChallengeModel(User user, U2fRegistration registration) + { + UserId = user.Id.ToString(); + AppId = registration.AppId; + Challenge = registration.Challenge; + Version = registration.Version; + } + + public string UserId { get; set; } + public string AppId { get; set; } + public string Challenge { get; set; } + public string Version { get; set; } + } + } +} diff --git a/src/Core/Models/Business/U2fRegistration.cs b/src/Core/Models/Business/U2fRegistration.cs new file mode 100644 index 0000000000..c27afdc40a --- /dev/null +++ b/src/Core/Models/Business/U2fRegistration.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.Models.Business +{ + public class U2fRegistration + { + public string AppId { get; set; } + public string Challenge { get; set; } + public string Version { get; set; } + } +} diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index ebc18dd2eb..f5da901fdb 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -24,6 +24,9 @@ namespace Bit.Core.Services Task SendMasterPasswordHintAsync(string email); Task SendTwoFactorEmailAsync(User user); Task VerifyTwoFactorEmailAsync(User user, string token); + Task StartU2fRegistrationAsync(User user); + Task DeleteU2fKeyAsync(User user, int id); + Task CompleteU2fRegistrationAsync(User user, int id, string name, string deviceResponse); Task StartWebAuthnRegistrationAsync(User user); Task DeleteWebAuthnKeyAsync(User user, int id); Task CompleteWebAuthRegistrationAsync(User user, int value, string name, AuthenticatorAttestationRawResponse attestationResponse); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 013b3755cd..3f41e5df5c 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -18,6 +18,7 @@ using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Utilities; using Bit.Core.Settings; +using U2F.Core.Exceptions; using System.IO; using Newtonsoft.Json; using Microsoft.AspNetCore.DataProtection; @@ -35,6 +36,7 @@ namespace Bit.Core.Services private readonly ICipherRepository _cipherRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationRepository _organizationRepository; + private readonly IU2fRepository _u2fRepository; private readonly IMailService _mailService; private readonly IPushNotificationService _pushService; private readonly IdentityErrorDescriber _identityErrorDescriber; @@ -58,6 +60,7 @@ namespace Bit.Core.Services ICipherRepository cipherRepository, IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository, + IU2fRepository u2fRepository, IMailService mailService, IPushNotificationService pushService, IUserStore store, @@ -95,6 +98,7 @@ namespace Bit.Core.Services _cipherRepository = cipherRepository; _organizationUserRepository = organizationUserRepository; _organizationRepository = organizationRepository; + _u2fRepository = u2fRepository; _mailService = mailService; _pushService = pushService; _identityOptions = optionsAccessor?.Value ?? new IdentityOptions(); @@ -363,6 +367,133 @@ namespace Bit.Core.Services "2faEmail:" + email, token); } + public async Task StartU2fRegistrationAsync(User user) + { + await _u2fRepository.DeleteManyByUserIdAsync(user.Id); + var reg = U2fLib.StartRegistration(CoreHelpers.U2fAppIdUrl(_globalSettings)); + await _u2fRepository.CreateAsync(new U2f + { + AppId = reg.AppId, + Challenge = reg.Challenge, + Version = reg.Version, + UserId = user.Id, + CreationDate = DateTime.UtcNow + }); + + return new U2fRegistration + { + AppId = reg.AppId, + Challenge = reg.Challenge, + Version = reg.Version + }; + } + + public async Task CompleteU2fRegistrationAsync(User user, int id, string name, string deviceResponse) + { + if (string.IsNullOrWhiteSpace(deviceResponse)) + { + return false; + } + + var challenges = await _u2fRepository.GetManyByUserIdAsync(user.Id); + if (!challenges?.Any() ?? true) + { + return false; + } + + var registerResponse = BaseModel.FromJson(deviceResponse); + + try + { + var challenge = challenges.OrderBy(i => i.Id).Last(i => i.KeyHandle == null); + var startedReg = new StartedRegistration(challenge.Challenge, challenge.AppId); + var reg = U2fLib.FinishRegistration(startedReg, registerResponse); + + await _u2fRepository.DeleteManyByUserIdAsync(user.Id); + + // Add device + var providers = user.GetTwoFactorProviders(); + if (providers == null) + { + providers = new Dictionary(); + } + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.U2f); + if (provider == null) + { + provider = new TwoFactorProvider(); + } + if (provider.MetaData == null) + { + provider.MetaData = new Dictionary(); + } + + if (provider.MetaData.Count >= 5) + { + // Can only register up to 5 keys + return false; + } + + var keyId = $"Key{id}"; + if (provider.MetaData.ContainsKey(keyId)) + { + provider.MetaData.Remove(keyId); + } + + provider.Enabled = true; + provider.MetaData.Add(keyId, new TwoFactorProvider.U2fMetaData + { + Name = name, + KeyHandle = reg.KeyHandle == null ? null : Utils.ByteArrayToBase64String(reg.KeyHandle), + PublicKey = reg.PublicKey == null ? null : Utils.ByteArrayToBase64String(reg.PublicKey), + Certificate = reg.AttestationCert == null ? null : Utils.ByteArrayToBase64String(reg.AttestationCert), + Compromised = false, + Counter = reg.Counter + }); + + if (providers.ContainsKey(TwoFactorProviderType.U2f)) + { + providers.Remove(TwoFactorProviderType.U2f); + } + + providers.Add(TwoFactorProviderType.U2f, provider); + user.SetTwoFactorProviders(providers); + await UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.U2f); + return true; + } + catch (U2fException e) + { + Logger.LogError(e, "Complete U2F registration error."); + return false; + } + } + + public async Task DeleteU2fKeyAsync(User user, int id) + { + var providers = user.GetTwoFactorProviders(); + if (providers == null) + { + return false; + } + + var keyName = $"Key{id}"; + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.U2f); + if (!provider?.MetaData?.ContainsKey(keyName) ?? true) + { + return false; + } + + if (provider.MetaData.Count < 2) + { + return false; + } + + provider.MetaData.Remove(keyName); + providers[TwoFactorProviderType.U2f] = provider; + user.SetTwoFactorProviders(providers); + await UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.U2f); + return true; + } + public async Task StartWebAuthnRegistrationAsync(User user) { var providers = user.GetTwoFactorProviders(); @@ -464,6 +595,25 @@ namespace Bit.Core.Services return false; } + // Delete U2F token is this is a migrated WebAuthn token. + var entry = new TwoFactorProvider.WebAuthnData(provider.MetaData[keyName]); + if (entry?.Migrated ?? false) + { + var u2fProvider = user.GetTwoFactorProvider(TwoFactorProviderType.U2f); + if (u2fProvider?.MetaData?.ContainsKey(keyName) ?? false) + { + u2fProvider.MetaData.Remove(keyName); + if (u2fProvider.MetaData.Count > 0) + { + providers[TwoFactorProviderType.U2f] = u2fProvider; + } + else + { + providers.Remove(TwoFactorProviderType.U2f); + } + } + } + provider.MetaData.Remove(keyName); providers[TwoFactorProviderType.WebAuthn] = provider; user.SetTwoFactorProviders(providers); @@ -719,6 +869,13 @@ namespace Bit.Core.Services return; } + // Since the user can no longer directly manipulate U2F tokens, we should + // disable them when the user disables WebAuthn. + if (type == TwoFactorProviderType.WebAuthn) + { + providers.Remove(TwoFactorProviderType.U2f); + } + providers.Remove(type); user.SetTwoFactorProviders(providers); await SaveUserAsync(user); diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index 8180559442..89c7949cf9 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -23,6 +23,7 @@ namespace Bit.Core.Test.Services private readonly ICipherRepository _cipherRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationRepository _organizationRepository; + private readonly IU2fRepository _u2fRepository; private readonly IMailService _mailService; private readonly IPushNotificationService _pushService; private readonly IUserStore _userStore; @@ -52,6 +53,7 @@ namespace Bit.Core.Test.Services _cipherRepository = Substitute.For(); _organizationUserRepository = Substitute.For(); _organizationRepository = Substitute.For(); + _u2fRepository = Substitute.For(); _mailService = Substitute.For(); _pushService = Substitute.For(); _userStore = Substitute.For>(); @@ -80,6 +82,7 @@ namespace Bit.Core.Test.Services _cipherRepository, _organizationUserRepository, _organizationRepository, + _u2fRepository, _mailService, _pushService, _userStore,