Migrate existing U2F to Fido2 (#2484)

* Migrate existing U2F to Fido2

This seamlessly switches all u2f registrations over to the new FIDO2 support. Please note that I have not yet added a way to drop the u2f DB and its UI so that we can test the migration works properly for all.

* add testing logic

* fix u2f tests

* remove duplicate status message

* fix test and namespaces

* fix test
This commit is contained in:
Andrew Camilleri 2021-04-28 06:14:15 +02:00 committed by GitHub
parent c878f63f99
commit 02bf5afe0b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 149 additions and 59 deletions

View file

@ -16,8 +16,7 @@ namespace BTCPayServer.Data
public CredentialType Type { get; set; }
public enum CredentialType
{
FIDO2,
U2F
FIDO2
}
public static void OnModelCreating(ModelBuilder builder)
{

View file

@ -9,6 +9,7 @@ using BTCPayServer.Tests.Logging;
using BTCPayServer.U2F;
using BTCPayServer.U2F.Models;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using U2F.Core.Models;
using U2F.Core.Utils;
using Xunit;
@ -61,8 +62,8 @@ namespace BTCPayServer.Tests
Assert.Equal("testdevice", addDeviceVM.Name);
Assert.NotEmpty(addDeviceVM.Version);
Assert.Null(addDeviceVM.DeviceResponse);
var devReg = new DeviceRegistration(Guid.NewGuid().ToByteArray(), Guid.NewGuid().ToByteArray(),
var devReg = new DeviceRegistration(Guid.NewGuid().ToByteArray(), RandomUtils.GetBytes(65),
Guid.NewGuid().ToByteArray(), 1);
mock.GetDevReg = () => devReg;

View file

@ -19,6 +19,8 @@ using BTCPayServer.Configuration;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Fido2;
using BTCPayServer.Fido2.Models;
using BTCPayServer.HostedServices;
using BTCPayServer.Hosting;
using BTCPayServer.Lightning;
@ -42,9 +44,9 @@ using BTCPayServer.Services.Labels;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Rates;
using BTCPayServer.Tests.Logging;
using BTCPayServer.U2F.Models;
using BTCPayServer.Validation;
using ExchangeSharp;
using Fido2NetLib;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
@ -3324,7 +3326,7 @@ namespace BTCPayServer.Tests
var accountController = tester.PayTester.GetController<AccountController>();
//no 2fa or u2f enabled, login should work
//no 2fa or fido2 enabled, login should work
Assert.Equal(nameof(HomeController.Index),
Assert.IsType<RedirectToActionResult>(await accountController.Login(new LoginViewModel()
{
@ -3332,48 +3334,46 @@ namespace BTCPayServer.Tests
Password = user.RegisterDetails.Password
})).ActionName);
var manageController = user.GetController<ManageController>();
var manageController = user.GetController<Fido2Controller>();
//by default no u2f devices available
//by default no fido2 devices available
Assert.Empty(Assert
.IsType<U2FAuthenticationViewModel>(Assert
.IsType<ViewResult>(await manageController.U2FAuthentication()).Model).Devices);
var addRequest =
Assert.IsType<AddU2FDeviceViewModel>(Assert
.IsType<ViewResult>(manageController.AddU2FDevice("label")).Model);
//name should match the one provided in beginning
Assert.Equal("label", addRequest.Name);
.IsType<Fido2AuthenticationViewModel>(Assert
.IsType<ViewResult>(await manageController.List()).Model).Credentials);
Assert.IsType<CredentialCreateOptions>(Assert
.IsType<ViewResult>(await manageController.Create(new AddFido2CredentialViewModel()
{
Name = "label"
})).Model);
//sending an invalid response model back to server, should error out
Assert.IsType<RedirectToActionResult>(await manageController.AddU2FDevice(addRequest));
Assert.IsType<RedirectToActionResult>(await manageController.CreateResponse("sdsdsa", "sds"));
var statusModel = manageController.TempData.GetStatusMessageModel();
Assert.Equal(StatusMessageModel.StatusSeverity.Error, statusModel.Severity);
var contextFactory = tester.PayTester.GetService<ApplicationDbContextFactory>();
//add a fake u2f device in db directly since emulating a u2f device is hard and annoying
//add a fake fido2 device in db directly since emulating a fido2 device is hard and annoying
using (var context = contextFactory.CreateContext())
{
var newDevice = new U2FDevice()
var newDevice = new Fido2Credential()
{
Id = Guid.NewGuid().ToString(),
Name = "fake",
Counter = 0,
KeyHandle = UTF8Encoding.UTF8.GetBytes("fake"),
PublicKey = UTF8Encoding.UTF8.GetBytes("fake"),
AttestationCert = UTF8Encoding.UTF8.GetBytes("fake"),
Type = Fido2Credential.CredentialType.FIDO2,
ApplicationUserId = user.UserId
};
await context.U2FDevices.AddAsync(newDevice);
newDevice.SetBlob(new Fido2CredentialBlob() { });
await context.Fido2Credentials.AddAsync(newDevice);
await context.SaveChangesAsync();
Assert.NotNull(newDevice.Id);
Assert.NotEmpty(Assert
.IsType<U2FAuthenticationViewModel>(Assert
.IsType<ViewResult>(await manageController.U2FAuthentication()).Model).Devices);
.IsType<Fido2AuthenticationViewModel>(Assert
.IsType<ViewResult>(await manageController.List()).Model).Credentials);
}
//check if we are showing the u2f login screen now
//check if we are showing the fido2 login screen now
var secondLoginResult = Assert.IsType<ViewResult>(await accountController.Login(new LoginViewModel()
{
Email = user.RegisterDetails.Email,
@ -3384,7 +3384,7 @@ namespace BTCPayServer.Tests
var vm = Assert.IsType<SecondaryLoginViewModel>(secondLoginResult.Model);
//2fa was never enabled for user so this should be empty
Assert.Null(vm.LoginWith2FaViewModel);
Assert.NotNull(vm.LoginWithU2FViewModel);
Assert.NotNull(vm.LoginWithFido2ViewModel);
}
}

View file

@ -51,8 +51,8 @@
<PackageReference Include="BundlerMinifier.Core" Version="3.2.435" />
<PackageReference Include="BundlerMinifier.TagHelpers" Version="3.2.435" />
<PackageReference Include="CsvHelper" Version="15.0.5" />
<PackageReference Include="Fido2" Version="2.0.0-preview2" />
<PackageReference Include="Fido2.AspNet" Version="2.0.0-preview2" />
<PackageReference Include="Fido2" Version="2.0.1" />
<PackageReference Include="Fido2.AspNet" Version="2.0.1" />
<PackageReference Include="HtmlSanitizer" Version="5.0.372" />
<PackageReference Include="McMaster.NETCore.Plugins.Mvc" Version="1.3.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Filter" Version="1.1.2" />

View file

@ -2,7 +2,7 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Data;
using BTCPayServer.Fido2;
using BTCPayServer.Fido2.Models;
using BTCPayServer.Models;
using Fido2NetLib;
using Microsoft.AspNetCore.Authorization;
@ -10,7 +10,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.U2F.Models
namespace BTCPayServer.Fido2
{
[Route("fido2")]
@ -78,8 +78,7 @@ namespace BTCPayServer.U2F.Models
[HttpPost("register")]
public async Task<IActionResult> CreateResponse([FromForm] string data, [FromForm] string name)
{
var attestationResponse = JObject.Parse(data).ToObject<AuthenticatorAttestationRawResponse>();
if (await _fido2Service.CompleteCreation(_userManager.GetUserId(User), name, attestationResponse))
if (await _fido2Service.CompleteCreation(_userManager.GetUserId(User), name, data))
{
TempData.SetStatusMessageModel(new StatusMessageModel

View file

@ -4,11 +4,13 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Fido2.Models;
using ExchangeSharp;
using Fido2NetLib;
using Fido2NetLib.Objects;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Fido2
{
@ -20,11 +22,13 @@ namespace BTCPayServer.Fido2
new ConcurrentDictionary<string, AssertionOptions>();
private readonly ApplicationDbContextFactory _contextFactory;
private readonly IFido2 _fido2;
private readonly Fido2Configuration _fido2Configuration;
public Fido2Service(ApplicationDbContextFactory contextFactory, IFido2 fido2)
public Fido2Service(ApplicationDbContextFactory contextFactory, IFido2 fido2, Fido2Configuration fido2Configuration)
{
_contextFactory = contextFactory;
_fido2 = fido2;
_fido2Configuration = fido2Configuration;
}
public async Task<CredentialCreateOptions> RequestCreation(string userId)
@ -70,26 +74,27 @@ namespace BTCPayServer.Fido2
return options;
}
public async Task<bool> CompleteCreation(string userId, string name, AuthenticatorAttestationRawResponse attestationResponse)
public async Task<bool> CompleteCreation(string userId, string name, string data)
{
await using var dbContext = _contextFactory.CreateContext();
var user = await dbContext.Users.Include(applicationUser => applicationUser.Fido2Credentials)
.FirstOrDefaultAsync(applicationUser => applicationUser.Id == userId);
if (user == null || !CreationStore.TryGetValue(userId, out var options))
{
try
{
var attestationResponse = JObject.Parse(data).ToObject<AuthenticatorAttestationRawResponse>();
await using var dbContext = _contextFactory.CreateContext();
var user = await dbContext.Users.Include(applicationUser => applicationUser.Fido2Credentials)
.FirstOrDefaultAsync(applicationUser => applicationUser.Id == userId);
if (user == null || !CreationStore.TryGetValue(userId, out var options))
{
return false;
}
}
// 2. Verify and make the credentials
var success = await _fido2.MakeNewCredentialAsync(attestationResponse, options, args => Task.FromResult(true));
var success =
await _fido2.MakeNewCredentialAsync(attestationResponse, options, args => Task.FromResult(true));
// 3. Store the credentials in db
var newCredential = new Fido2Credential()
{
Name = name,
ApplicationUserId = userId
};
var newCredential = new Fido2Credential() {Name = name, ApplicationUserId = userId};
newCredential.SetBlob(new Fido2CredentialBlob()
{
Descriptor = new PublicKeyCredentialDescriptor(success.Result.CredentialId),
@ -104,8 +109,13 @@ namespace BTCPayServer.Fido2
await dbContext.SaveChangesAsync();
CreationStore.Remove(userId, out _);
return true;
}
catch (Exception)
{
return false;
}
}
public async Task<List<Fido2Credential>> GetCredentials(string userId)
@ -158,7 +168,9 @@ namespace BTCPayServer.Fido2
},
UserVerificationIndex = true,
Location = true,
UserVerificationMethod = true
UserVerificationMethod = true ,
Extensions = true,
AppID = _fido2Configuration.Origin
};
// 3. Create options

View file

@ -1,8 +1,9 @@
using System;
using BTCPayServer.Data;
using BTCPayServer.Fido2.Models;
using NBXplorer;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Data
namespace BTCPayServer.Fido2
{
public static class Fido2Extensions
{

View file

@ -1,6 +1,6 @@
using Fido2NetLib.Objects;
namespace BTCPayServer.U2F.Models
namespace BTCPayServer.Fido2.Models
{
public class AddFido2CredentialViewModel
{

View file

@ -1,7 +1,7 @@
using System.Collections.Generic;
using BTCPayServer.Data;
namespace BTCPayServer.U2F.Models
namespace BTCPayServer.Fido2.Models
{
public class Fido2AuthenticationViewModel
{

View file

@ -2,7 +2,7 @@ using Fido2NetLib;
using Fido2NetLib.Objects;
using Newtonsoft.Json;
namespace BTCPayServer.Data
namespace BTCPayServer.Fido2.Models
{
public class Fido2CredentialBlob
{

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@ -9,11 +10,15 @@ using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Fido2;
using BTCPayServer.Fido2.Models;
using BTCPayServer.Logging;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Services;
using BTCPayServer.Services.Stores;
using Fido2NetLib;
using Fido2NetLib.Objects;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@ -22,6 +27,8 @@ using NBitcoin.DataEncoders;
using NBXplorer;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
using Org.BouncyCastle.Math.EC;
using PeterO.Cbor;
namespace BTCPayServer.Hosting
{
@ -121,6 +128,13 @@ namespace BTCPayServer.Hosting
settings.TransitionInternalNodeConnectionString = true;
await _Settings.UpdateSetting(settings);
}
if (true || !settings.MigrateU2FToFIDO2)
{
await MigrateU2FToFIDO2();
settings.MigrateU2FToFIDO2 = true;
await _Settings.UpdateSetting(settings);
}
}
catch (Exception ex)
{
@ -129,6 +143,61 @@ namespace BTCPayServer.Hosting
}
}
private async Task MigrateU2FToFIDO2()
{
await using var ctx = _DBContextFactory.CreateContext();
ctx.RemoveRange(ctx.Fido2Credentials.ToList());
var u2fDevices = await ctx.U2FDevices.ToListAsync();
foreach (U2FDevice u2FDevice in u2fDevices)
{
var fido2 = new Fido2Credential()
{
ApplicationUserId = u2FDevice.ApplicationUserId,
Name = u2FDevice.Name,
Type = Fido2Credential.CredentialType.FIDO2
};
fido2.SetBlob(new Fido2CredentialBlob()
{
SignatureCounter = (uint)u2FDevice.Counter,
PublicKey = CreatePublicKeyFromU2fRegistrationData( u2FDevice.PublicKey).EncodeToBytes() ,
UserHandle = u2FDevice.KeyHandle,
Descriptor = new PublicKeyCredentialDescriptor(u2FDevice.KeyHandle),
CredType = "u2f"
});
await ctx.AddAsync(fido2);
ctx.Remove(u2FDevice);
}
await ctx.SaveChangesAsync();
}
//from https://github.com/abergs/fido2-net-lib/blob/0fa7bb4b4a1f33f46c5f7ca4ee489b47680d579b/Test/ExistingU2fRegistrationDataTests.cs#L70
private static CBORObject CreatePublicKeyFromU2fRegistrationData(byte[] publicKeyData)
{
if (publicKeyData.Length != 65)
{
throw new ArgumentException("u2f public key must be 65 bytes", nameof(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 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, x);
coseKey.Add(COSE.KeyTypeParameter.Y, y);
return coseKey;
}
private async Task TransitionInternalNodeConnectionString()
{
var nodes = LightningOptions.Value.InternalLightningByCryptoCode.Values.Select(c => c.ToString()).ToHashSet();

View file

@ -9,6 +9,7 @@ using BTCPayServer.Client;
using BTCPayServer.Data;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@ -61,7 +62,16 @@ namespace BTCPayServer.Security.GreenField
if (!result.Succeeded)
return AuthenticateResult.Fail(result.ToString());
var user = await _userManager.FindByNameAsync(username);
var user = await _userManager.Users
.Include(applicationUser => applicationUser.U2FDevices)
.Include(applicationUser => applicationUser.Fido2Credentials)
.FirstOrDefaultAsync(applicationUser =>
applicationUser.NormalizedUserName == _userManager.NormalizeName(username));
if (user.U2FDevices.Any() || user.Fido2Credentials.Any())
{
return AuthenticateResult.Fail("Cannot use Basic authentication with multi-factor is enabled.");
}
var claims = new List<Claim>()
{
new Claim(_identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, user.Id),

View file

@ -2,6 +2,7 @@ namespace BTCPayServer.Services
{
public class MigrationSettings
{
public bool MigrateU2FToFIDO2{ get; set; }
public bool UnreachableStoreCheck { get; set; }
public bool DeprecatedLightningConnectionStringCheck { get; set; }
public bool ConvertMultiplierToSpread { get; set; }

View file

@ -1,10 +1,8 @@
@model BTCPayServer.U2F.Models.Fido2AuthenticationViewModel
@model BTCPayServer.Fido2.Models.Fido2AuthenticationViewModel
@{
ViewData.SetActivePageAndTitle(ManageNavPages.Fido2, "Registered FIDO2 Credentials");
}
<partial name="_StatusMessage"/>
<table class="table table-lg mb-4">
<thead>
<tr>