fbpx

08. PART 1 IdentityServer4 MFA – FIDO2 (YubiKey 5)

You can find the project here.

Why FIDO2

Two words. Unphishable authentication. Let’s explain a bit more. Authentication can be intercepted in a reverse proxy scenario. To get a user in a reverse proxy scenario an attacker can send an email looking like a legitimate email from a service the user is frequently using. Let’s say it’s YouTube and the user is a famous content creator. A spear-phishing campaign can be launched against YouTube creators and the user will receive an email from YouTube with a link in it as a call to action. The email is not actually from YouTube and the link will not take you to “YouTube.com” but to a similar-looking domain like “Y0uTube.com”.

An even better strategy that attackers use is to register domains with letters from another alphabet that looks pretty much the same to a human eye but it is a different character to a machine so it is a unique domain name. The user clicks on a link in the email which leads to a waiting reverse proxy that serves the YouTube page and it looks identical to the original because it is the original. The reverse proxy connects to “YouTube.com” and it serves all content back to the user. So now when using a single factor like username and password when the authentication ceremony is completed the attacker now has the user’s credentials, issued tokens or code to get the tokens and any issued web app auth cookies. The attacker has everything needed to impersonate the user and log in to the service as the legitimate user. Image the havoc the attacker could create to this user’s account. The worst thing about this kind of attack is that it works with MFA too.

If the user was using TOTP as a second factor, as explained in my previous tutorial the attack would still go through and the attacker would have user’s credentials, one-time-password, issued tokens or code to get the tokens and any issued web app auth cookies. There is little you can do against a reverse proxy. Legitimate reverse proxy scenarios like firewalls, load balancers, web servers, etc exist and are used every day. The problem is the malicious reverse proxies. Phishing brought the user to a reverse proxy and the user gave the identity away. The solution is FIDO2 as a second factor or even as a single factor aka passwordless.

The FIDO2 has two parts, the web authentication API called WebAuthn and CTAP client-to-authenticator protocol. CTAP talks over USB or NFC to your hardware authenticator (like a YubiKey 5). WebAuthn is used by JavaScript in a browser to communicate with the IdentityServer4 to authenticate. It works by a server issuing a challenge. Client, in this case, the browser, gets it, adds client data to it and sends it to a hardware authenticator to sign in. It all boils down to plain old asymmetric encryption with a pair of public and private keys. The private key never leaves the hardware authenticator and is used for signing the client data.

Signed client data is sent back to the server where the challenge is checked against the issued challenge and client data is checked for origin. If the origin in the client data is different from the server origin the authentication will fail. This makes phishing impossible as a malicious reverse proxy will have a different origin (domain) than a legitimate server. So, in this case, the only thing the attacker gets is maybe first factor credentials but because the authentication failed the tokens and auth cookie are not issued. The attacker is unable to impersonate the user. This is really hard for the attacker but really easy for the user. All the user has to do is plug in a key in the USB port and tap it. Users can leave the hardware authenticator plugged in all the time and a malicious program could automatically sign anything with it if the user action wasn’t required on a hardware authenticator itself. More expensive authenticators used in banks, for example, have a PIN, fingerprint, etc instead of just touch confirmation.

This allows for having MFA on a hardware key itself. RSA as one factor and PIN as another. FIDO2 enables passwordless authentication. This means we can use a hardware authenticator as the only factor without username and password. This is possible because having an RSA hardware authenticator as a factor is much more secure than using credentials or even TOTP. Unphishable authentication is a holy grail of the Identity industry.

Careful readers might have noticed that RSA public/private key encryption is used for FIDO2, the same as for SSL/TLS. Phishing/Reverse Proxy attack works even with SSL enabled because a browser will accept any SSL certificate as long as it’s issued by a trusted CA. That means an attacker can easily buy the SSL certificate and put it on a reverse proxy server. SSL offloading is performed on a reverse proxy and the attacker has a clear-text view of both the traffic going in and out of it. With FIDO2 in the reverse proxy scenario, the attacker can use the hardware authenticator public key to see the signed assertion but can’t modify it as it doesn’t have the private key to sign in again. If the reverse proxy signs the assertion using another private key the public key of the hardware authenticator, which is saved on the server during the MFA enrollment process, wouldn’t be able to decrypt it. There is no known way for the attacker using phishing and a malicious reverse proxy to defeat FIDO2.

Enough talking let’s start adding FIDO2 to our “Quickstart” IdentityServer4 project from a previous tutorial. We are going to add FIDO2 to ASP.NET Core Identity as it takes care of the users and authenticating the users against the user store for the IdentityServer4.

To be able to enroll and login with FIDO2 you will need a FIDO2 hardware authenticator device. I will be using YubiKey 5, it should work the same with any FIDO2 authenticator.

I will continue with the “Quickstart” solution from my previous 2FA TOTP tutorial. Open the project in Visual Studio and let’s start.

 

Libraries and resources

First, add the “Fido2” NuGet package like so:

We will add four JS scripts in total. Two scripts “mfa.login.js” and “mfa.register.js” take care of invoking WebAuthn API for logging in with FIDO2 and enrolling a FIDO2 authenticator. The other two scripts “helpers.js” and “instant.js” take care of some helper functions for data manipulation and on-screen notifications when working with FIDO2 authenticator. Let’s navigate to “wwwroot/js” and start adding scripts:

Create “mfa.login.js” like so:

document.getElementById('signin').addEventListener('submit', handleSignInSubmit);

async function handleSignInSubmit(event) {
    event.preventDefault();

    // prepare form post data
    var formData = new FormData();

    // send to server for registering
    let makeAssertionOptions;
    try {
        var res = await fetch('/assertionOptions', {
            method: 'POST', // or 'PUT'
            body: formData, // data can be `string` or {object}!
            headers: {
                'Accept': 'application/json'
            }
        });

        makeAssertionOptions = await res.json();
    } catch (e) {
        showErrorAlert("Request to server failed", e);
    }

    // console.log("Assertion Options Object", makeAssertionOptions);

    // show options error to user
    if (makeAssertionOptions.status !== "ok") {
        console.log("Error creating assertion options");
        console.log(makeAssertionOptions.errorMessage);
        showErrorAlert(makeAssertionOptions.errorMessage);
        return;
    }

    const challenge = makeAssertionOptions.challenge.replace(/-/g, "+").replace(/_/g, "/");
    makeAssertionOptions.challenge = Uint8Array.from(atob(challenge), c => c.charCodeAt(0));

    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);

    Swal.fire({
        title: 'Logging In...',
        text: 'Tap your security key to login.',
        imageUrl: "/images/securitykey.min.svg",
        showCancelButton: true,
        showConfirmButton: false,
        focusConfirm: false,
        focusCancel: false
    });

    // ask browser for credentials (browser will ask connected authenticators)
    let credential;
    try {
        credential = await navigator.credentials.get({ publicKey: makeAssertionOptions })
    } catch (err) {
        showErrorAlert(err.message ? err.message : err);
    }

    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)
        }
    };

    let response;
    try {
        let res = await fetch("/makeAssertion", {
            method: 'POST', // or 'PUT'
            body: JSON.stringify(data), // data can be `string` or {object}!
            headers: {
                'Accept': 'application/json',
                'Content-Type': 'application/json'
            }
        });

        response = await res.json();
    } catch (e) {
        showErrorAlert("Request to server failed", e);
        throw e;
    }

    // console.log("Assertion Object", response);

    // show error
    if (response.status !== "ok") {
        console.log("Error doing assertion");
        console.log(response.errorMessage);
        showErrorAlert(response.errorMessage);
        return;
    }

    // show success message
    await Swal.fire({
        title: 'Logged In!',
        text: 'You\'re logged in successfully.',
        type: 'success',
        timer: 2000
    });

    // get redirect uri
    var urlParams = new URLSearchParams(window.location.search);
    if (urlParams.has('ReturnUrl')) {
        let redirectUri = urlParams.get('ReturnUrl');
        redirectUri = redirectUri.replace('~/', '/');
        window.location.href = redirectUri;
    } else {
        window.location.href = "/";
    }
}

Create “mfa.register.js” like so:

document.getElementById('register').addEventListener('submit', handleRegisterSubmit);

async function handleRegisterSubmit(event) {
    event.preventDefault();

    let username = this.username.value;

    // possible values: none, direct, indirect
    let attestation_type = "none";
    // possible values: <empty>, platform, cross-platform
    let authenticator_attachment = "";

    // possible values: preferred, required, discouraged
    let user_verification = "preferred";

    // possible values: true,false
    let require_resident_key = false;

    // prepare form post data
    var data = new FormData();
    data.append('username', username);
    data.append('attType', attestation_type);
    data.append('authType', authenticator_attachment);
    data.append('userVerification', user_verification);
    data.append('requireResidentKey', require_resident_key);

    // send to server for registering
    let makeCredentialOptions;
    try {
        makeCredentialOptions = await fetchMakeCredentialOptions(data);
    } catch (e) {
        console.error(e);
        let msg = "Something wen't really wrong";
        showErrorAlert(msg);
    }

    // console.log("Credential Options Object", makeCredentialOptions);

    if (makeCredentialOptions.status !== "ok") {
        console.log("Error creating credential options");
        console.log(makeCredentialOptions.errorMessage);
        showErrorAlert(makeCredentialOptions.errorMessage);
        return;
    }

    // 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);

    Swal.fire({
        title: 'Registering...',
        text: 'Tap your security key to finish registration.',
        imageUrl: "/images/securitykey.min.svg",
        showCancelButton: true,
        showConfirmButton: false,
        focusConfirm: false,
        focusCancel: false
    });


    // console.log("Creating PublicKeyCredential...");
    // console.log(navigator);
    // console.log(navigator.credentials);
    // console.log(makeCredentialOptions);
    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.";
        console.error(msg, e);
        showErrorAlert(msg, e);
    }

    // console.log("PublicKeyCredential Created", newCredential);

    try {
        registerNewCredential(newCredential);
    } catch (e) {
        showErrorAlert(err.message ? err.message : err);
    }
}

async function fetchMakeCredentialOptions(formData) {
    let response = await fetch('/makeCredentialOptions', {
        method: 'POST', // or 'PUT'
        body: formData, // data can be `string` or {object}!
        headers: {
            'Accept': 'application/json'
        }
    });

    let data = await response.json();

    return data;
}


// 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)
        }
    };

    let response;
    try {
        response = await registerCredentialWithServer(data);
    } catch (e) {
        showErrorAlert(e);
    }

    // console.log("Credential Object", response);

    // show error
    if (response.status !== "ok") {
        console.log("Error creating credential");
        console.log(response.errorMessage);
        showErrorAlert(response.errorMessage);
        return;
    }

    // show success 
    Swal.fire({
        title: 'Registration Successful!',
        text: 'You\'ve registered successfully.',
        type: 'success',
        timer: 2000
    });

    window.location.href = "/Identity/Account/Manage/ShowRecoveryCodes";
}

async function registerCredentialWithServer(formData) {
    let response = await fetch('/makeCredential', {
        method: 'POST', // or 'PUT'
        body: JSON.stringify(formData), // data can be `string` or {object}!
        headers: {
            'Accept': 'application/json',
            'Content-Type': 'application/json'
        }
    });

    let data = await response.json();

    return data;
}

Create “helpers.js” like so:

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();
    }
    Swal.fire({
        type: 'error',
        title: 'Error',
        text: message,
        footer: footermsg
        //footer: '<a href>Why do I have this issue?</a>'
    })
}

function detectFIDOSupport() {
    if (window.PublicKeyCredential === undefined ||
        typeof window.PublicKeyCredential !== "function") {
        //$('#register-button').attr("disabled", true);
        //$('#login-button').attr("disabled", true);
        var el = document.getElementById("notSupportedWarning");
        if (el) {
            el.style.display = 'block';
        }
        return;
    }
}

/**
 * 
 * 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;
}

Create “instant.js” like so:

/*! instant.page v1.1.0 - (C) 2019 Alexandre Dieulot - https://instant.page/license */

let urlToPreload
let mouseoverTimer
let lastTouchTimestamp

const prefetcher = document.createElement('link')
const isSupported = prefetcher.relList && prefetcher.relList.supports && prefetcher.relList.supports('prefetch')
const allowQueryString = 'instantAllowQueryString' in document.body.dataset

if (isSupported) {
    prefetcher.rel = 'prefetch'
    document.head.appendChild(prefetcher)

    const eventListenersOptions = {
        capture: true,
        passive: true,
    }
    document.addEventListener('touchstart', touchstartListener, eventListenersOptions)
    document.addEventListener('mouseover', mouseoverListener, eventListenersOptions)
}

function touchstartListener(event) {
    /* Chrome on Android calls mouseover before touchcancel so `lastTouchTimestamp`
     * must be assigned on touchstart to be measured on mouseover. */
    lastTouchTimestamp = performance.now()

    const linkElement = event.target.closest('a')

    if (!linkElement) {
        return
    }

    if (!isPreloadable(linkElement)) {
        return
    }

    linkElement.addEventListener('touchcancel', touchendAndTouchcancelListener, { passive: true })
    linkElement.addEventListener('touchend', touchendAndTouchcancelListener, { passive: true })

    urlToPreload = linkElement.href
    preload(linkElement.href)
}

function touchendAndTouchcancelListener() {
    urlToPreload = undefined
    stopPreloading()
}

function mouseoverListener(event) {
    if (performance.now() - lastTouchTimestamp < 1100) {
        return
    }

    const linkElement = event.target.closest('a')

    if (!linkElement) {
        return
    }

    if (!isPreloadable(linkElement)) {
        return
    }

    linkElement.addEventListener('mouseout', mouseoutListener, { passive: true })

    urlToPreload = linkElement.href

    mouseoverTimer = setTimeout(() => {
        preload(linkElement.href)
        mouseoverTimer = undefined
    }, 65)
}

function mouseoutListener(event) {
    if (event.relatedTarget && event.target.closest('a') == event.relatedTarget.closest('a')) {
        return
    }

    if (mouseoverTimer) {
        clearTimeout(mouseoverTimer)
        mouseoverTimer = undefined
    }
    else {
        urlToPreload = undefined
        stopPreloading()
    }
}

function isPreloadable(linkElement) {
    if (urlToPreload == linkElement.href) {
        return
    }

    const urlObject = new URL(linkElement.href)

    if (urlObject.origin != location.origin) {
        return
    }

    if (!allowQueryString && urlObject.search && !('instant' in linkElement.dataset)) {
        return
    }

    if (urlObject.hash && urlObject.pathname + urlObject.search == location.pathname + location.search) {
        return
    }

    if ('noInstant' in linkElement.dataset) {
        return
    }

    return true
}

function preload(url) {
    prefetcher.href = url
}

function stopPreloading() {
    /* The spec says an empty string should abort the prefetching
    * but Firefox 64 interprets it as a relative URL to prefetch. */
    prefetcher.removeAttribute('href')
}

Navigate to “wwwroot/images” and add an SVG image that will be displayed to the user along with the instructions when using the FIDO2 authenticator.

Create “securitykey.min.svg” like so:

<svg xmlns="http://www.w3.org/2000/svg" width="783.478" height="321.091" viewBox="0 0 734.51033 301.02249"><g transform="translate(71.951 -314.708)"><rect width="285.714" height="122.857" x="105.714" y="403.791" ry="6" fill="#fff" stroke="#446cb3"/><path fill="#fff" stroke="#446cb3" d="M391.465 416.976h16.162v93.944h-16.162z"/><circle cx="246.429" cy="462.362" r="40" fill="#446cb3" stroke="#446cb3"/><circle r="14.603" cy="463.791" cx="125.714" fill="#fff" stroke="#446cb3"/><path fill="#f9bf3b" fill-opacity=".497" stroke="#f9bf3b" stroke-width="1.223" d="M408.861 428.802h62.991v14.471h-62.991zM408.861 484.958h62.991v14.471h-62.991z"/><path fill="#f9bf3b" fill-opacity=".497" stroke="#f9bf3b" stroke-width="1.078" d="M408.789 447.653h48.493v14.615h-48.493zM408.789 466.106h48.493v14.615h-48.493z"/><path fill="none" stroke="#446cb3" d="M407.784 417.011h77.11v93.943h-77.11z"/><path d="M236.034 451.289a10.094 10.094 0 0 0-10.093 10.094 10.094 10.094 0 0 0 10.093 10.093 10.094 10.094 0 0 0 10.097-10.093 10.094 10.094 0 0 0-10.097-10.094zm0 3.73a6.365 6.365 0 0 1 6.367 6.364 6.365 6.365 0 0 1-6.367 6.364 6.365 6.365 0 0 1-6.364-6.364 6.365 6.365 0 0 1 6.364-6.365z" fill="#fff" stroke="#446cb3" stroke-width=".691"/><path fill="#fff" d="M245.102 459.194h25.987v3.939h-25.987z"/><path fill="#fff" d="M266.719 462.841h2.669v5.463h-2.669zM259.949 462.841h2.669v4.334h-2.669z"/><circle r="40" cy="462.362" cx="246.429" fill="none" stroke="#446cb3"><animate attributeType="SVG" attributeName="r" begin="0s" dur="1.5s" repeatCount="indefinite" from="10%" to="25%"/><animate attributeType="CSS" attributeName="stroke-width" begin="0s" dur="1.5s" repeatCount="indefinite" from="1%" to="0%"/><animate attributeType="CSS" attributeName="opacity" begin="0s" dur="1.5s" repeatCount="indefinite" from="1" to="0"/></circle></g></svg>

Navigate to “Views/Shared” and open “_Layout.cshtml” view. Add styles and scripts into “head” element like so:

<head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>IdentityServer4</title>
    <link rel="icon" type="image/x-icon" href="~/favicon.ico" />
    <link rel="shortcut icon" type="image/x-icon" href="~/favicon.ico" />
    <link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.css" />
    <link rel="stylesheet" href="~/css/site.css" />
    <link href="https://fonts.googleapis.com/css?family=Work+Sans" rel="stylesheet">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.11.0/umd/popper.min.js" integrity="sha384-b/U6ypiBEHpOf/4+1nzFpr53nxSS+GLCkfwBdFNTxtclqqenISfwAzpKaMNFNmj4" crossorigin="anonymous"></script>
    <script type="text/javascript" src="https://cdn.jsdelivr.net/npm/sweetalert2"></script>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/limonte-sweetalert2/6.10.1/sweetalert2.min.css" />
    <script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>
</head>

We will modify the dropdown menu in navbar to add the “Manage account” option so a user can easily access it. Changes are in the “body” element like so:

<ul class="nav navbar-nav">
	<li class="dropdown">
		<a href="#" class="dropdown-toggle" data-toggle="dropdown">@name <b class="caret"></b></a>
		<ul class="dropdown-menu">
			<li><a href="~/Identity/Account/Manage">Manage account</a></li>
			@if (!string.IsNullOrWhiteSpace(name))
			{
				<li><a asp-action="Logout" asp-controller="Account">Logout</a></li>
			}
		</ul>
	</li>
</ul>

That concludes the libraries and resources we need to add at the moment. We will add razor pages later but let’s focus on database changes for a moment.

 

Database changes

Our current implementation of ASP.NET Core Identity doesn’t support FIDO2 out of the box. We need to persist the public credentials of the enrolled authenticators in the database in order to be able to validate the authenticator assertions during the authentication ceremony. We will create a new entity called “FidoStoredCredential” along with the service to easily manipulate the stored credentials. Navigate to the “Models” folder and add the “FidoStoredCredential.cs” class like so:

using Fido2NetLib.Objects;
using Newtonsoft.Json;
using System;
using System.ComponentModel.DataAnnotations.Schema;

namespace IdentityServer.Models
{
    public class FidoStoredCredential
    {
        public string Username { get; set; }
        public byte[] UserId { get; set; }
        public byte[] PublicKey { get; set; }
        public byte[] UserHandle { get; set; }
        public uint SignatureCounter { get; set; }
        public string CredType { get; set; }
        public DateTime RegDate { get; set; }
        public Guid AaGuid { get; set; }

        [NotMapped]
        public PublicKeyCredentialDescriptor Descriptor
        {
            get { return string.IsNullOrWhiteSpace(DescriptorJson) ? null : JsonConvert.DeserializeObject<PublicKeyCredentialDescriptor>(DescriptorJson); }
            set { DescriptorJson = JsonConvert.SerializeObject(value); }
        }
        public string DescriptorJson { get; set; }
    }
}

Now navigate to “Services” folder and add “Fido2Storage.cs” class like so:

using Fido2NetLib;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using IdentityServer.Models;

namespace IdentityServer.Services
{
    public class Fido2Storage
    {
        private readonly IdentityDbContext _identityDbContext;

        public Fido2Storage(IdentityDbContext identityDbContext)
        {
            _identityDbContext = identityDbContext;
        }

        public async Task<List<FidoStoredCredential>> GetCredentialsByUsername(string username)
        {
            return await _identityDbContext.FidoStoredCredential.Where(c => c.Username == username).ToListAsync();
        }

        public async Task RemoveCredentialsByUsername(string username)
        {
            var item = await _identityDbContext.FidoStoredCredential.Where(c => c.Username == username).FirstOrDefaultAsync();
            if (item != null)
            {
                _identityDbContext.FidoStoredCredential.Remove(item);
                await _identityDbContext.SaveChangesAsync();
            }
        }

        public async Task<FidoStoredCredential> GetCredentialById(byte[] id)
        {
            var credentialIdString = Base64Url.Encode(id);
            //byte[] credentialIdStringByte = Base64Url.Decode(credentialIdString);

            var cred = await _identityDbContext.FidoStoredCredential
                .Where(c => c.DescriptorJson.Contains(credentialIdString)).FirstOrDefaultAsync();

            return cred;
        }

        public Task<List<FidoStoredCredential>> GetCredentialsByUserHandleAsync(byte[] userHandle)
        {
            return Task.FromResult(_identityDbContext.FidoStoredCredential.Where(c => c.UserHandle.SequenceEqual(userHandle)).ToList());
        }

        public async Task UpdateCounter(byte[] credentialId, uint counter)
        {
            var credentialIdString = Base64Url.Encode(credentialId);
            //byte[] credentialIdStringByte = Base64Url.Decode(credentialIdString);

            var cred = await _identityDbContext.FidoStoredCredential
                .Where(c => c.DescriptorJson.Contains(credentialIdString)).FirstOrDefaultAsync();

            cred.SignatureCounter = counter;
            await _identityDbContext.SaveChangesAsync();
        }

        public async Task AddCredentialToUser(Fido2User user, FidoStoredCredential credential)
        {
            credential.UserId = user.Id;
            _identityDbContext.FidoStoredCredential.Add(credential);
            await _identityDbContext.SaveChangesAsync();
        }

        public async Task<List<Fido2User>> GetUsersByCredentialIdAsync(byte[] credentialId)
        {
            var credentialIdString = Base64Url.Encode(credentialId);
            //byte[] credentialIdStringByte = Base64Url.Decode(credentialIdString);

            var cred = await _identityDbContext.FidoStoredCredential
                .Where(c => c.DescriptorJson.Contains(credentialIdString)).FirstOrDefaultAsync();

            if (cred == null)
            {
                return new List<Fido2User>();
            }

            return await _identityDbContext.Users
                    .Where(u => Encoding.UTF8.GetBytes(u.UserName)
                    .SequenceEqual(cred.UserId))
                    .Select(u => new Fido2User
                    {
                        DisplayName = u.UserName,
                        Name = u.UserName,
                        Id = Encoding.UTF8.GetBytes(u.UserName) // byte representation of userID is required
                    }).ToListAsync();
        }
    }
}

You will notice that our “IdentityDbContext” doesn’t contain a definition for “FidoStoredCredential” as “Fido2Storage” underlines errors in red. Let’s fix that. Navigate to “Data” folder and open “IdentityDbContext.cs”. Let’s add “FidoStoredCredentials” entity like so:

using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace IdentityServer.Models
{
    public class IdentityDbContext : IdentityDbContext<ApplicationUser>
    {
        public IdentityDbContext(DbContextOptions<IdentityDbContext> options)
            : base(options)
        {
        }

        public DbSet<FidoStoredCredential> FidoStoredCredential { get; set; }

        protected override void OnModelCreating(ModelBuilder builder)
        {
            builder.Entity<FidoStoredCredential>().HasKey(m => m.Username);
            base.OnModelCreating(builder);
        }
    }
}

Let’s build to the solution to verify there are no more errors so we can do the code-first migration and update the database with the new table for FIDO2 stored credentials. Open the Package Manager Console and create the migration and update the database like so:

Add-Migration FidoStoredCredentialAdded -c IdentityDbContext -o Data/Migrations/AspNetIdentity/AspNetIdentityDb
Update-Database -Context IdentityDbContext

This concludes the database changes. Now we have a new entity for FIDO2 credentials, we have a service for easily working with FIDO2 data and a new table in the database called “dbo.FidoStoredCredential” to persist FIDO2 data.

Let’s start working on the razor pages next. Remember, ASP.NET Core Identity doesn’t come with out-of-the-box support for FIDO2 so same as we needed to extend the data store to support it, we need to extend the code to add missing features too.

 

Continue to PART 2 of this tutorial.

 

You can find the project here.

Support

For direct assistance schedule a technical meeting with Ivan to talk about your requirements.
For a general overview of our services and a live demo schedule a meeting with Maja.

Comments

Comments are closed.