diff --git a/src/Admin/Startup.cs b/src/Admin/Startup.cs index 380274586..9f67455dc 100644 --- a/src/Admin/Startup.cs +++ b/src/Admin/Startup.cs @@ -66,6 +66,15 @@ namespace Bit.Admin services.AddBaseServices(); services.AddDefaultServices(globalSettings); + // Fido2 + services.AddFido2(options => + { + options.ServerDomain = new Uri(globalSettings.BaseServiceUri.Vault).Host; + options.ServerName = "Bitwarden"; + options.Origin = globalSettings.BaseServiceUri.Vault; + options.TimestampDriftTolerance = 300000; + }); + // Mvc services.AddMvc(config => { diff --git a/src/Api/Controllers/TwoFactorController.cs b/src/Api/Controllers/TwoFactorController.cs index 5f59be9f0..1f94c04f0 100644 --- a/src/Api/Controllers/TwoFactorController.cs +++ b/src/Api/Controllers/TwoFactorController.cs @@ -14,6 +14,7 @@ using Bit.Core.Repositories; using Bit.Core.Utilities; using Bit.Core.Utilities.Duo; using Bit.Core.Settings; +using Fido2NetLib; namespace Bit.Api.Controllers { @@ -219,45 +220,44 @@ namespace Bit.Api.Controllers return response; } - [HttpPost("get-u2f")] - public async Task GetU2f([FromBody]TwoFactorRequestModel model) + [HttpPost("get-webauthn")] + public async Task GetWebAuthn([FromBody]TwoFactorRequestModel model) { var user = await CheckAsync(model.MasterPasswordHash, true); - var response = new TwoFactorU2fResponseModel(user); + var response = new TwoFactorWebAuthnResponseModel(user); return response; } - [HttpPost("get-u2f-challenge")] - public async Task GetU2fChallenge( - [FromBody]TwoFactorRequestModel model) + [HttpPost("get-webauthn-challenge")] + public async Task GetWebAuthnChallenge([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; + var reg = await _userService.StartWebAuthnRegistrationAsync(user); + return reg; } - [HttpPut("u2f")] - [HttpPost("u2f")] - public async Task PutU2f([FromBody]TwoFactorU2fRequestModel model) + [HttpPut("webauthn")] + [HttpPost("webauthn")] + public async Task PutWebAuthn([FromBody]TwoFactorWebAuthnRequestModel model) { var user = await CheckAsync(model.MasterPasswordHash, true); - var success = await _userService.CompleteU2fRegistrationAsync( + + var success = await _userService.CompleteWebAuthRegistrationAsync( user, model.Id.Value, model.Name, model.DeviceResponse); if (!success) { - throw new BadRequestException("Unable to complete U2F key registration."); + throw new BadRequestException("Unable to complete WebAuthn registration."); } - var response = new TwoFactorU2fResponseModel(user); + var response = new TwoFactorWebAuthnResponseModel(user); return response; } - [HttpDelete("u2f")] - public async Task DeleteU2f([FromBody]TwoFactorU2fDeleteRequestModel model) + [HttpDelete("webauthn")] + public async Task DeleteWebAuthn([FromBody]TwoFactorWebAuthnDeleteRequestModel model) { var user = await CheckAsync(model.MasterPasswordHash, true); - await _userService.DeleteU2fKeyAsync(user, model.Id.Value); - var response = new TwoFactorU2fResponseModel(user); + await _userService.DeleteWebAuthnKeyAsync(user, model.Id.Value); + var response = new TwoFactorWebAuthnResponseModel(user); return response; } diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 3f298430b..0cd132ae2 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -18,6 +18,7 @@ using Microsoft.IdentityModel.Logging; using Microsoft.Extensions.Hosting; using Microsoft.OpenApi.Models; using System.Collections.Generic; +using System; namespace Bit.Api { @@ -112,6 +113,15 @@ namespace Bit.Api services.AddDefaultServices(globalSettings); services.AddCoreLocalizationServices(); + // Fido2 + services.AddFido2(options => + { + options.ServerDomain = new Uri(globalSettings.BaseServiceUri.Vault).Host; + options.ServerName = "Bitwarden"; + options.Origin = globalSettings.BaseServiceUri.Vault; + options.TimestampDriftTolerance = 300000; + }); + // MVC services.AddMvc(config => { diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 30ef21eb7..8790584ba 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -26,6 +26,7 @@ + diff --git a/src/Core/Enums/TwoFactorProviderType.cs b/src/Core/Enums/TwoFactorProviderType.cs index 1a1b7ffc9..d3d0c195a 100644 --- a/src/Core/Enums/TwoFactorProviderType.cs +++ b/src/Core/Enums/TwoFactorProviderType.cs @@ -8,6 +8,7 @@ YubiKey = 3, U2f = 4, Remember = 5, - OrganizationDuo = 6 + OrganizationDuo = 6, + WebAuthn = 7, } } diff --git a/src/Core/Identity/WebAuthnTokenProvider.cs b/src/Core/Identity/WebAuthnTokenProvider.cs new file mode 100644 index 000000000..ca4490de9 --- /dev/null +++ b/src/Core/Identity/WebAuthnTokenProvider.cs @@ -0,0 +1,158 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Bit.Core.Models.Table; +using Bit.Core.Enums; +using Bit.Core.Models; +using Newtonsoft.Json; +using System.Collections.Generic; +using System.Linq; +using System; +using Bit.Core.Services; +using Microsoft.Extensions.DependencyInjection; +using Fido2NetLib.Objects; +using Fido2NetLib; +using Bit.Core.Utilities; +using Bit.Core.Settings; + +namespace Bit.Core.Identity +{ + public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider + { + private readonly IServiceProvider _serviceProvider; + private readonly IFido2 _fido2; + private readonly GlobalSettings _globalSettings; + + public WebAuthnTokenProvider(IServiceProvider serviceProvider, IFido2 fido2, GlobalSettings globalSettings) + { + _serviceProvider = serviceProvider; + _fido2 = fido2; + _globalSettings = globalSettings; + } + + public async Task CanGenerateTwoFactorTokenAsync(UserManager manager, User user) + { + var userService = _serviceProvider.GetRequiredService(); + if (!(await userService.CanAccessPremium(user))) + { + return false; + } + + var webAuthnProvider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn); + if (!HasProperMetaData(webAuthnProvider)) + { + return false; + } + + return await userService.TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.WebAuthn, user); + } + + public async Task GenerateAsync(string purpose, UserManager manager, User user) + { + var userService = _serviceProvider.GetRequiredService(); + if (!(await userService.CanAccessPremium(user))) + { + return null; + } + + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn); + var keys = LoadKeys(provider); + var existingCredentials = keys.Select(key => key.Item2.Descriptor).ToList(); + + if (existingCredentials.Count == 0) + { + return null; + } + + var exts = new AuthenticationExtensionsClientInputs() + { + UserVerificationIndex = true, + UserVerificationMethod = true, + AppID = CoreHelpers.U2fAppIdUrl(_globalSettings), + }; + + var options = _fido2.GetAssertionOptions(existingCredentials, UserVerificationRequirement.Preferred, exts); + + provider.MetaData["login"] = options; + + var providers = user.GetTwoFactorProviders(); + providers[TwoFactorProviderType.WebAuthn] = provider; + user.SetTwoFactorProviders(providers); + await userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn); + + return options.ToJson(); + } + + public async Task ValidateAsync(string purpose, string token, UserManager manager, User user) + { + var userService = _serviceProvider.GetRequiredService(); + if (!(await userService.CanAccessPremium(user)) || string.IsNullOrWhiteSpace(token)) + { + return false; + } + + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn); + var keys = LoadKeys(provider); + + if (!provider.MetaData.ContainsKey("login")) + { + return false; + } + + var clientResponse = JsonConvert.DeserializeObject(token); + + var jsonOptions = provider.MetaData["login"].ToString(); + var options = AssertionOptions.FromJson(jsonOptions); + + var webAuthCred = keys.Find(k => k.Item2.Descriptor.Id.SequenceEqual(clientResponse.Id)); + + if (webAuthCred == null) + { + return false; + } + + IsUserHandleOwnerOfCredentialIdAsync callback = (args) => Task.FromResult(true); + + var res = await _fido2.MakeAssertionAsync(clientResponse, options, webAuthCred.Item2.PublicKey, webAuthCred.Item2.SignatureCounter, callback); + + provider.MetaData.Remove("login"); + + // Update SignatureCounter + webAuthCred.Item2.SignatureCounter = res.Counter; + + var providers = user.GetTwoFactorProviders(); + providers[TwoFactorProviderType.WebAuthn].MetaData[webAuthCred.Item1] = webAuthCred.Item2; + user.SetTwoFactorProviders(providers); + await userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn); + + return res.Status == "ok"; + } + + private bool HasProperMetaData(TwoFactorProvider provider) + { + return provider?.MetaData?.Any() ?? false; + } + + private List> LoadKeys(TwoFactorProvider provider) + { + var keys = new List>(); + if (!HasProperMetaData(provider)) + { + return keys; + } + + // Support up to 5 keys + for (var i = 1; i <= 5; i++) + { + var keyName = $"Key{i}"; + if (provider.MetaData.ContainsKey(keyName)) + { + var key = new TwoFactorProvider.WebAuthnData((dynamic)provider.MetaData[keyName]); + + keys.Add(new Tuple(keyName, key)); + } + } + + return keys; + } + } +} diff --git a/src/Core/IdentityServer/BaseRequestValidator.cs b/src/Core/IdentityServer/BaseRequestValidator.cs index 4eede6aed..dc579c4be 100644 --- a/src/Core/IdentityServer/BaseRequestValidator.cs +++ b/src/Core/IdentityServer/BaseRequestValidator.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Identity; using System; using System.Collections.Generic; using System.Security.Claims; +using System.Text.Json; using System.Threading.Tasks; using Bit.Core.Services; using System.Linq; @@ -367,6 +368,7 @@ namespace Bit.Core.IdentityServer case TwoFactorProviderType.Duo: case TwoFactorProviderType.YubiKey: case TwoFactorProviderType.U2f: + case TwoFactorProviderType.WebAuthn: case TwoFactorProviderType.Remember: if (type != TwoFactorProviderType.Remember && !(await _userService.TwoFactorProviderIsEnabledAsync(type, user))) @@ -394,6 +396,7 @@ namespace Bit.Core.IdentityServer { case TwoFactorProviderType.Duo: case TwoFactorProviderType.U2f: + case TwoFactorProviderType.WebAuthn: case TwoFactorProviderType.Email: case TwoFactorProviderType.YubiKey: if (!(await _userService.TwoFactorProviderIsEnabledAsync(type, user))) @@ -421,6 +424,10 @@ namespace Bit.Core.IdentityServer ["Challenges"] = tokens != null && tokens.Length > 1 ? tokens[1] : null }; } + else if (type == TwoFactorProviderType.WebAuthn) + { + return JsonSerializer.Deserialize>(token); + } else if (type == TwoFactorProviderType.Email) { return new Dictionary diff --git a/src/Core/Models/Api/Request/TwoFactorRequestModels.cs b/src/Core/Models/Api/Request/TwoFactorRequestModels.cs index 41929c530..ad60e9dfc 100644 --- a/src/Core/Models/Api/Request/TwoFactorRequestModels.cs +++ b/src/Core/Models/Api/Request/TwoFactorRequestModels.cs @@ -1,5 +1,6 @@ using Bit.Core.Enums; using Bit.Core.Models.Table; +using Fido2NetLib; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; @@ -223,14 +224,14 @@ namespace Bit.Core.Models.Api } } - public class TwoFactorU2fRequestModel : TwoFactorU2fDeleteRequestModel + public class TwoFactorWebAuthnRequestModel : TwoFactorWebAuthnDeleteRequestModel { [Required] - public string DeviceResponse { get; set; } + public AuthenticatorAttestationRawResponse DeviceResponse { get; set; } public string Name { get; set; } } - public class TwoFactorU2fDeleteRequestModel : TwoFactorRequestModel, IValidatableObject + public class TwoFactorWebAuthnDeleteRequestModel : TwoFactorRequestModel, IValidatableObject { [Required] public int? Id { get; set; } diff --git a/src/Core/Models/Api/Response/TwoFactor/TwoFactorU2fResponseModel.cs b/src/Core/Models/Api/Response/TwoFactor/TwoFactorU2fResponseModel.cs deleted file mode 100644 index 121be3d4b..000000000 --- a/src/Core/Models/Api/Response/TwoFactor/TwoFactorU2fResponseModel.cs +++ /dev/null @@ -1,59 +0,0 @@ -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/Api/Response/TwoFactor/TwoFactorWebAuthnResponseModel.cs b/src/Core/Models/Api/Response/TwoFactor/TwoFactorWebAuthnResponseModel.cs new file mode 100644 index 000000000..3c4da9e05 --- /dev/null +++ b/src/Core/Models/Api/Response/TwoFactor/TwoFactorWebAuthnResponseModel.cs @@ -0,0 +1,44 @@ +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 TwoFactorWebAuthnResponseModel : ResponseModel + { + public TwoFactorWebAuthnResponseModel(User user) + : base("twoFactorWebAuthn") + { + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn); + Enabled = provider?.Enabled ?? false; + Keys = provider?.MetaData? + .Where(k => k.Key.StartsWith("Key")) + .Select(k => new KeyModel(k.Key, new TwoFactorProvider.WebAuthnData((dynamic)k.Value))); + } + + public bool Enabled { get; set; } + public IEnumerable Keys { get; set; } + + public class KeyModel + { + public KeyModel(string id, TwoFactorProvider.WebAuthnData data) + { + Name = data.Name; + Id = Convert.ToInt32(id.Replace("Key", string.Empty)); + Migrated = data.Migrated; + } + + public string Name { get; set; } + public int Id { get; set; } + public bool Migrated { get; set; } + } + } +} diff --git a/src/Core/Models/Business/U2fRegistration.cs b/src/Core/Models/Business/U2fRegistration.cs deleted file mode 100644 index c27afdc40..000000000 --- a/src/Core/Models/Business/U2fRegistration.cs +++ /dev/null @@ -1,9 +0,0 @@ -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/Models/TwoFactorProvider.cs b/src/Core/Models/TwoFactorProvider.cs index 8d51a8c02..79bb9d448 100644 --- a/src/Core/Models/TwoFactorProvider.cs +++ b/src/Core/Models/TwoFactorProvider.cs @@ -1,6 +1,11 @@ using Bit.Core.Enums; +using Fido2NetLib.Objects; +using Microsoft.Extensions.Options; using Newtonsoft.Json; +using PeterO.Cbor; +using System; using System.Collections.Generic; +using System.Security.Cryptography; using U2F.Core.Utils; namespace Bit.Core.Models @@ -39,6 +44,84 @@ namespace Bit.Core.Models string.IsNullOrWhiteSpace(Certificate) ? null : Utils.Base64StringToByteArray(Certificate); public uint Counter { get; set; } public bool Compromised { get; set; } + + private static CBORObject CreatePublicKeyFromU2fRegistrationData(byte[] keyHandleData, byte[] publicKeyData) + { + var x = new byte[32]; + var y = new byte[32]; + Buffer.BlockCopy(publicKeyData, 1, x, 0, 32); + Buffer.BlockCopy(publicKeyData, 33, y, 0, 32); + + var point = new ECPoint + { + X = x, + Y = y, + }; + + var coseKey = CBORObject.NewMap(); + + coseKey.Add(COSE.KeyCommonParameter.KeyType, COSE.KeyType.EC2); + coseKey.Add(COSE.KeyCommonParameter.Alg, -7); + + coseKey.Add(COSE.KeyTypeParameter.Crv, COSE.EllipticCurve.P256); + + coseKey.Add(COSE.KeyTypeParameter.X, point.X); + coseKey.Add(COSE.KeyTypeParameter.Y, point.Y); + + return coseKey; + } + + public WebAuthnData ToWebAuthnData() + { + return new WebAuthnData + { + Name = Name, + Descriptor = new PublicKeyCredentialDescriptor + { + Id = KeyHandleBytes, + Type = PublicKeyCredentialType.PublicKey + }, + PublicKey = CreatePublicKeyFromU2fRegistrationData(KeyHandleBytes, PublicKeyBytes).EncodeToBytes(), + SignatureCounter = Counter, + Migrated = true, + }; + } + } + + public class WebAuthnData + { + public WebAuthnData() { } + + public WebAuthnData(dynamic o) + { + Name = o.Name; + try + { + Descriptor = o.Descriptor; + } + catch + { + // Handle newtonsoft parsing + Descriptor = JsonConvert.DeserializeObject(o.Descriptor.ToString()); + } + PublicKey = o.PublicKey; + UserHandle = o.UserHandle; + SignatureCounter = o.SignatureCounter; + CredType = o.CredType; + RegDate = o.RegDate; + AaGuid = o.AaGuid; + Migrated = o.Migrated; + } + + public string Name { get; set; } + public PublicKeyCredentialDescriptor Descriptor { get; internal set; } + public byte[] PublicKey { get; internal set; } + public byte[] UserHandle { get; internal set; } + public uint SignatureCounter { get; set; } + public string CredType { get; internal set; } + public DateTime RegDate { get; internal set; } + public Guid AaGuid { get; internal set; } + public bool Migrated { get; internal set; } } public static bool RequiresPremium(TwoFactorProviderType type) diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index c2830f6ef..2b695690e 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -7,6 +7,7 @@ using System.Security.Claims; using Bit.Core.Enums; using Bit.Core.Models; using Bit.Core.Models.Business; +using Fido2NetLib; namespace Bit.Core.Services { @@ -23,9 +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); Task SendEmailVerificationAsync(User user); Task ConfirmEmailAsync(User user, string token); Task InitiateEmailChangeAsync(User user, string newEmail); @@ -38,7 +39,7 @@ namespace Bit.Core.Services Task UpdateKeyAsync(User user, string masterPassword, string key, string privateKey, IEnumerable ciphers, IEnumerable folders); Task RefreshSecurityStampAsync(User user, string masterPasswordHash); - Task UpdateTwoFactorProviderAsync(User user, TwoFactorProviderType type); + Task UpdateTwoFactorProviderAsync(User user, TwoFactorProviderType type, bool setEnabled = true); Task DisableTwoFactorProviderAsync(User user, TwoFactorProviderType type, IOrganizationService organizationService); Task RecoverTwoFactorAsync(string email, string masterPassword, string recoveryCode, diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index d5bc06e08..268288ffe 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -21,7 +21,8 @@ using Bit.Core.Settings; using System.IO; using Newtonsoft.Json; using Microsoft.AspNetCore.DataProtection; -using U2F.Core.Exceptions; +using Fido2NetLib; +using Fido2NetLib.Objects; namespace Bit.Core.Services { @@ -34,7 +35,6 @@ 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; @@ -48,6 +48,7 @@ namespace Bit.Core.Services private readonly IPolicyRepository _policyRepository; private readonly IDataProtector _organizationServiceDataProtector; private readonly IReferenceEventService _referenceEventService; + private readonly IFido2 _fido2; private readonly ICurrentContext _currentContext; private readonly GlobalSettings _globalSettings; private readonly IOrganizationService _organizationService; @@ -57,7 +58,6 @@ namespace Bit.Core.Services ICipherRepository cipherRepository, IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository, - IU2fRepository u2fRepository, IMailService mailService, IPushNotificationService pushService, IUserStore store, @@ -76,6 +76,7 @@ namespace Bit.Core.Services IPaymentService paymentService, IPolicyRepository policyRepository, IReferenceEventService referenceEventService, + IFido2 fido2, ICurrentContext currentContext, GlobalSettings globalSettings, IOrganizationService organizationService) @@ -94,7 +95,6 @@ namespace Bit.Core.Services _cipherRepository = cipherRepository; _organizationUserRepository = organizationUserRepository; _organizationRepository = organizationRepository; - _u2fRepository = u2fRepository; _mailService = mailService; _pushService = pushService; _identityOptions = optionsAccessor?.Value ?? new IdentityOptions(); @@ -109,6 +109,7 @@ namespace Bit.Core.Services _organizationServiceDataProtector = dataProtectionProvider.CreateProtector( "OrganizationServiceDataProtector"); _referenceEventService = referenceEventService; + _fido2 = fido2; _currentContext = currentContext; _globalSettings = globalSettings; _organizationService = organizationService; @@ -362,107 +363,88 @@ namespace Bit.Core.Services "2faEmail:" + email, token); } - public async Task StartU2fRegistrationAsync(User user) + public async Task StartWebAuthnRegistrationAsync(User user) { - await _u2fRepository.DeleteManyByUserIdAsync(user.Id); - var reg = U2fLib.StartRegistration(CoreHelpers.U2fAppIdUrl(_globalSettings)); - await _u2fRepository.CreateAsync(new U2f + var providers = user.GetTwoFactorProviders(); + if (providers == null) { - AppId = reg.AppId, - Challenge = reg.Challenge, - Version = reg.Version, - UserId = user.Id, - CreationDate = DateTime.UtcNow - }); + providers = new Dictionary(); + } + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn); + if (provider == null) + { + provider = new TwoFactorProvider + { + Enabled = false + }; + } + if (provider.MetaData == null) + { + provider.MetaData = new Dictionary(); + } - return new U2fRegistration + var fidoUser = new Fido2User { - AppId = reg.AppId, - Challenge = reg.Challenge, - Version = reg.Version + DisplayName = user.Name, + Name = user.Email, + Id = user.Id.ToByteArray(), }; + + var excludeCredentials = provider.MetaData + .Where(k => k.Key.StartsWith("Key")) + .Select(k => new TwoFactorProvider.WebAuthnData((dynamic)k.Value).Descriptor) + .ToList(); + + var options = _fido2.RequestNewCredential(fidoUser, excludeCredentials, AuthenticatorSelection.Default, AttestationConveyancePreference.None); + + provider.MetaData["pending"] = options.ToJson(); + providers[TwoFactorProviderType.WebAuthn] = provider; + user.SetTwoFactorProviders(providers); + await UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn, false); + + return options; } - public async Task CompleteU2fRegistrationAsync(User user, int id, string name, string deviceResponse) + public async Task CompleteWebAuthRegistrationAsync(User user, int id, string name, AuthenticatorAttestationRawResponse attestationResponse) { - if (string.IsNullOrWhiteSpace(deviceResponse)) + var keyId = $"Key{id}"; + + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn); + if (!provider?.MetaData?.ContainsKey("pending") ?? true) { return false; } - var challenges = await _u2fRepository.GetManyByUserIdAsync(user.Id); - if (!challenges?.Any() ?? true) + var options = CredentialCreateOptions.FromJson((string)provider.MetaData["pending"]); + + // Callback to ensure credential id is unique. Always return true since we don't care if another + // account uses the same 2fa key. + IsCredentialIdUniqueToUserAsyncDelegate callback = args => Task.FromResult(true); + + var success = await _fido2.MakeNewCredentialAsync(attestationResponse, options, callback); + + provider.MetaData.Remove("pending"); + provider.MetaData[keyId] = new TwoFactorProvider.WebAuthnData { - return false; - } + Name = name, + Descriptor = new PublicKeyCredentialDescriptor(success.Result.CredentialId), + PublicKey = success.Result.PublicKey, + UserHandle = success.Result.User.Id, + SignatureCounter = success.Result.Counter, + CredType = success.Result.CredType, + RegDate = DateTime.Now, + AaGuid = success.Result.Aaguid + }; - var registerResponse = BaseModel.FromJson(deviceResponse); + var providers = user.GetTwoFactorProviders(); + providers[TwoFactorProviderType.WebAuthn] = provider; + user.SetTwoFactorProviders(providers); + await UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn); - 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; - } + return true; } - public async Task DeleteU2fKeyAsync(User user, int id) + public async Task DeleteWebAuthnKeyAsync(User user, int id) { var providers = user.GetTwoFactorProviders(); if (providers == null) @@ -471,7 +453,7 @@ namespace Bit.Core.Services } var keyName = $"Key{id}"; - var provider = user.GetTwoFactorProvider(TwoFactorProviderType.U2f); + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn); if (!provider?.MetaData?.ContainsKey(keyName) ?? true) { return false; @@ -483,9 +465,9 @@ namespace Bit.Core.Services } provider.MetaData.Remove(keyName); - providers[TwoFactorProviderType.U2f] = provider; + providers[TwoFactorProviderType.WebAuthn] = provider; user.SetTwoFactorProviders(providers); - await UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.U2f); + await UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn); return true; } @@ -703,9 +685,9 @@ namespace Bit.Core.Services return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); } - public async Task UpdateTwoFactorProviderAsync(User user, TwoFactorProviderType type) + public async Task UpdateTwoFactorProviderAsync(User user, TwoFactorProviderType type, bool setEnabled = true) { - SetTwoFactorProvider(user, type); + SetTwoFactorProvider(user, type, setEnabled); await SaveUserAsync(user); await _eventService.LogUserEventAsync(user.Id, EventType.User_Updated2fa); } @@ -1158,7 +1140,7 @@ namespace Bit.Core.Services return IdentityResult.Success; } - public void SetTwoFactorProvider(User user, TwoFactorProviderType type) + public void SetTwoFactorProvider(User user, TwoFactorProviderType type, bool setEnabled = true) { var providers = user.GetTwoFactorProviders(); if (!providers?.ContainsKey(type) ?? true) @@ -1166,7 +1148,10 @@ namespace Bit.Core.Services return; } - providers[type].Enabled = true; + if (setEnabled) + { + providers[type].Enabled = true; + } user.SetTwoFactorProviders(providers); if (string.IsNullOrWhiteSpace(user.TwoFactorRecoveryCode)) diff --git a/src/Core/Utilities/ServiceCollectionExtensions.cs b/src/Core/Utilities/ServiceCollectionExtensions.cs index 4d86764f2..1038d825e 100644 --- a/src/Core/Utilities/ServiceCollectionExtensions.cs +++ b/src/Core/Utilities/ServiceCollectionExtensions.cs @@ -294,7 +294,9 @@ namespace Bit.Core.Utilities CoreHelpers.CustomProviderName(TwoFactorProviderType.U2f)) .AddTokenProvider( CoreHelpers.CustomProviderName(TwoFactorProviderType.Remember)) - .AddTokenProvider>(TokenOptions.DefaultEmailProvider); + .AddTokenProvider>(TokenOptions.DefaultEmailProvider) + .AddTokenProvider( + CoreHelpers.CustomProviderName(TwoFactorProviderType.WebAuthn)); return identityBuilder; } diff --git a/src/Identity/Startup.cs b/src/Identity/Startup.cs index a3d03b295..27ae5a889 100644 --- a/src/Identity/Startup.cs +++ b/src/Identity/Startup.cs @@ -56,6 +56,15 @@ namespace Bit.Identity // Caching services.AddMemoryCache(); + // Fido2 + services.AddFido2(options => + { + options.ServerDomain = new Uri(globalSettings.BaseServiceUri.Vault).Host; + options.ServerName = "Bitwarden"; + options.Origin = globalSettings.BaseServiceUri.Vault; + options.TimestampDriftTolerance = 300000; + }); + // Mvc services.AddMvc(); diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index 69cb3bfd0..818055944 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.Options; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; +using Fido2NetLib; using Bit.Core.Context; namespace Bit.Core.Test.Services @@ -22,7 +23,6 @@ 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; @@ -41,6 +41,7 @@ namespace Bit.Core.Test.Services private readonly IPaymentService _paymentService; private readonly IPolicyRepository _policyRepository; private readonly IReferenceEventService _referenceEventService; + private readonly IFido2 _fido2; private readonly CurrentContext _currentContext; private readonly GlobalSettings _globalSettings; private readonly IOrganizationService _organizationService; @@ -51,7 +52,6 @@ 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>(); @@ -70,6 +70,7 @@ namespace Bit.Core.Test.Services _paymentService = Substitute.For(); _policyRepository = Substitute.For(); _referenceEventService = Substitute.For(); + _fido2 = Substitute.For(); _currentContext = new CurrentContext(); _globalSettings = new GlobalSettings(); _organizationService = Substitute.For(); @@ -79,7 +80,6 @@ namespace Bit.Core.Test.Services _cipherRepository, _organizationUserRepository, _organizationRepository, - _u2fRepository, _mailService, _pushService, _userStore, @@ -98,6 +98,7 @@ namespace Bit.Core.Test.Services _paymentService, _policyRepository, _referenceEventService, + _fido2, _currentContext, _globalSettings, _organizationService diff --git a/util/Migrator/DbScripts/2020-09-09_00-ScriptMigrateU2FToWebAuthn.cs b/util/Migrator/DbScripts/2020-09-09_00-ScriptMigrateU2FToWebAuthn.cs new file mode 100644 index 000000000..599066587 --- /dev/null +++ b/util/Migrator/DbScripts/2020-09-09_00-ScriptMigrateU2FToWebAuthn.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using Bit.Core.Enums; +using Bit.Core.Models; +using DbUp.Engine; +using Newtonsoft.Json; + +namespace Bit.Migrator.DbScripts +{ + class ScriptMigrateU2FToWebAuthn : IScript + { + + public string ProvideScript(Func commandFactory) + { + var cmd = commandFactory(); + cmd.CommandText = "SELECT Id, TwoFactorProviders FROM [dbo].[User] WHERE TwoFactorProviders IS NOT NULL"; + + var users = new List(); + + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + var id = reader.GetGuid(0); + var twoFactorProviders = reader.GetString(1); + + if (string.IsNullOrWhiteSpace(twoFactorProviders)) + { + continue; + } + + var providers = JsonConvert.DeserializeObject>(twoFactorProviders); + + if (!providers.ContainsKey(TwoFactorProviderType.U2f)) + { + continue; + } + + var u2fProvider = providers[TwoFactorProviderType.U2f]; + + if (!u2fProvider.Enabled || !HasProperMetaData(u2fProvider)) + { + continue; + } + + var u2fKeys = LoadKeys(u2fProvider); + var webAuthnKeys = u2fKeys.Select(key => (key.Item1, key.Item2.ToWebAuthnData())); + + var webAuthnProvider = new TwoFactorProvider + { + Enabled = true, + MetaData = webAuthnKeys.ToDictionary(x => x.Item1, x => (object)x.Item2) + }; + + providers[TwoFactorProviderType.WebAuthn] = webAuthnProvider; + + users.Add(new User + { + Id = id, + TwoFactorProviders = JsonConvert.SerializeObject(providers), + }); + } + } + + foreach (User user in users) + { + var command = commandFactory(); + + command.CommandText = "UPDATE [dbo].[User] SET TwoFactorProviders = @twoFactorProviders WHERE Id = @id"; + var idParameter = command.CreateParameter(); + idParameter.ParameterName = "@id"; + idParameter.Value = user.Id; + + var twoFactorParameter = command.CreateParameter(); + twoFactorParameter.ParameterName = "@twoFactorProviders"; + twoFactorParameter.Value = user.TwoFactorProviders; + + command.Parameters.Add(idParameter); + command.Parameters.Add(twoFactorParameter); + + command.ExecuteNonQuery(); + } + + return ""; + } + + private bool HasProperMetaData(TwoFactorProvider provider) + { + return (provider?.MetaData?.Count ?? 0) > 0; + } + + private List> LoadKeys(TwoFactorProvider provider) + { + var keys = new List>(); + + // Support up to 5 keys + for (var i = 1; i <= 5; i++) + { + var keyName = $"Key{i}"; + if (provider.MetaData.ContainsKey(keyName)) + { + var key = new TwoFactorProvider.U2fMetaData((dynamic)provider.MetaData[keyName]); + if (!key?.Compromised ?? false) + { + keys.Add(new Tuple(keyName, key)); + } + } + } + + return keys; + } + + private class User + { + public Guid Id { get; set; } + public string TwoFactorProviders { get; set; } + } + } +} diff --git a/util/Setup/Templates/NginxConfig.hbs b/util/Setup/Templates/NginxConfig.hbs index 7ccd61ce8..86af0d1c1 100644 --- a/util/Setup/Templates/NginxConfig.hbs +++ b/util/Setup/Templates/NginxConfig.hbs @@ -92,6 +92,14 @@ server { proxy_pass http://web:5000/u2f-connector.html; } + location = /webauthn-connector.html { + proxy_pass http://web:5000/webauthn-connector.html; + } + + location = /webauthn-fallback-connector.html { + proxy_pass http://web:5000/webauthn-fallback-connector.html; + } + location = /sso-connector.html { proxy_pass http://web:5000/sso-connector.html; }