FIDO2/WebAuthN Support (#2356)

* FIDO2/WebAuthN Support

This adds initial support for WebAuthN/FIDO2 as another MFA mode. U2F is still intact and runs alongside it for now. Once this is merged, I will start work on migrating U2F support to happen over the FIDO2 protocol instead.

* Refactor and future proof system (prep work of seamless u2f migration)

* attempt js fix for mobile devices

* Apply suggestions from code review

Co-authored-by: d11n <mail@dennisreimann.de>

* fix fido name saving

* do not spam logs and hide loader when failed

* PR Changes

* Apply suggestions from code review

Co-authored-by: d11n <mail@dennisreimann.de>

* attempt fido2 bump

* add name if not named for credentials

Co-authored-by: d11n <mail@dennisreimann.de>
This commit is contained in:
Andrew Camilleri
2021-04-20 07:06:32 +02:00
committed by GitHub
parent 315284d5f5
commit 0554565b30
30 changed files with 1004 additions and 25 deletions

View File

@@ -55,6 +55,7 @@ namespace BTCPayServer.Data
public DbSet<StoreWebhookData> StoreWebhooks { get; set; } public DbSet<StoreWebhookData> StoreWebhooks { get; set; }
public DbSet<StoreData> Stores { get; set; } public DbSet<StoreData> Stores { get; set; }
public DbSet<U2FDevice> U2FDevices { get; set; } public DbSet<U2FDevice> U2FDevices { get; set; }
public DbSet<Fido2Credential> Fido2Credentials { get; set; }
public DbSet<UserStore> UserStore { get; set; } public DbSet<UserStore> UserStore { get; set; }
public DbSet<WalletData> Wallets { get; set; } public DbSet<WalletData> Wallets { get; set; }
public DbSet<WalletTransactionData> WalletTransactions { get; set; } public DbSet<WalletTransactionData> WalletTransactions { get; set; }
@@ -99,6 +100,7 @@ namespace BTCPayServer.Data
StoreWebhookData.OnModelCreating(builder); StoreWebhookData.OnModelCreating(builder);
//StoreData.OnModelCreating(builder); //StoreData.OnModelCreating(builder);
U2FDevice.OnModelCreating(builder); U2FDevice.OnModelCreating(builder);
Fido2Credential.OnModelCreating(builder);
Data.UserStore.OnModelCreating(builder); Data.UserStore.OnModelCreating(builder);
//WalletData.OnModelCreating(builder); //WalletData.OnModelCreating(builder);
WalletTransactionData.OnModelCreating(builder); WalletTransactionData.OnModelCreating(builder);

View File

@@ -16,5 +16,6 @@ namespace BTCPayServer.Data
public List<NotificationData> Notifications { get; set; } public List<NotificationData> Notifications { get; set; }
public List<UserStore> UserStores { get; set; } public List<UserStore> UserStores { get; set; }
public List<Fido2Credential> Fido2Credentials { get; set; }
} }
} }

View File

@@ -0,0 +1,33 @@
using System;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Data
{
public class Fido2Credential
{
public string Name { get; set; }
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public string Id { get; set; }
public string ApplicationUserId { get; set; }
public byte[] Blob { get; set; }
public CredentialType Type { get; set; }
public enum CredentialType
{
FIDO2,
U2F
}
public static void OnModelCreating(ModelBuilder builder)
{
builder.Entity<Fido2Credential>()
.HasOne(o => o.ApplicationUser)
.WithMany(i => i.Fido2Credentials)
.HasForeignKey(i => i.ApplicationUserId).OnDelete(DeleteBehavior.Cascade);
}
public ApplicationUser ApplicationUser { get; set; }
}
}

View File

@@ -0,0 +1,48 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20210314092253_Fido2Credentials")]
public partial class Fido2Credentials : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Fido2Credentials",
columns: table => new
{
Id = table.Column<string>(nullable: false),
Name = table.Column<string>(nullable: true),
ApplicationUserId = table.Column<string>(nullable: true),
Blob = table.Column<byte[]>(nullable: true),
Type = table.Column<int>(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Fido2Credentials", x => x.Id);
table.ForeignKey(
name: "FK_Fido2Credentials_AspNetUsers_ApplicationUserId",
column: x => x.ApplicationUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Fido2Credentials_ApplicationUserId",
table: "Fido2Credentials",
column: "ApplicationUserId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Fido2Credentials");
}
}
}

View File

@@ -170,6 +170,31 @@ namespace BTCPayServer.Migrations
b.ToTable("AspNetUsers"); b.ToTable("AspNetUsers");
}); });
modelBuilder.Entity("BTCPayServer.Data.Fido2Credential", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ApplicationUserId")
.HasColumnType("TEXT");
b.Property<byte[]>("Blob")
.HasColumnType("BLOB");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ApplicationUserId");
b.ToTable("Fido2Credentials");
});
modelBuilder.Entity("BTCPayServer.Data.HistoricalAddressInvoiceData", b => modelBuilder.Entity("BTCPayServer.Data.HistoricalAddressInvoiceData", b =>
{ {
b.Property<string>("InvoiceDataId") b.Property<string>("InvoiceDataId")
@@ -958,6 +983,14 @@ namespace BTCPayServer.Migrations
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
modelBuilder.Entity("BTCPayServer.Data.Fido2Credential", b =>
{
b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser")
.WithMany("Fido2Credentials")
.HasForeignKey("ApplicationUserId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("BTCPayServer.Data.HistoricalAddressInvoiceData", b => modelBuilder.Entity("BTCPayServer.Data.HistoricalAddressInvoiceData", b =>
{ {
b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData")

View File

@@ -53,4 +53,8 @@
<ProjectReference Include="..\BTCPayServer\BTCPayServer.csproj" /> <ProjectReference Include="..\BTCPayServer\BTCPayServer.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Pages" />
</ItemGroup>
</Project> </Project>

View File

@@ -51,6 +51,8 @@
<PackageReference Include="BundlerMinifier.Core" Version="3.2.435" /> <PackageReference Include="BundlerMinifier.Core" Version="3.2.435" />
<PackageReference Include="BundlerMinifier.TagHelpers" Version="3.2.435" /> <PackageReference Include="BundlerMinifier.TagHelpers" Version="3.2.435" />
<PackageReference Include="CsvHelper" Version="15.0.5" /> <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="HtmlSanitizer" Version="5.0.372" /> <PackageReference Include="HtmlSanitizer" Version="5.0.372" />
<PackageReference Include="McMaster.NETCore.Plugins.Mvc" Version="1.3.1" /> <PackageReference Include="McMaster.NETCore.Plugins.Mvc" Version="1.3.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Filter" Version="1.1.2" /> <PackageReference Include="Microsoft.Extensions.Logging.Filter" Version="1.1.2" />

View File

@@ -57,7 +57,7 @@ else
if (!disabled) if (!disabled)
{ {
var user = await UserManager.GetUserAsync(User); var user = await UserManager.GetUserAsync(User);
disabled = user.DisabledNotifications == "all"; disabled = user?.DisabledNotifications == "all";
} }
} }
@if (!disabled) @if (!disabled)

View File

@@ -1,24 +1,25 @@
using System; using System;
using System.Globalization; using System.Globalization;
using System.Security.Policy;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models; using BTCPayServer.Abstractions.Models;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Events; using BTCPayServer.Events;
using BTCPayServer.Fido2;
using BTCPayServer.Fido2.Models;
using BTCPayServer.Logging; using BTCPayServer.Logging;
using BTCPayServer.Models;
using BTCPayServer.Models.AccountViewModels; using BTCPayServer.Models.AccountViewModels;
using BTCPayServer.Security;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.U2F; using BTCPayServer.U2F;
using BTCPayServer.U2F.Models; using BTCPayServer.U2F.Models;
using Fido2NetLib;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
using NicolasDorier.RateLimits; using NicolasDorier.RateLimits;
using U2F.Core.Exceptions; using U2F.Core.Exceptions;
@@ -35,7 +36,7 @@ namespace BTCPayServer.Controllers
readonly Configuration.BTCPayServerOptions _Options; readonly Configuration.BTCPayServerOptions _Options;
private readonly BTCPayServerEnvironment _btcPayServerEnvironment; private readonly BTCPayServerEnvironment _btcPayServerEnvironment;
public U2FService _u2FService; public U2FService _u2FService;
private readonly RateLimitService _rateLimitService; private readonly Fido2Service _fido2Service;
private readonly EventAggregator _eventAggregator; private readonly EventAggregator _eventAggregator;
readonly ILogger _logger; readonly ILogger _logger;
@@ -47,8 +48,8 @@ namespace BTCPayServer.Controllers
Configuration.BTCPayServerOptions options, Configuration.BTCPayServerOptions options,
BTCPayServerEnvironment btcPayServerEnvironment, BTCPayServerEnvironment btcPayServerEnvironment,
U2FService u2FService, U2FService u2FService,
RateLimitService rateLimitService, EventAggregator eventAggregator,
EventAggregator eventAggregator) Fido2Service fido2Service)
{ {
_userManager = userManager; _userManager = userManager;
_signInManager = signInManager; _signInManager = signInManager;
@@ -57,7 +58,7 @@ namespace BTCPayServer.Controllers
_Options = options; _Options = options;
_btcPayServerEnvironment = btcPayServerEnvironment; _btcPayServerEnvironment = btcPayServerEnvironment;
_u2FService = u2FService; _u2FService = u2FService;
_rateLimitService = rateLimitService; _fido2Service = fido2Service;
_eventAggregator = eventAggregator; _eventAggregator = eventAggregator;
_logger = Logs.PayServer; _logger = Logs.PayServer;
} }
@@ -125,7 +126,9 @@ namespace BTCPayServer.Controllers
return View(model); return View(model);
} }
if (!await _userManager.IsLockedOutAsync(user) && await _u2FService.HasDevices(user.Id)) var u2fDevices = await _u2FService.HasDevices(user.Id);
var fido2Devices = await _fido2Service.HasCredentials(user.Id);
if (!await _userManager.IsLockedOutAsync(user) && u2fDevices || fido2Devices)
{ {
if (await _userManager.CheckPasswordAsync(user, model.Password)) if (await _userManager.CheckPasswordAsync(user, model.Password))
{ {
@@ -144,7 +147,8 @@ namespace BTCPayServer.Controllers
return View("SecondaryLogin", new SecondaryLoginViewModel() return View("SecondaryLogin", new SecondaryLoginViewModel()
{ {
LoginWith2FaViewModel = twoFModel, LoginWith2FaViewModel = twoFModel,
LoginWithU2FViewModel = await BuildU2FViewModel(model.RememberMe, user) LoginWithU2FViewModel = u2fDevices? await BuildU2FViewModel(model.RememberMe, user) : null,
LoginWithFido2ViewModel = fido2Devices? await BuildFido2ViewModel(model.RememberMe, user): null,
}); });
} }
else else
@@ -210,6 +214,77 @@ namespace BTCPayServer.Controllers
return null; return null;
} }
private async Task<LoginWithFido2ViewModel> BuildFido2ViewModel(bool rememberMe, ApplicationUser user)
{
if (_btcPayServerEnvironment.IsSecure)
{
var r = await _fido2Service.RequestLogin(user.Id);
if (r is null)
{
return null;
}
return new LoginWithFido2ViewModel()
{
Data = r,
UserId = user.Id,
RememberMe = rememberMe
};
}
return null;
}
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> LoginWithFido2(LoginWithFido2ViewModel viewModel, string returnUrl = null)
{
if (!CanLoginOrRegister())
{
return RedirectToAction("Login");
}
ViewData["ReturnUrl"] = returnUrl;
var user = await _userManager.FindByIdAsync(viewModel.UserId);
if (user == null)
{
return NotFound();
}
var errorMessage = string.Empty;
try
{
if (await _fido2Service.CompleteLogin(viewModel.UserId, JObject.Parse(viewModel.Response).ToObject<AuthenticatorAssertionRawResponse>()))
{
await _signInManager.SignInAsync(user, viewModel.RememberMe, "FIDO2");
_logger.LogInformation("User logged in.");
return RedirectToLocal(returnUrl);
}
errorMessage = "Invalid login attempt.";
}
catch (U2fException e)
{
errorMessage = e.Message;
}
ModelState.AddModelError(string.Empty, errorMessage);
viewModel.Response = null;
return View("SecondaryLogin", new SecondaryLoginViewModel()
{
LoginWithFido2ViewModel = viewModel,
LoginWithU2FViewModel = (await _u2FService.HasDevices(user.Id)) ? await BuildU2FViewModel(viewModel.RememberMe, user) : null,
LoginWith2FaViewModel = !user.TwoFactorEnabled
? null
: new LoginWith2faViewModel()
{
RememberMe = viewModel.RememberMe
}
});
}
[HttpPost] [HttpPost]
[AllowAnonymous] [AllowAnonymous]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
@@ -280,7 +355,8 @@ namespace BTCPayServer.Controllers
return View("SecondaryLogin", new SecondaryLoginViewModel() return View("SecondaryLogin", new SecondaryLoginViewModel()
{ {
LoginWith2FaViewModel = new LoginWith2faViewModel { RememberMe = rememberMe }, LoginWith2FaViewModel = new LoginWith2faViewModel { RememberMe = rememberMe },
LoginWithU2FViewModel = (await _u2FService.HasDevices(user.Id)) ? await BuildU2FViewModel(rememberMe, user) : null LoginWithU2FViewModel = (await _u2FService.HasDevices(user.Id)) ? await BuildU2FViewModel(rememberMe, user) : null,
LoginWithFido2ViewModel = (await _fido2Service.HasCredentials(user.Id)) ? await BuildFido2ViewModel(rememberMe, user) : null,
}); });
} }
@@ -326,7 +402,8 @@ namespace BTCPayServer.Controllers
return View("SecondaryLogin", new SecondaryLoginViewModel() return View("SecondaryLogin", new SecondaryLoginViewModel()
{ {
LoginWith2FaViewModel = model, LoginWith2FaViewModel = model,
LoginWithU2FViewModel = (await _u2FService.HasDevices(user.Id)) ? await BuildU2FViewModel(rememberMe, user) : null LoginWithU2FViewModel = (await _u2FService.HasDevices(user.Id)) ? await BuildU2FViewModel(rememberMe, user) : null,
LoginWithFido2ViewModel = (await _fido2Service.HasCredentials(user.Id)) ? await BuildFido2ViewModel(rememberMe, user) : null,
}); });
} }
} }

View File

@@ -0,0 +1,104 @@
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Data;
using BTCPayServer.Fido2;
using BTCPayServer.Models;
using Fido2NetLib;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.U2F.Models
{
[Route("fido2")]
[Authorize]
public class Fido2Controller : Controller
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly Fido2Service _fido2Service;
public Fido2Controller(UserManager<ApplicationUser> userManager, Fido2Service fido2Service)
{
_userManager = userManager;
_fido2Service = fido2Service;
}
[HttpGet("")]
public async Task<IActionResult> List()
{
return View(new Fido2AuthenticationViewModel()
{
Credentials = await _fido2Service.GetCredentials( _userManager.GetUserId(User))
});
}
[HttpGet("{id}/delete")]
public IActionResult Remove(string id)
{
return View("Confirm", new ConfirmModel("Are you sure you want to remove FIDO2 credential?", "Your account will no longer have this credential as an option for MFA.", "Remove"));
}
[HttpPost("{id}/delete")]
public async Task<IActionResult> RemoveP(string id)
{
await _fido2Service.Remove(id, _userManager.GetUserId(User));
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
Html = $"FIDO2 Credentials were removed successfully."
});
return RedirectToAction(nameof(List));
}
[HttpGet("register")]
public async Task<IActionResult> Create(AddFido2CredentialViewModel viewModel)
{
var options = await _fido2Service.RequestCreation(_userManager.GetUserId(User));
if (options is null)
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Error,
Html = $"FIDO2 Credentials could not be saved."
});
return RedirectToAction(nameof(List));
}
ViewData["CredentialName"] = viewModel.Name ?? "";
return View(options);
}
[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))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
Html = $"FIDO2 Credentials were saved successfully."
});
}
else
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Error,
Html = $"FIDO2 Credentials could not be saved."
});
}
return RedirectToAction(nameof(List));
}
}
}

View File

@@ -0,0 +1,206 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
using ExchangeSharp;
using Fido2NetLib;
using Fido2NetLib.Objects;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
namespace BTCPayServer.Fido2
{
public class Fido2Service
{
private static readonly ConcurrentDictionary<string, CredentialCreateOptions> CreationStore =
new ConcurrentDictionary<string, CredentialCreateOptions>();
private static readonly ConcurrentDictionary<string, AssertionOptions> LoginStore =
new ConcurrentDictionary<string, AssertionOptions>();
private readonly ApplicationDbContextFactory _contextFactory;
private readonly IFido2 _fido2;
public Fido2Service(ApplicationDbContextFactory contextFactory, IFido2 fido2)
{
_contextFactory = contextFactory;
_fido2 = fido2;
}
public async Task<CredentialCreateOptions> RequestCreation(string userId)
{
await using var dbContext = _contextFactory.CreateContext();
var user = await dbContext.Users.Include(applicationUser => applicationUser.Fido2Credentials)
.FirstOrDefaultAsync(applicationUser => applicationUser.Id == userId);
if (user == null)
{
return null;
}
// 2. Get user existing keys by username
var existingKeys =
user.Fido2Credentials
.Where(credential => credential.Type == Fido2Credential.CredentialType.FIDO2)
.Select(c => c.GetBlob().Descriptor).ToList();
// 3. Create options
var authenticatorSelection = new AuthenticatorSelection
{
RequireResidentKey = false, UserVerification = UserVerificationRequirement.Preferred
};
var exts = new AuthenticationExtensionsClientInputs()
{
Extensions = true,
UserVerificationIndex = true,
Location = true,
UserVerificationMethod = true,
BiometricAuthenticatorPerformanceBounds = new AuthenticatorBiometricPerfBounds
{
FAR = float.MaxValue, FRR = float.MaxValue
},
};
var options = _fido2.RequestNewCredential(
new Fido2User() {DisplayName = user.UserName, Name = user.UserName, Id = user.Id.ToBytesUTF8()},
existingKeys, authenticatorSelection, AttestationConveyancePreference.None, exts);
// options.Rp = new PublicKeyCredentialRpEntity(Request.Host.Host, options.Rp.Name, "");
CreationStore.AddOrReplace(userId, options);
return options;
}
public async Task<bool> CompleteCreation(string userId, string name, AuthenticatorAttestationRawResponse attestationResponse)
{
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));
// 3. Store the credentials in db
var newCredential = new Fido2Credential()
{
Name = name,
ApplicationUserId = userId
};
newCredential.SetBlob(new Fido2CredentialBlob()
{
Descriptor = new PublicKeyCredentialDescriptor(success.Result.CredentialId),
PublicKey = success.Result.PublicKey,
UserHandle = success.Result.User.Id,
SignatureCounter = success.Result.Counter,
CredType = success.Result.CredType,
AaGuid = success.Result.Aaguid.ToString(),
});
await dbContext.Fido2Credentials.AddAsync(newCredential);
await dbContext.SaveChangesAsync();
CreationStore.Remove(userId, out _);
return true;
}
public async Task<List<Fido2Credential>> GetCredentials(string userId)
{
await using var context = _contextFactory.CreateContext();
return await context.Fido2Credentials
.Where(device => device.ApplicationUserId == userId)
.ToListAsync();
}
public async Task Remove(string id, string userId)
{
await using var context = _contextFactory.CreateContext();
var device = await context.Fido2Credentials.FindAsync( id);
if (device == null || !device.ApplicationUserId.Equals(userId, StringComparison.InvariantCulture))
{
return;
}
context.Fido2Credentials.Remove(device);
await context.SaveChangesAsync();
}
public async Task<bool> HasCredentials(string userId)
{
await using var context = _contextFactory.CreateContext();
return await context.Fido2Credentials.Where(fDevice => fDevice.ApplicationUserId == userId).AnyAsync();
}
public async Task<AssertionOptions> RequestLogin(string userId)
{
await using var dbContext = _contextFactory.CreateContext();
var user = await dbContext.Users.Include(applicationUser => applicationUser.Fido2Credentials)
.FirstOrDefaultAsync(applicationUser => applicationUser.Id == userId);
if (!(user?.Fido2Credentials?.Any() is true))
{
return null;
}
var existingCredentials = user.Fido2Credentials
.Where(credential => credential.Type == Fido2Credential.CredentialType.FIDO2)
.Select(c => c.GetBlob().Descriptor)
.ToList();
var exts = new AuthenticationExtensionsClientInputs()
{
SimpleTransactionAuthorization = "FIDO",
GenericTransactionAuthorization = new TxAuthGenericArg
{
ContentType = "text/plain",
Content = new byte[] { 0x46, 0x49, 0x44, 0x4F }
},
UserVerificationIndex = true,
Location = true,
UserVerificationMethod = true
};
// 3. Create options
var options = _fido2.GetAssertionOptions(
existingCredentials,
UserVerificationRequirement.Discouraged,
exts
);
LoginStore.AddOrReplace(userId, options);
return options;
}
public async Task<bool> CompleteLogin(string userId, AuthenticatorAssertionRawResponse response){
await using var dbContext = _contextFactory.CreateContext();
var user = await dbContext.Users.Include(applicationUser => applicationUser.Fido2Credentials)
.FirstOrDefaultAsync(applicationUser => applicationUser.Id == userId);
if (user == null || !LoginStore.TryGetValue(userId, out var options))
{
return false;
}
var credential = user.Fido2Credentials
.Where(fido2Credential => fido2Credential.Type is Fido2Credential.CredentialType.FIDO2)
.Select(fido2Credential => (fido2Credential, fido2Credential.GetBlob()))
.FirstOrDefault(fido2Credential => fido2Credential.Item2.Descriptor.Id.SequenceEqual(response.Id));
if (credential.Item2 is null)
{
return false;
}
// 5. Make the assertion
var res = await _fido2.MakeAssertionAsync(response, options, credential.Item2.PublicKey,
credential.Item2.SignatureCounter, x => Task.FromResult(true));
// 6. Store the updated counter
credential.Item2.SignatureCounter = res.Counter;
credential.fido2Credential.SetBlob(credential.Item2);
await dbContext.SaveChangesAsync();
LoginStore.Remove(userId, out _);
// 7. return OK to client
return true;
}
}
}

View File

@@ -0,0 +1,30 @@
using System;
using NBXplorer;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Data
{
public static class Fido2Extensions
{
public static Fido2CredentialBlob GetBlob(this Fido2Credential credential)
{
var result = credential.Blob == null
? new Fido2CredentialBlob()
: JObject.Parse(ZipUtils.Unzip(credential.Blob)).ToObject<Fido2CredentialBlob>();
return result;
}
public static bool SetBlob(this Fido2Credential credential, Fido2CredentialBlob descriptor)
{
var original = new Serializer(null).ToString(credential.GetBlob());
var newBlob = new Serializer(null).ToString(descriptor);
if (original == newBlob)
return false;
credential.Type = Fido2Credential.CredentialType.FIDO2;
credential.Blob = ZipUtils.Zip(newBlob);
return true;
}
}
}

View File

@@ -0,0 +1,11 @@
using Fido2NetLib.Objects;
namespace BTCPayServer.U2F.Models
{
public class AddFido2CredentialViewModel
{
public AuthenticatorAttachment? AuthenticatorAttachment { get; set; }
public string Name { get; set; }
}
}

View File

@@ -0,0 +1,10 @@
using System.Collections.Generic;
using BTCPayServer.Data;
namespace BTCPayServer.U2F.Models
{
public class Fido2AuthenticationViewModel
{
public List<Fido2Credential> Credentials { get; set; }
}
}

View File

@@ -0,0 +1,18 @@
using Fido2NetLib;
using Fido2NetLib.Objects;
using Newtonsoft.Json;
namespace BTCPayServer.Data
{
public class Fido2CredentialBlob
{
public PublicKeyCredentialDescriptor Descriptor { get; set; }
[JsonConverter(typeof(Base64UrlConverter))]
public byte[] PublicKey { get; set; }
[JsonConverter(typeof(Base64UrlConverter))]
public byte[] UserHandle { get; set; }
public uint SignatureCounter { get; set; }
public string CredType { get; set; }
public string AaGuid { get; set; }
}
}

View File

@@ -0,0 +1,13 @@
using Fido2NetLib;
namespace BTCPayServer.Fido2.Models
{
public class LoginWithFido2ViewModel
{
public string UserId { get; set; }
public bool RememberMe { get; set; }
public AssertionOptions Data { get; set; }
public string Response { get; set; }
}
}

View File

@@ -1,8 +1,10 @@
using System; using System;
using System.IO; using System.IO;
using System.Linq;
using System.Net; using System.Net;
using BTCPayServer.Configuration; using BTCPayServer.Configuration;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Fido2;
using BTCPayServer.Filters; using BTCPayServer.Filters;
using BTCPayServer.Logging; using BTCPayServer.Logging;
using BTCPayServer.PaymentRequest; using BTCPayServer.PaymentRequest;
@@ -12,9 +14,11 @@ using BTCPayServer.Services.Apps;
using BTCPayServer.Storage; using BTCPayServer.Storage;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Fido2NetLib;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Core;
@@ -78,6 +82,29 @@ namespace BTCPayServer.Hosting
services.AddProviderStorage(); services.AddProviderStorage();
services.AddSession(); services.AddSession();
services.AddSignalR(); services.AddSignalR();
services.AddFido2(options =>
{
options.ServerName = "BTCPay Server";
})
.AddCachedMetadataService(config =>
{
//They'll be used in a "first match wins" way in the order registered
config.AddStaticMetadataRepository();
});
var descriptor =services.Single(descriptor => descriptor.ServiceType == typeof(Fido2Configuration));
services.Remove(descriptor);
services.AddScoped(provider =>
{
var httpContext = provider.GetService<IHttpContextAccessor>();
return new Fido2Configuration()
{
ServerName = "BTCPay Server",
Origin = $"{httpContext.HttpContext.Request.Scheme}://{httpContext.HttpContext.Request.Host}",
ServerDomain = httpContext.HttpContext.Request.Host.Host
};
});
services.AddScoped<Fido2Service>();
var mvcBuilder= services.AddMvc(o => var mvcBuilder= services.AddMvc(o =>
{ {
o.Filters.Add(new XFrameOptionsAttribute("DENY")); o.Filters.Add(new XFrameOptionsAttribute("DENY"));

View File

@@ -1,9 +1,11 @@
using BTCPayServer.Fido2.Models;
using BTCPayServer.U2F.Models; using BTCPayServer.U2F.Models;
namespace BTCPayServer.Models.AccountViewModels namespace BTCPayServer.Models.AccountViewModels
{ {
public class SecondaryLoginViewModel public class SecondaryLoginViewModel
{ {
public LoginWithFido2ViewModel LoginWithFido2ViewModel { get; set; }
public LoginWith2faViewModel LoginWith2FaViewModel { get; set; } public LoginWith2faViewModel LoginWith2FaViewModel { get; set; }
public LoginWithU2FViewModel LoginWithU2FViewModel { get; set; } public LoginWithU2FViewModel LoginWithU2FViewModel { get; set; }
} }

View File

@@ -47,6 +47,7 @@ namespace BTCPayServer
l.AddFilter("Microsoft", LogLevel.Error); l.AddFilter("Microsoft", LogLevel.Error);
l.AddFilter("System.Net.Http.HttpClient", LogLevel.Critical); l.AddFilter("System.Net.Http.HttpClient", LogLevel.Critical);
l.AddFilter("Microsoft.AspNetCore.Antiforgery.Internal", LogLevel.Critical); l.AddFilter("Microsoft.AspNetCore.Antiforgery.Internal", LogLevel.Critical);
l.AddFilter("Fido2NetLib.DistributedCacheMetadataService", LogLevel.Error);
l.AddProvider(new CustomConsoleLogProvider(processor)); l.AddProvider(new CustomConsoleLogProvider(processor));
}) })
.UseStartup<Startup>() .UseStartup<Startup>()

View File

@@ -0,0 +1,32 @@
@model BTCPayServer.Fido2.Models.LoginWithFido2ViewModel
<form id="fidoForm" asp-action="LoginWithFido2" method="post" asp-route-returnUrl="@ViewData["ReturnUrl"]">
<input type="hidden" asp-for="Data"/>
<input type="hidden" asp-for="Response"/>
<input type="hidden" asp-for="UserId"/>
<input type="hidden" asp-for="RememberMe"/>
</form>
<section class="pt-5">
<div>
<div class="row">
<div class="col-lg-12 section-heading">
<h2>FIDO2 Authentication</h2>
<hr class="primary">
<div>
<span id="spinner" class="fa fa-spinner fa-spin float-right ml-3 mr-5 mt-1 fido-running" style="font-size:2.5em"></span>
<p>Insert your security key into your computer's USB port. If it has a button, tap on it.</p>
</div>
<p id="error-message" class="d-none alert alert-danger"></p>
<a id="btn-retry" class="btn btn-secondary d-none" href="javascript:window.location.reload()">Retry</a>
</div>
</div>
</div>
</section>
<script>
// send to server for registering
window.makeAssertionOptions = @Safe.Json(Model.Data);
</script>
<script src="~/js/webauthn/helpers.js" ></script>
<script src="~/js/webauthn/login.js" ></script>

View File

@@ -5,15 +5,15 @@
<section> <section>
<div class="container"> <div class="container">
@if (Model.LoginWith2FaViewModel != null && Model.LoginWithU2FViewModel != null) @if (Model.LoginWith2FaViewModel != null && Model.LoginWithU2FViewModel != null && Model.LoginWithFido2ViewModel != null)
{ {
<div asp-validation-summary="ModelOnly" class="text-danger"></div> <div asp-validation-summary="ModelOnly" class="text-danger"></div>
} }
else if (Model.LoginWith2FaViewModel == null && Model.LoginWithU2FViewModel == null) else if (Model.LoginWith2FaViewModel == null && Model.LoginWithU2FViewModel == null && Model.LoginWithFido2ViewModel == null)
{ {
<div class="row"> <div class="row">
<div class="col-lg-12 section-heading"> <div class="col-lg-12 section-heading">
<h2 class="bg-danger">Both 2FA and U2F Authentication Methods are not available. Please go to the https endpoint</h2> <h2 class="bg-danger">Both 2FA and U2F/FIDO2 Authentication Methods are not available. Please go to the https endpoint.</h2>
<hr class="danger"> <hr class="danger">
</div> </div>
</div> </div>
@@ -33,6 +33,12 @@
<partial name="LoginWithU2F" model="@Model.LoginWithU2FViewModel"/> <partial name="LoginWithU2F" model="@Model.LoginWithU2FViewModel"/>
</div> </div>
} }
@if (Model.LoginWithFido2ViewModel != null)
{
<div class="col-sm-12 col-md-6">
<partial name="LoginWithFido2" model="@Model.LoginWithFido2ViewModel"/>
</div>
}
</div> </div>
</div> </div>
</section> </section>

View File

@@ -0,0 +1,25 @@
@model Fido2NetLib.CredentialCreateOptions
@{
ViewData.SetActivePageAndTitle(ManageNavPages.Fido2, "Register FIDO2 Credentials");
}
<form asp-action="CreateResponse" id="registerForm" >
<input type="hidden" name="data" id="data" />
<input type="hidden" name="name" id="name" value="@(ViewData.ContainsKey("CredentialName")? ViewData["CredentialName"] : string.Empty)" />
</form>
<div class="row">
<div class="col-lg-12 section-heading">
<div>
<span id="spinner" class="fa fa-spinner fa-spin float-right ml-3 mr-5 mt-1 fido-running" style="font-size:2.5em"></span>
<p>Insert your security key into your computer's USB port. If it has a button, tap on it.</p>
</div>
<p id="error-message" class="d-none alert alert-danger"></p>
<a id="btn-retry" class="btn btn-secondary d-none" href="javascript:window.location.reload()">Retry</a>
</div>
</div>
<script>
// send to server for registering
window.makeCredentialOptions = @Json.Serialize(Model);
</script>
<script src="~/js/webauthn/helpers.js" ></script>
<script src="~/js/webauthn/register.js" ></script>

View File

@@ -0,0 +1,44 @@
@model BTCPayServer.U2F.Models.Fido2AuthenticationViewModel
@{
ViewData.SetActivePageAndTitle(ManageNavPages.Fido2, "Registered FIDO2 Credentials");
}
<partial name="_StatusMessage"/>
<table class="table table-lg mb-4">
<thead>
<tr>
<th>Name</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var device in Model.Credentials)
{
<tr>
<td>@(string.IsNullOrEmpty(device.Name)? "Unnamed FIDO2 credential": device.Name)</td>
<td class="text-right">
<a asp-action="Remove" asp-route-id="@device.Id">Remove</a>
</td>
</tr>
}
@if (!Model.Credentials.Any())
{
<tr>
<td colspan="2" class="text-center h5 py-2">
No registered credentials
</td>
</tr>
}
</tbody>
</table>
<form asp-action="Create" method="get" class="form-inline">
<div class="form-group">
<input type="text" class="form-control" name="Name" placeholder="New Credential Name"/>
<button type="submit" class="btn btn-primary ml-2">
<span class="fa fa-plus"></span>
Add New Credential
</button>
</div>
</form>

View File

@@ -0,0 +1 @@
@using BTCPayServer.Views.Manage

View File

@@ -0,0 +1,6 @@
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewBag.MainTitle = "Manage your account";
ViewData["NavPartialName"] = "../Manage/_Nav";
}

View File

@@ -2,6 +2,6 @@ namespace BTCPayServer.Views.Manage
{ {
public enum ManageNavPages public enum ManageNavPages
{ {
Index, ChangePassword, TwoFactorAuthentication, U2F, APIKeys, Notifications Index, ChangePassword, TwoFactorAuthentication, U2F, APIKeys, Notifications, Fido2
} }
} }

View File

@@ -1,12 +1,13 @@
@inject SignInManager<ApplicationUser> SignInManager @inject SignInManager<ApplicationUser> SignInManager
<div class="nav flex-column mb-4">
<a id="@ManageNavPages.Index.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.Index)" asp-action="Index">Profile</a>
<a id="@ManageNavPages.ChangePassword.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.ChangePassword)" asp-action="ChangePassword">Password</a>
<a id="@ManageNavPages.TwoFactorAuthentication.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.TwoFactorAuthentication)" asp-action="TwoFactorAuthentication">Two-factor authentication</a>
<a id="@ManageNavPages.U2F.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.U2F)" asp-action="U2FAuthentication">U2F Authentication</a>
<a id="@ManageNavPages.APIKeys.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.APIKeys)" asp-action="APIKeys">API Keys</a> <a id="@ManageNavPages.APIKeys.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.APIKeys)" asp-action="APIKeys">API Keys</a>
<a id="@ManageNavPages.Notifications.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.Notifications)" asp-action="NotificationSettings">Notifications</a> <div class="nav flex-column mb-4">
<a id="@ManageNavPages.Index.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.Index)" asp-controller="Manage" asp-action="Index">Profile</a>
<a id="@ManageNavPages.ChangePassword.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.ChangePassword)" asp-controller="Manage" asp-action="ChangePassword">Password</a>
<a id="@ManageNavPages.TwoFactorAuthentication.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.TwoFactorAuthentication)" asp-controller="Manage" asp-action="TwoFactorAuthentication">Two-factor authentication</a>
<a id="@ManageNavPages.U2F.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.U2F)" asp-controller="Manage" asp-action="U2FAuthentication">U2F Authentication</a>
<a id="@ManageNavPages.Fido2.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.Fido2)" asp-action="List" asp-controller="Fido2">FIDO2 Authentication</a>
<a id="@ManageNavPages.APIKeys.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.APIKeys)" asp-controller="Manage" asp-action="APIKeys">API Keys</a>
<a id="@ManageNavPages.Notifications.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.Notifications)" asp-controller="Manage" asp-action="NotificationSettings">Notifications</a>
<vc:ui-extension-point location="user-nav"/> <vc:ui-extension-point location="user-nav"/>
</div> </div>

View File

@@ -0,0 +1,109 @@
coerceToArrayBuffer = function (thing, name) {
if (typeof thing === "string") {
// base64url to base64
thing = thing.replace(/-/g, "+").replace(/_/g, "/");
// base64 to Uint8Array
var str = window.atob(thing);
var bytes = new Uint8Array(str.length);
for (var i = 0; i < str.length; i++) {
bytes[i] = str.charCodeAt(i);
}
thing = bytes;
}
// Array to Uint8Array
if (Array.isArray(thing)) {
thing = new Uint8Array(thing);
}
// Uint8Array to ArrayBuffer
if (thing instanceof Uint8Array) {
thing = thing.buffer;
}
// error if none of the above worked
if (!(thing instanceof ArrayBuffer)) {
throw new TypeError("could not coerce '" + name + "' to ArrayBuffer");
}
return thing;
};
coerceToBase64Url = function (thing) {
// Array or ArrayBuffer to Uint8Array
if (Array.isArray(thing)) {
thing = Uint8Array.from(thing);
}
if (thing instanceof ArrayBuffer) {
thing = new Uint8Array(thing);
}
// Uint8Array to base64
if (thing instanceof Uint8Array) {
var str = "";
var len = thing.byteLength;
for (var i = 0; i < len; i++) {
str += String.fromCharCode(thing[i]);
}
thing = window.btoa(str);
}
if (typeof thing !== "string") {
throw new Error("could not coerce to string");
}
// base64 to base64url
// NOTE: "=" at the end of challenge is optional, strip it off here
thing = thing.replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, "");
return thing;
};
// HELPERS
function showErrorAlert(message, error) {
let footermsg = '';
if (error) {
footermsg = 'exception:' + error.toString();
}
console.error(message, footermsg);
document.getElementById("btn-retry").classList.remove("d-none");
document.getElementById("error-message").textContent = message;
for(let el of document.getElementsByClassName("fido-running")){
el.classList.add("d-none");
}
document.getElementById("error-message").classList.remove("d-none");
}
function detectFIDOSupport() {
if (window.PublicKeyCredential === undefined ||
typeof window.PublicKeyCredential !== "function") {
//$('#register-button').attr("disabled", true);
//$('#login-button').attr("disabled", true);
var el = document.getElementById("error-message");
el.textContent = "Your browser does not support FIDO2/WebAuthN";
el.classList.remove("d-none");
return false;
}
return true;
}
/**
*
* Get a form value
* @param {any} selector
*/
function value(selector) {
var el = document.querySelector(selector);
if (el.type === "checkbox") {
return el.checked;
}
return el.value;
}

View File

@@ -0,0 +1,66 @@
if (detectFIDOSupport() && makeAssertionOptions){
login(makeAssertionOptions);
}
async function login(makeAssertionOptions) {
console.log("Assertion Options Object", makeAssertionOptions);
const challenge = makeAssertionOptions.challenge.replace(/-/g, "+").replace(/_/g, "/");
makeAssertionOptions.challenge = Uint8Array.from(atob(challenge), c => c.charCodeAt(0));
// fix escaping. Change this to coerce
makeAssertionOptions.allowCredentials.forEach(function (listItem) {
var fixedId = listItem.id.replace(/\_/g, "/").replace(/\-/g, "+");
listItem.id = Uint8Array.from(atob(fixedId), c => c.charCodeAt(0));
});
console.log("Assertion options", makeAssertionOptions);
let credential;
try {
credential = await navigator.credentials.get({ publicKey: makeAssertionOptions })
} catch (err) {
showErrorAlert(err.message ? err.message : err);
return;
}
try {
await verifyAssertionWithServer(credential);
} catch (e) {
showErrorAlert("Could not verify assertion", e);
}
}
/**
* Sends the credential to the the FIDO2 server for assertion
* @param {any} assertedCredential
*/
async function verifyAssertionWithServer(assertedCredential) {
// Move data into Arrays incase it is super long
let authData = new Uint8Array(assertedCredential.response.authenticatorData);
let clientDataJSON = new Uint8Array(assertedCredential.response.clientDataJSON);
let rawId = new Uint8Array(assertedCredential.rawId);
let sig = new Uint8Array(assertedCredential.response.signature);
const data = {
id: assertedCredential.id,
rawId: coerceToBase64Url(rawId),
type: assertedCredential.type,
extensions: assertedCredential.getClientExtensionResults(),
response: {
authenticatorData: coerceToBase64Url(authData),
clientDataJson: coerceToBase64Url(clientDataJSON),
signature: coerceToBase64Url(sig)
}
};
document.getElementById("Response").value = JSON.stringify(data);
document.getElementById("fidoForm").submit();
}

View File

@@ -0,0 +1,67 @@
if (detectFIDOSupport() && makeCredentialOptions){
register(makeCredentialOptions);
}
async function register(makeCredentialOptions) {
console.log("Credential Options Object", makeCredentialOptions);
// Turn the challenge back into the accepted format of padded base64
makeCredentialOptions.challenge = coerceToArrayBuffer(makeCredentialOptions.challenge);
// Turn ID into a UInt8Array Buffer for some reason
makeCredentialOptions.user.id = coerceToArrayBuffer(makeCredentialOptions.user.id);
makeCredentialOptions.excludeCredentials = makeCredentialOptions.excludeCredentials.map((c) => {
c.id = coerceToArrayBuffer(c.id);
return c;
});
if (makeCredentialOptions.authenticatorSelection.authenticatorAttachment == null) makeCredentialOptions.authenticatorSelection.authenticatorAttachment = undefined;
console.log("Credential Options Formatted", makeCredentialOptions);
console.log("Creating PublicKeyCredential...");
let newCredential;
try {
newCredential = await navigator.credentials.create({
publicKey: makeCredentialOptions
});
} catch (e) {
var msg = "Could not create credentials in browser. Probably because the username is already registered with your authenticator. Please change username or authenticator."
showErrorAlert(msg, e);
return;
}
console.log("PublicKeyCredential Created", newCredential);
try {
registerNewCredential(newCredential);
} catch (e) {
showErrorAlert(err.message ? err.message : err);
}
}
// This should be used to verify the auth data with the server
async function registerNewCredential(newCredential) {
// Move data into Arrays incase it is super long
let attestationObject = new Uint8Array(newCredential.response.attestationObject);
let clientDataJSON = new Uint8Array(newCredential.response.clientDataJSON);
let rawId = new Uint8Array(newCredential.rawId);
const data = {
id: newCredential.id,
rawId: coerceToBase64Url(rawId),
type: newCredential.type,
extensions: newCredential.getClientExtensionResults(),
response: {
AttestationObject: coerceToBase64Url(attestationObject),
clientDataJson: coerceToBase64Url(clientDataJSON)
}
};
document.getElementById("data").value = JSON.stringify(data);
document.getElementById("registerForm").submit();
}