ASP.NET Core 5.0 - FIDO2 Credential Get

Ken Haggerty
Created 08/10/2021 - Updated 08/16/2021 04:01

This article will describe the login or assertion ceremony I implement in the Users Without Passwords Project (UWPP). I will assume you have downloaded the ASP.NET Core 5.0 - Users Without Passwords Project.

Users Without Passwords Project and Article Series

I have developed two separate projects in the Users Without Passwords Project (UWPP) solution. The Users Without Passwords v4 project supports Bootstrap v4 and the new Users Without Passwords project supports Bootstrap v5. The new version is published at Fido.KenHaggerty.Com. You can register a new user with Windows Hello or a FIDO2 security key. Details, screenshots, and related articles can be found at ASP.NET Core 5.0 - Users Without Passwords Project. The details page includes the version change log.

The log in or assertion ceremony verifies the authenticating device belongs to the user and validates the integrity of the response with the PublicKey and the response signature. The UWPP configures the CredentialRequestOptions including a list of AllowCredentials which are all the Credential Ids related to the user before the user calls the navigator. credentials. get() function from JavaScript. The serialized options must have a field name of publicKey. See ASP.NET Core 5.0 - FIDO2 Challenge Options.

LoginChallenge.cshtml.cs > OnGetAsync:
// Generate challenge
ChallengeGuid = Guid.NewGuid();
var challenge = ChallengeGuid.ToByteArray();  // 16 bytes

var currentHost = HttpContext.Request.Host.ToString();
var relyingPartyId = currentHost.Split(":")[0];

// 5.5 https://www.w3.org/TR/webauthn-2/#dictionary-assertion-options
// allowCredentials, of type sequence<PublicKeyCredentialDescriptor>, defaulting to []
var allowCredentials = new List<AllowCredential>();
var userCredentials = await _credentialService.GetCredentialIdsAndTranportsTupleList ByAppUserIdAsync(AppUserId);
if (userCredentials.Count == 0)
    throw new InvalidOperationException($"No Credentials found for AppUser.Id = {AppUserId})");

foreach (var cred in userCredentials)
    allowCredentials.Add(new AllowCredential()
    {
        Type = _challengeOptions.CredentialType,
        Id = cred.Item1,
        Transports = cred.Item2.Split(", ").ToList()
    });

var userVerification = _challengeOptions.AuthenticatorSelection.UserVerification; //required, preferred, discouraged
var timeout = _challengeOptions.AssertionTimeout;
var extensions = new
{
    uvm = _challengeOptions.UvmExtension
};

// 7.2.1 Let options be a new PublicKeyCredentialRequestOptions structure configured to the Relying Party's
// needs for the ceremony.
var getOptions = new
{
    challenge,
    allowCredentials,
    relyingPartyId,
    userVerification,
    timeout,
    extensions
};
var jsonSerializerOptions = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
    WriteIndented = true
};

GetOptions = JsonSerializer.Serialize(getOptions, jsonSerializerOptions);

The CredentialService. GetCredentialIdsAndTranportsTupleList ByAppUserIdAsync function is new. If the browser supports the credential. response. getTransports function during the registration or attestation ceremony, the UWPP stores the list of tranports the authenticator supports. Such transports may be USB, NFC, BLE or internal (applicable when the authenticator is not removable from the device). See MDN - AuthenticatorAttestationResponse. getTransports(). If none of the AllowCredentials' Tansports are empty and none contain internal, the assertion ceremony's first response is much quicker.

The JavaScript requestAuthentication function converts the CredentialRequestOptions' Challenge and each AllowCredential's Id to Uint8Array type before calling navigator. credentials. get. The UWPP implements a small setTimeout() function because I found Firefox freezes the DOM before the waiting-div is displayed. The UWPP extracts the ExtensionResults with the credential. getClientExtensionResults function.

let getOptions = @Html.Raw(Model.GetOptions);

function requestAuthentication() {

    // Update byte string to expected Uint8Array
    try {
        if (typeof (getOptions.challenge) != Uint8Array)
            getOptions.challenge = getUint8Array(getOptions.challenge);

        let l = getOptions.allowCredentials.length;
        for (let i = 0; i < l; i++)
            if (typeof (getOptions.allowCredentials[i].id) != Uint8Array)
                getOptions.allowCredentials[i].id = getUint8Array(getOptions.allowCredentials[i].id);
                
    } catch (e) {
        showMessageModal(e, 'alert-warning');
        return;
    }

    document.querySelector('.request-div').classList.add('d-none');
    document.querySelector('.waiting-div').classList.remove('d-none');

    // Firefox v91.0 freezes the DOM before the waiting-div is displayed
    setTimeout(function () {
        // 7.1.2 Call navigator.credentials.get() and pass options as the publicKey option.
        navigator.credentials.get({ publicKey: getOptions })
            .then((credential) => {

                if (authAbortSignal.aborted) {
                    showMessageModal('Abort by user, authAbortSignal.aborted.', 'alert-danger', false, '', '', true, './login',
                        '_self', 'Try Again', 'btn-primary', false);
                    return;
                }

                let credentialJson = credentialToJSON(credential);

                credentialJson.extensionResults = credential.getClientExtensionResults();

                return fetch(window.location.pathname + '?handler=callback',
                    {
                        method: 'post',
                        credentials: 'same-origin',
                        headers: {
                            'Content-Type': 'application/json;charset=UTF-8',
                            'RequestVerificationToken':
                                document.querySelector('input[type="hidden"][name="__RequestVerificationToken"]').value
                        },
                        body: JSON.stringify(credentialJson),
                        keepalive: false,
                        signal: authAbortSignal
                    })
                    .then(function (response) {
                        if (response.ok) return response.text();
                        else throw Error('Response Not OK');
                    })
                    .then(function (text) {
                        try { return JSON.parse(text); }
                        catch (err) { throw Error('Method Not Found'); }
                    })
                    .then(function (responseJSON) {
                        if (responseJSON.Status == 'Success')
                            window.location.href = '@Model.ReturnUrl';
                        else
                            showMessageModal(responseJSON.Error, 'alert-danger', false, '', '', true, './login',
                                '_self', 'Try Again', 'btn-primary', false);
                    })
                    .catch(function (error) {
                        showMessageModal(error, 'alert-danger', false, '', '', true, './login',
                            '_self', 'Try Again', 'btn-primary', false);
                    })
            })
            .catch(function (err) {
                if (err == 'AbortError: Request has been aborted.') err = 'Abort by user completed.';
                // No acceptable authenticator or user refused consent. Handle appropriately.
                showMessageModal(err, 'alert-danger', false, '', '', true, './login', '_self',
                    'Try Again', 'btn-primary', false);
            });

    }, 100);
}

The UWPP implements the recursive credentialToJSON function in site.js to serialize the credential.

function credentialToJSON(pubKeyCred) {
    if (pubKeyCred instanceof Array) {
        let arr = [];
        for (let i of pubKeyCred)
            arr.push(credentialToJSON(i));
        return arr;
    }
    if (pubKeyCred instanceof ArrayBuffer)
        return btoa(String.fromCharCode.apply(null, new Uint8Array(pubKeyCred)));
    if (pubKeyCred instanceof Object) {
        let obj = {};
        for (let key in pubKeyCred)
            obj[key] = credentialToJSON(pubKeyCred[key]);
        return obj;
    }
    return pubKeyCred;
}

The UWPP implements a Challenge entity to log challenges with all available data. The Verified property allows the UWPP to log failed challenges. The authenticator's response is posted to the server for decoding, verifying and storage.

LoginChallenge.cshtml.cs > OnPostCallbackAsync:
public async Task<JsonResult> OnPostCallbackAsync([FromBody] AssertionResponseModel assertionResponse)
{
    string path = HttpContext.Request.Path.ToString().ToLower();
    if (path.Length > 256) path = path.Substring(0, 256);
    var uaString = HttpContext.Request.Headers["User-Agent"].FirstOrDefault();
    if (string.IsNullOrEmpty(uaString)) uaString = "NullOrEmpty";
    else if (uaString.Length > 512) uaString = uaString.Substring(0, 512);
    string anonymizedIp = HttpContext.Connection.RemoteIpAddress.AnonymizeIP();

    if (TempData.Peek("ChallengeGuid") == null || TempData.Peek("AppUserId") == null || TempData.Peek("RememberMe") == null)
    {
        if (!await _credentialService.AddNewChallengeAsync(string.Empty, false, 0, string.Empty, 0,
            string.Empty, true, string.Empty, string.Empty, string.Empty, string.Empty, 0, string.Empty,
            string.Empty, "TempData not found.", path, anonymizedIp, uaString))
            return new JsonResult(new { Status = "Failed", Error = "An error occurred creating a failed Challenge." });
        return new JsonResult(new { Status = "Failed", Error = "TempData not found." });
    }

    // 7.2.3 Let response be credential.response. If response is not an instance of AuthenticatorAssertionResponse,
    // abort the ceremony with a user-visible error.
    if (assertionResponse == null)
    {
        if (!await _credentialService.AddNewChallengeAsync(ChallengeGuid.ToString(), false, AppUserId, string.Empty, 0,
            string.Empty, false, string.Empty, string.Empty, string.Empty, string.Empty, 0, string.Empty, string.Empty,
            "7.2.3 AssertionResponse = null.", path, anonymizedIp, uaString))
            return new JsonResult(new { Status = "Failed", Error = "An error occurred creating a failed Challenge." });
        return new JsonResult(new { Status = "Failed", Error = "7.2.3 AssertionResponse = null." });
    }

    // 7.2.4 Let clientExtensionResults be the result of calling credential.getClientExtensionResults().
    // Assigned to ExtensionResults in JavaScript. 
    var clientExtensionResultsJson = JsonSerializer.Serialize(assertionResponse.ExtensionResults);

    // 7.2.5 If options.allowCredentials is not empty, verify that credential.id identifies one of the public key
    // credentials listed in options.allowCredentials.
    // This specification is verified with 7.2.6
    //var credentialIds = await _credentialService.GetCredentialIdsByAppUserIdAsync(AppUserId);
    //if (credentialIds.Count == 0)
    //{
    //    if (!await _credentialService.AddNewChallengeAsync(ChallengeGuid.ToString(), false, AppUserId, string.Empty, 0,
    //        string.Empty, false, string.Empty, string.Empty, string.Empty, string.Empty, 0, string.Empty, string.Empty,
    //        $"7.2.5 No Credentials found for AppUser.Id = {AppUserId}.", path, anonymizedIp, uaString))
    //        return new JsonResult(new { Status = "Failed", Error = "An error occurred creating a failed Challenge." });
    //    return new JsonResult(new { Status = "Failed", Error = $"7.2.5 No Credentials found for AppUser." });
    //}
    //if (!credentialIds.Contains(assertionResponse.RawId))
    //{
    //    if (!await _credentialService.AddNewChallengeAsync(ChallengeGuid.ToString(), false, AppUserId, string.Empty, 0,
    //        string.Empty, false, string.Empty, string.Empty, string.Empty, string.Empty, 0, string.Empty, string.Empty,
    //        $"7.2.5 Credential RawId not found for AppUser.Id = {AppUserId}, RawId = {assertionResponse.RawId}.",
    //        path, anonymizedIp, uaString))
    //        return new JsonResult(new { Status = "Failed", Error = "An error occurred creating a failed Challenge." });
    //    return new JsonResult(new
    //    {
    //        Status = "Failed",
    //        Error = $"7.2.5 Credential RawId not found for AppUser."
    //    });
    //}

    // 7.2.6 Identify the user being authenticated and verify that this user is the owner of the public key credential
    // source credentialSource identified by credential.id:
    var appUser = await _userService.GetAppUserByIdAsync(AppUserId).ConfigureAwait(false);
    if (appUser == null)
    {
        if (!await _credentialService.AddNewChallengeAsync(ChallengeGuid.ToString(), false, AppUserId, string.Empty,
            0, assertionResponse.Response.ClientDataJson, false, assertionResponse.Response.AuthenticatorData,
            string.Empty, assertionResponse.Response.Signature,
            assertionResponse.Response.UserHandle ?? string.Empty, 0, string.Empty, clientExtensionResultsJson,
            $"7.2.6 AppUser not found, AppUser.Id = {AppUserId}.", path, uaString, anonymizedIp))
            return new JsonResult(new { Status = "Failed", Error = "An error occurred creating a failed Challenge." });
        return new JsonResult(new { Status = "Failed", Error = "7.2.6 AppUser not found." });
    }
    // 7.2.7 Using credential.id (or credential.rawId, if base64url encoding is inappropriate for your use case),
    // look up the corresponding credential public key and let credentialPublicKey be that credential public key.
    var credential = await _credentialService.GetCredentialByCredentialIdAsync(assertionResponse.RawId);
    if (credential == null)
    {
        if (!await _credentialService.AddNewChallengeAsync(ChallengeGuid.ToString(), false, AppUserId, string.Empty,
            0, assertionResponse.Response.ClientDataJson, false, assertionResponse.Response.AuthenticatorData,
            string.Empty, assertionResponse.Response.Signature,
            assertionResponse.Response.UserHandle ?? string.Empty, 0, string.Empty, clientExtensionResultsJson,
            $"7.2.7 Credential not found, RawId = {assertionResponse.RawId}.", path, uaString, anonymizedIp))
            return new JsonResult(new { Status = "Failed", Error = "An error occurred creating a failed Challenge." });
        return new JsonResult(new { Status = "Failed", Error = "7.2.7 Credential Not Found." });
    }
    // If the user was identified before the authentication ceremony was initiated, e.g., via a username or cookie,
    // verify that the identified user is the owner of credentialSource.
    // If response.userHandle is present, let userHandle be its value. Verify that userHandle also maps to the same user.
    // If the user was not identified before the authentication ceremony was initiated, verify that response.userHandle
    // is present, and that the user identified by this value is the owner of credentialSource.
    if (appUser.UserHandle != credential.UserHandle)
    {
        if (!await _credentialService.AddNewChallengeAsync(ChallengeGuid.ToString(), false, AppUserId,
            credential.AaGuid, credential.Id, assertionResponse.Response.ClientDataJson, false,
            assertionResponse.Response.AuthenticatorData, string.Empty, assertionResponse.Response.Signature,
            assertionResponse.Response.UserHandle ?? string.Empty, 0, string.Empty, clientExtensionResultsJson,
            $"7.2.6 AppUser's UserHandle mismatch with credential, AppUser = {appUser.UserHandle}, " +
            $"Credential = {credential.UserHandle}.", path, uaString, anonymizedIp))
            return new JsonResult(new { Status = "Failed", Error = "An error occurred creating a failed Challenge." });
        return new JsonResult(new { Status = "Failed", Error = "7.2.6 AppUser's UserHandle mismatch with credential." });
    }

    // Verify userHandle when user verification is enabled
    if (assertionResponse.Response.UserHandle != null &&
        assertionResponse.Response.UserHandle.Length != 0 &&
        assertionResponse.Response.UserHandle != credential.UserHandle)
    {
        if (!await _credentialService.AddNewChallengeAsync(ChallengeGuid.ToString(), false, AppUserId,
            credential.AaGuid, credential.Id, assertionResponse.Response.ClientDataJson, false,
            assertionResponse.Response.AuthenticatorData, string.Empty, assertionResponse.Response.Signature,
            assertionResponse.Response.UserHandle ?? string.Empty, 0, string.Empty, clientExtensionResultsJson,
            "7.2.6 Invalid UserHandle.", path, uaString, anonymizedIp))
            return new JsonResult(new { Status = "Failed", Error = "An error occurred creating a failed Challenge." });
        return new JsonResult(new { Status = "Failed", Error = "7.2.6 Invalid UserHandle." });
    }

    var currentOrigin = HttpContext.Request.Scheme + Uri.SchemeDelimiter + HttpContext.Request.Host;

    var currentHost = HttpContext.Request.Host.ToString();
    var relyingPartyId = currentHost.Split(":")[0];

    var userVerification = (int)_challengeOptions.AuthenticatorSelection.UserVerification;

    // Return a JsonResult with the exception message.
    try
    {
        // 7.2.8 to 7.2.22
        var result = AssertionCrypto.DecodeAssertionResponse(assertionResponse, credential.PublicKey,
            credential.SignatureCounter, ChallengeGuid, currentOrigin, relyingPartyId, userVerification);
        if (!result.Verified)
        {
            if (!await _credentialService.AddNewChallengeAsync(ChallengeGuid.ToString(), false, AppUserId,
                result.AaGuidString, credential.Id, assertionResponse.Response.ClientDataJson, false,
                assertionResponse.Response.AuthenticatorData, string.Empty, assertionResponse.Response.Signature,
                assertionResponse.Response.UserHandle, result.SignatureCounter, result.SanExtensionJson,
                clientExtensionResultsJson, result.Error, path, uaString, anonymizedIp))
                return new JsonResult(new { Status = "Failed", Error = "An error occurred creating a failed Challenge." });
            return new JsonResult(new { Status = "Failed", Error = result.Error });
        }

        // Above steps are successful, continue with the authentication ceremony as appropriate.
        var challengeCompleted = new Challenge(ChallengeGuid.ToString(), true, AppUserId,
            result.AaGuidString, credential.Id, assertionResponse.Response.ClientDataJson, false,
            assertionResponse.Response.AuthenticatorData, string.Empty, assertionResponse.Response.Signature,
            assertionResponse.Response.UserHandle, result.SignatureCounter, result.SanExtensionJson,
            clientExtensionResultsJson, string.Empty, path, uaString, anonymizedIp);

        if (!await _credentialService.AddChallengeAsync(challengeCompleted))
        {
            if (!await _credentialService.AddNewChallengeAsync(ChallengeGuid.ToString(), false, AppUserId,
                result.AaGuidString, credential.Id, assertionResponse.Response.ClientDataJson, false,
                assertionResponse.Response.AuthenticatorData, string.Empty, assertionResponse.Response.Signature,
                assertionResponse.Response.UserHandle, result.SignatureCounter, result.SanExtensionJson,
                clientExtensionResultsJson, "An error occurred creating a completed Challenge.", path, uaString, anonymizedIp))
                return new JsonResult(new { Status = "Failed", Error = "An error occurred creating a failed Challenge." });
            return new JsonResult(new { Status = "Failed", Error = "An error occurred creating a ChallengeCompleted." });
        }

        if (challengeCompleted.Id == 0)
        {
            if (!await _credentialService.AddNewChallengeAsync(ChallengeGuid.ToString(), false, AppUserId, 
                result.AaGuidString, credential.Id, assertionResponse.Response.ClientDataJson, false,
                assertionResponse.Response.AuthenticatorData, string.Empty, assertionResponse.Response.Signature,
                assertionResponse.Response.UserHandle, result.SignatureCounter, result.SanExtensionJson,
                clientExtensionResultsJson, "New completed Challenge Id = 0.", path, uaString, anonymizedIp))
                return new JsonResult(new { Status = "Failed", Error = "An error occurred creating a failed Challenge." });
            return new JsonResult(new { Status = "Failed", Error = "New ChallengeCompleted Id = 0." });
        }

        // 7.2.21 Let storedSignCount be the stored signature counter value associated with credential.id.
        // If authData.signCount is nonzero or storedSignCount is nonzero, then run the following sub-step:
        //      If authData.signCount is greater than storedSignCount:
        //          Update storedSignCount to be the value of authData.signCount.
        credential.SignatureCounter = Convert.ToUInt32(result.SignatureCounter);
        credential.UpdatedDate = DateTimeOffset.UtcNow;

        if (!await _credentialService.UpdateCredentialAsync(credential))
        {
            if (!await _credentialService.AddNewChallengeAsync(ChallengeGuid.ToString(), false, AppUserId,
                result.AaGuidString, credential.Id, assertionResponse.Response.ClientDataJson, false,
                assertionResponse.Response.AuthenticatorData, string.Empty, assertionResponse.Response.Signature,
                assertionResponse.Response.UserHandle, result.SignatureCounter, result.SanExtensionJson,
                clientExtensionResultsJson, "7.2.21 An error occurred updating a Credential.", path, uaString, anonymizedIp))
                return new JsonResult(new { Status = "Failed", Error = "An error occurred creating a failed Challenge." });
            return new JsonResult(new { Status = "Failed", Error = "7.2.21 An error occurred updating a Credential." });
        }

        var claims = new List()
        {
            new Claim(ClaimTypes.NameIdentifier, appUser.Id.ToString(), ClaimValueTypes.Integer),
            new Claim(ClaimTypes.Name, appUser.LoginName),
            new Claim(ClaimTypes.Sid, appUser.SecurityStamp)
        };

        if (appUser.AdministratorRole) claims.Add(new Claim(ClaimTypes.Role, "Administrator"));

        var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);

        await HttpContext.SignInAsync(
            CookieAuthenticationDefaults.AuthenticationScheme,
            new ClaimsPrincipal(claimsIdentity),
            new AuthenticationProperties
            {
                // When ExpiresUtc is set, it overrides the value of the ExpireTimeSpan option of CookieAuthenticationOptions, if set.
                // ExpiresUtc = DateTime.UtcNow.AddMinutes(20),
                AllowRefresh = true,
                IsPersistent = RememberMe
            });

        appUser.LastLoginDate = DateTimeOffset.UtcNow;
        _ = await _userService.UpdateAppUserAsync(appUser);
    }
    catch (Exception ex)
    {
        if (!await _credentialService.AddNewChallengeAsync(ChallengeGuid.ToString(), false, AppUserId, credential.AaGuid,
            credential.Id, string.Empty, false, string.Empty, string.Empty, string.Empty, string.Empty, 0, string.Empty,
            string.Empty, $"Assertion verification: Exception: {ex.Message}.", path, anonymizedIp, uaString))
            return new JsonResult(new { Status = "Failed", Error = "An error occurred creating a failed Challenge." });
        return new JsonResult(new { Status = "Failed", Error = $"Assertion verification: Exception {ex.Message}." });
    }

    return new JsonResult(new { Status = "Success" });
}

The AssertionResponseModel contains the authenticator response's ClientDataJSON, AuthenticatorData, Signature, and UserHandle properties.

AssertionResponseModel.cs:
public class AssertionResponseModel
{
    public string Id { get; set; }
    public string RawId { get; set; }
    public AuthenticatorAssertionResponse Response { get; set; }
    public object ExtensionResults { get; set; }
}
public class AuthenticatorAssertionResponse
{
    public string ClientDataJson { get; set; }
    public string AuthenticatorData { get; set; }
    public string Signature { get; set; }
    public string UserHandle { get; set; }
}

I developed attestation and assertion crypto classes and commented the code with references to the new standards published by the World Wide Web Consortium. See Web Authentication: An API for accessing Public Key Credentials Level 2 W3C Proposed Recommendation, 25 February 2021. The AssertionCrypto. DecodeAssertionResponse function verifies all the new standards except TokenBinding (7.1.10, 7.2.14) which is not supported by popular browsers. If the verification for a standard fails, an invalid challenge with the error message is logged. Valid challenges are logged when a challenge completes successfully. The user can display the challenge history by credential in Manage > Credentials.

The AssertionResponseModel, PublicKey, SignatureCounter, Challenge, Origin, RelyingPartyId, and UserVerficationRequirement are verified with the AssertionCrypto. DecodeAssertionResponse function which returns an AuthenticationResult. The ClientDataJSON, AuthenticatorData, and Signature are decoded to bytes with the Microsoft. AspNetCore. WebUtilities. Base64UrlTextEncoder. Decode function. The ClientDataJson is used to verify the type (create or get), the challenge and Relying Party's origin. The UWPP implements an AuthenticatorUtils class with many enums and functions from GitHub - passwordless-lib / fido2-net-lib. The AuthenticatorUtils. GetSizedByteArray function slices the AuthenticatorData byte array to extract and validate properties. The Signature counter is decoded and compared to the stored SignatureCounter. If the Signature counter is greater than the Credential. SignatureCounter, the Credential. SignatureCounter is updated with the new count. If the Signature counter is less than or equal to the Credential. SignatureCounter, the verification fails because the authenticator may be cloned.

AssertionCrypto.cs:
// Copyright © Ken Haggerty (https://kenhaggerty.com)
// Licensed under the MIT License.

/// <summary>
/// 7.2 https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion
/// </summary>
public static class AssertionCrypto
{
    /// <summary>
    /// Gets a <see cref="AuthenticationResult" /> with <see cref="AuthenticationResult.Verified" /> indicating success
    /// and <see cref="AuthenticationResult.Error" /> indicating cause of failure.
    /// </summary>
    /// <param name="assertionResponse"></param>
    /// <param name="publicKey"></param>
    /// <param name="credentialCounter"></param>
    /// <param name="challegeGuid"></param>
    /// <param name="currentOrigin"></param>
    /// <param name="relyingPartyId"></param>
    /// <param name="userVerficationRequirement"></param>
    /// <returns><see cref="AuthenticationResult" /> with <see cref="AuthenticationResult.Verified" /> indicating success.</returns>
    /// <remarks>
    /// All known <see cref="AuthenticationResult" /> properties are populated when the result is returned.
    /// </remarks>
    public static AuthenticationResult DecodeAssertionResponse(AssertionResponseModel assertionResponse,
        string publicKey, uint credentialCounter, Guid challegeGuid, string currentOrigin, string relyingPartyId, 
        int userVerficationRequirement)
    {
        // 7.2.8 Let cData, authData and sig denote the value of response’s clientDataJSON, authenticatorData,
        // and signature respectively.
        // Decode clientDataJSON, authenticatorData, and signature
        var clientDataBytes = Base64UrlTextEncoder.Decode(assertionResponse.Response.ClientDataJson);
        var authenticatorDataBytes = Base64UrlTextEncoder.Decode(assertionResponse.Response.AuthenticatorData);
        var signatureBytes = Base64UrlTextEncoder.Decode(assertionResponse.Response.Signature);

        // 7.2.9 ASCII decode clientDataBytes
        var clientDataAscii = Encoding.ASCII.GetString(clientDataBytes);
        // 7.2.10 Deserialize clientData
        var clientData = JsonSerializer.Deserialize<ClientData>(clientDataAscii);

        // 7.2.11 Verify Type is webauthn.get.
        if (clientData.Type != "webauthn.get")
            return new AuthenticationResult()
            {
                Error = $"7.2.11 Invalid client data type, expected: webauthn.get, detected: {clientData.Type}."
            };

        // 7.2.12 Verify Challenge
        var tempDataChallenge = challegeGuid.ToByteArray(); // 16 bytes
        var clientChallenge = Base64UrlTextEncoder.Decode(clientData.Challenge);
        if (!clientChallenge.SequenceEqual(tempDataChallenge))
            return new AuthenticationResult()
            {
                Error = $"7.2.12 Invalid challenge, expected: {challegeGuid}, detected: {clientData.Challenge}."
            };

        // 7.2.13 Verify Relying Party's origin
        if (clientData.Origin != currentOrigin)
            return new AuthenticationResult()
            {
                Error = $"7.2.13 Invalid origin, expected: {currentOrigin}, detected: {clientData.Origin}."
            };

        // 7.2.14
        // Verify that the value of clientData.tokenBinding.status matches the state of Token Binding for the TLS
        // connection over which the assertion was obtained. If Token Binding was used on that TLS connection,
        // also verify that clientData.tokenBinding.id matches the base64url encoding of the Token Binding ID for
        // the connection. Not used very often at the moment due to the lack of support by major web browsers.
        // https://research.kudelskisecurity.com/2020/02/12/fido2-deep-dive-attestations-trust-model-and-security/

        var offset = 0;

        // 7.2.15 Verify RP ID hash is the RP ID expected by the RP.
        var rpIdHash = AuthenticatorUtils.GetSizedByteArray(authenticatorDataBytes, ref offset, 32);
        var sha256Hasher = new SHA256Managed();
        var relyingPartyIdHash = sha256Hasher.ComputeHash(Encoding.ASCII.GetBytes(relyingPartyId));
        if (!rpIdHash.SequenceEqual(relyingPartyIdHash))
            return new AuthenticationResult()
            {
                Error = $"7.2.15 Invalid RP ID hash, expected: {Base64UrlTextEncoder.Encode(relyingPartyIdHash)}, detected: {Base64UrlTextEncoder.Encode(rpIdHash.ToArray())}."
            };

        // Flags (1 byte)
        var flagsByte = AuthenticatorUtils.GetSizedByteArray(authenticatorDataBytes, ref offset, 1);
        var flags = new BitArray(flagsByte);
        var userPresent = flags[0]; // (UP)
        // Bit 1 reserved for future use (RFU1)
        var userVerified = flags[2]; // (UV)
        // Bits 3-5 reserved for future use (RFU2)
        var attestedCredentialData = flags[6]; // (AT) "Indicates whether the authenticator added attested credential data"
        var extensionDataIncluded = flags[7]; // (ED)

        if (attestedCredentialData)
            return new AuthenticationResult()
            {
                Error = "Assertion should not have attestedCredentialData, expected: false, detected: true."
            };

        if (extensionDataIncluded)
            return new AuthenticationResult()
            {
                Error = "Extension Data is not supported but extensionDataIncluded flag is set, expected: false, detected: true."
            };

        // 7.2.16 Verify that the User Present bit of the flags in authData is set.
        if (!userPresent)
            return new AuthenticationResult()
            {
                Error = "7.2.16 User not present, expected: true, detected: false."
            };

        var testInvalidChallenge = false;
        if (testInvalidChallenge)
            return new AuthenticationResult()
            {
                Error = "Debug. Deliberate Invalid Challenge, testInvalidChallenge = true."
            };

        // 7.2.17 If user verification is required for this assertion, verify that the User Verified bit of the flags in aData is set.
        // userVerficationRequirement: required = 0, preferred = 1, discouraged = 2
        if (userVerficationRequirement == 0 && !userVerified)
            return new AuthenticationResult()
            {
                Error = "7.2.17 User verification is required and the userVerified flag is false, expected: true, detected: false."
            };

        // 7.2.18 Verify that the values of the client extension outputs in clientExtensionResults
        // See 7.2.4 Current evaluation = ValueKind = Object {}

        // 7.2.19 Let hash be the result of computing a hash over the cData using SHA-256.
        var clientDataHash = sha256Hasher.ComputeHash(clientDataBytes); // 32 bytes

        // 7.2.20 Using credentialPublicKey, verify that sig is a valid signature over the binary concatenation
        // of authData and hash.
        var dataBytes = new byte[authenticatorDataBytes.Length + clientDataHash.Length];
        authenticatorDataBytes.CopyTo(dataBytes, 0);
        clientDataHash.CopyTo(dataBytes, authenticatorDataBytes.Length);
        bool isValid;
        try
        {
            isValid = IsAssertionValid(publicKey, dataBytes, signatureBytes);
        }
        catch (Exception ex)
        {
            return new AuthenticationResult()
            {
                Error = $"7.2.20 Assertion verification: Exception {ex.Message}."
            };
        }
        if (!isValid)
            return new AuthenticationResult()
            {
                Error = "7.2.20 Algorithm verification failed, expected: true, detected: false."
            };

        // Signature counter (4 bytes, big-endian unint32)
        var counterBytes = AuthenticatorUtils.GetSizedByteArray(authenticatorDataBytes, ref offset, 4);
        Array.Reverse(counterBytes);
        var counter = BitConverter.ToUInt32(counterBytes);

        // 7.2.21 Let storedSignCount be the stored signature counter value associated with credential.id.
        // If authData.signCount is nonzero or storedSignCount is nonzero, then run the following sub-step:
        //      If authData.signCount is greater than storedSignCount:
        //          Update storedSignCount to be the value of authData.signCount.
        //      less than or equal to storedSignCount:
        //          This is a signal that the authenticator may be cloned,
        //          i.e.at least two copies of the credential private key may exist and are being used in parallel.
        //          Relying Parties should incorporate this information into their risk scoring.
        //          Whether the Relying Party updates storedSignCount in this case, or not,
        //          or fails the authentication ceremony or not, is Relying Party-specific.
        if (counter > 0 && counter <= credentialCounter)
            return new AuthenticationResult()
            {
                Error = $"7.2.21 Invalid signature counter, expected: > {credentialCounter}, detected: {counter}."
            };

        // 7.2.22 If all the above steps are successful, continue with the authentication ceremony as appropriate.
        // Otherwise, fail the authentication ceremony.
        return new AuthenticationResult
        {
            SignatureCounter = counter,
            SanExtensionJson = string.Empty,
            Verified = true
        };
    }

    private static bool IsAssertionValid(string publicKeyString, byte[] dataBytes, byte[] signatureBytes)
    {
        var isValid = false;
        // Parse the PublicKey for the algorithm value, JsonPropertyName("3")
        var algorithmModel = JsonSerializer.Deserialize<AlgorithmModel>(publicKeyString);
        var hashAlgorithmName = HashAlgorithmName.SHA256; // default
        switch ((AuthenticatorUtils.KeyType)algorithmModel.KeyType)
        {
            case AuthenticatorUtils.KeyType.EC2: // Elliptic Curve Cryptography
                var shaPublicKey = JsonSerializer.Deserialize<ECCModel>(publicKeyString);
                var curve = ECCurve.NamedCurves.nistP256;
                switch ((AuthenticatorUtils.Algorithm)algorithmModel.Algorithm)
                {
                    case AuthenticatorUtils.Algorithm.ES256:
                        break;
                    case AuthenticatorUtils.Algorithm.ES384:
                        curve = ECCurve.NamedCurves.nistP384;
                        hashAlgorithmName = HashAlgorithmName.SHA384;
                        break;
                    case AuthenticatorUtils.Algorithm.ES512:
                        curve = ECCurve.NamedCurves.nistP521;
                        hashAlgorithmName = HashAlgorithmName.SHA512;
                        break;
                    default:
                        break;
                }
                var ecDsa = ECDsa.Create(new ECParameters
                {
                    Curve = curve,
                    Q = new ECPoint
                    {
                        X = Base64UrlTextEncoder.Decode(shaPublicKey.X),
                        Y = Base64UrlTextEncoder.Decode(shaPublicKey.Y)
                    }
                });
                var signature = AuthenticatorUtils.SigFromEcDsaSig(signatureBytes, ecDsa.KeySize);
                isValid = ecDsa.VerifyData(dataBytes, signature, hashAlgorithmName);
                break;
            case AuthenticatorUtils.KeyType.RSA:
                var rsaPublicKey = JsonSerializer.Deserialize<RSAModel>(publicKeyString);
                var rsa = RSA.Create(new RSAParameters
                {
                    Modulus = Base64UrlTextEncoder.Decode(rsaPublicKey.N),
                    Exponent = Base64UrlTextEncoder.Decode(rsaPublicKey.E)
                });
                switch ((AuthenticatorUtils.Algorithm)algorithmModel.Algorithm)
                {
                    case AuthenticatorUtils.Algorithm.RS256:
                        break;
                    case AuthenticatorUtils.Algorithm.RS384:
                        hashAlgorithmName = HashAlgorithmName.SHA384;
                        break;
                    case AuthenticatorUtils.Algorithm.RS512:
                        hashAlgorithmName = HashAlgorithmName.SHA512;
                        break;
                    default:
                        break;
                }
                isValid = rsa.VerifyData(dataBytes, signatureBytes, hashAlgorithmName, RSASignaturePadding.Pkcs1);
                break;
            default:
                break;
        }
        return isValid;
    }
}

The IsAssertionValid function first decodes the PublicKey for KeyType and Algorithm. If the KeyType is EC2, the PublicKey is decoded as an Elliptic Curve Digital Signature Algorithm (ECDsa). If the KeyType is RSA, the PublicKey is decoded as a RSA Algorithm. The PublicKey Algorithm is used to verify a valid signature over the binary concatenation of the clientDataHash and authenticatorDataBytes. The AuthenticatorUtils.SigFromEcDsaSig function from GitHub - passwordless-lib / fido2-net-lib / Src/ Fido2/ CryptoUtils.cs has been updated to use the MSDocs - AsnDecoder Class, new in .NET 5.0.

AuthenticatorUtils.cs:
/// <summary>
/// .NET requires IEEE P-1363 fixed size unsigned big endian values for R and S
/// ASN.1 requires storing positive integer values with any leading 0s removed
/// Converts ASN.1 format to IEEE P-1363 format 
/// </summary>
/// <param name="ecDsaSig"></param>
/// <param name="keySize"></param>
/// <returns><see cref="byte[]" /></returns>
public static byte[] SigFromEcDsaSig(byte[] ecDsaSig, int keySize)
{
    AsnReader asnReader = new(ecDsaSig, AsnEncodingRules.DER);
    var sequence = asnReader.ReadSequence();
    var r = sequence.ReadInteger().ToByteArray().Reverse().ToArray();
    var s = sequence.ReadInteger().ToByteArray().Reverse().ToArray();

    // Determine coefficient size 
    var coefficientSize = (int)Math.Ceiling((decimal)keySize / 8);
    // Create byte array to copy R into 
    var P1363R = new byte[coefficientSize];
    if (r[0] == 0x0 && (r[1] & (1 << 7)) != 0)
        r.Skip(1).ToArray().CopyTo(P1363R, coefficientSize - r.Length + 1);
    else
        r.CopyTo(P1363R, coefficientSize - r.Length);

    // Create byte array to copy S into 
    var P1363S = new byte[coefficientSize];
    if (s[0] == 0x0  && (s[1] & (1 << 7)) != 0)
        s.Skip(1).ToArray().CopyTo(P1363S, coefficientSize - s.Length + 1);
    else
        s.CopyTo(P1363S, coefficientSize - s.Length);

    // Concatenate R + S coordinates and return the raw signature
    return P1363R.Concat(P1363S).ToArray();
}

Comment Count = 0

Please log in to comment.

Login Register
Logged in users receive web notifications.
Web Notifications