|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace Microsoft.AspNetCore.Identity;
/// <summary>
/// The default passkey handler.
/// </summary>
public partial class DefaultPasskeyHandler<TUser> : IPasskeyHandler<TUser>
where TUser : class
{
private readonly PasskeyOptions _passkeyOptions;
/// <summary>
/// Constructs a new <see cref="DefaultPasskeyHandler{TUser}"/> instance.
/// </summary>
/// <param name="options">The <see cref="IdentityOptions"/>.</param>
public DefaultPasskeyHandler(IOptions<IdentityOptions> options)
{
_passkeyOptions = options.Value.Passkey;
}
/// <inheritdoc/>
public async Task<PasskeyAttestationResult> PerformAttestationAsync(PasskeyAttestationContext<TUser> context)
{
try
{
return await PerformAttestationCoreAsync(context).ConfigureAwait(false);
}
catch (PasskeyException ex)
{
return PasskeyAttestationResult.Fail(ex);
}
catch (Exception ex)
{
if (ex is OperationCanceledException)
{
throw;
}
return PasskeyAttestationResult.Fail(new PasskeyException($"An unexpected error occurred during passkey attestation: {ex.Message}", ex));
}
}
/// <inheritdoc/>
public async Task<PasskeyAssertionResult<TUser>> PerformAssertionAsync(PasskeyAssertionContext<TUser> context)
{
try
{
return await PerformAssertionCoreAsync(context).ConfigureAwait(false);
}
catch (PasskeyException ex)
{
return PasskeyAssertionResult.Fail<TUser>(ex);
}
catch (Exception ex)
{
if (ex is OperationCanceledException)
{
throw;
}
return PasskeyAssertionResult.Fail<TUser>(new PasskeyException($"An unexpected error occurred during passkey assertion: {ex.Message}", ex));
}
}
/// <summary>
/// Determines whether the specified origin is valid for passkey operations.
/// </summary>
/// <param name="originInfo">Information about the passkey's origin.</param>
/// <param name="httpContext">The HTTP context for the request.</param>
/// <returns><c>true</c> if the origin is valid; otherwise, <c>false</c>.</returns>
protected virtual Task<bool> IsValidOriginAsync(PasskeyOriginInfo originInfo, HttpContext httpContext)
{
var result = IsValidOrigin();
return Task.FromResult(result);
bool IsValidOrigin()
{
if (string.IsNullOrEmpty(originInfo.Origin))
{
return false;
}
if (originInfo.CrossOrigin && !_passkeyOptions.AllowCrossOriginIframes)
{
return false;
}
if (!Uri.TryCreate(originInfo.Origin, UriKind.Absolute, out var originUri))
{
return false;
}
if (_passkeyOptions.AllowedOrigins.Count > 0)
{
foreach (var allowedOrigin in _passkeyOptions.AllowedOrigins)
{
// Uri.Equals correctly handles string comparands.
if (originUri.Equals(allowedOrigin))
{
return true;
}
}
}
if (_passkeyOptions.AllowCurrentOrigin && httpContext.Request.Headers.Origin is [var origin])
{
// Uri.Equals correctly handles string comparands.
if (originUri.Equals(origin))
{
return true;
}
}
return false;
}
}
/// <summary>
/// Verifies the attestation statement of a passkey.
/// </summary>
/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#verification-procedure"/>.
/// </remarks>
/// <param name="attestationObject">The attestation object to verify. See <see href="https://www.w3.org/TR/webauthn-3/#attestation-object"/>.</param>
/// <param name="clientDataHash">The hash of the client data used during registration.</param>
/// <param name="httpContext">The HTTP context for the request.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains true if the verification is successful; otherwise, false.</returns>
protected virtual Task<bool> VerifyAttestationStatementAsync(ReadOnlyMemory<byte> attestationObject, ReadOnlyMemory<byte> clientDataHash, HttpContext httpContext)
=> Task.FromResult(true);
/// <summary>
/// Performs passkey attestation using the provided credential JSON and original options JSON.
/// </summary>
/// <param name="context">The context containing necessary information for passkey attestation.</param>
/// <returns>A task object representing the asynchronous operation containing the <see cref="PasskeyAttestationResult"/>.</returns>
protected virtual async Task<PasskeyAttestationResult> PerformAttestationCoreAsync(PasskeyAttestationContext<TUser> context)
{
// See: https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential
// NOTE: Quotes from the spec may have been modified.
// NOTE: Steps 1-3 are expected to have been performed prior to the execution of this method.
PublicKeyCredential<AuthenticatorAttestationResponse> credential;
PublicKeyCredentialCreationOptions originalOptions;
try
{
credential = JsonSerializer.Deserialize(context.CredentialJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialAuthenticatorAttestationResponse)
?? throw PasskeyException.NullAttestationCredentialJson();
}
catch (JsonException ex)
{
throw PasskeyException.InvalidAttestationCredentialJsonFormat(ex);
}
try
{
originalOptions = JsonSerializer.Deserialize(context.OriginalOptionsJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialCreationOptions)
?? throw PasskeyException.NullOriginalCreationOptionsJson();
}
catch (JsonException ex)
{
throw PasskeyException.InvalidOriginalCreationOptionsJsonFormat(ex);
}
VerifyCredentialType(credential);
// 3. Let response be credential.response.
var response = credential.Response;
// 4. Let clientExtensionResults be the result of calling credential.getClientExtensionResults().
// NOTE: Not currently supported.
// 5. Let JSONtext be the result of running UTF-8 decode on the value of response.clientDataJSON.
// 6. Let clientData, claimed as collected during the credential creation, be the result of running an implementation-specific JSON parser on JSONtext.
// 7. Verify that the value of clientData.type is webauthn.create.
// 8. Verify that the value of clientData.challenge equals the base64url encoding of pkOptions.challenge.
// 9-11. Verify that the value of C.origin matches the Relying Party's origin.
await VerifyClientDataAsync(
utf8Json: response.ClientDataJSON.AsMemory(),
originalChallenge: originalOptions.Challenge.AsMemory(),
expectedType: "webauthn.create",
context.HttpContext)
.ConfigureAwait(false);
// 12. Let clientDataHash be the result of computing a hash over response.clientDataJSON using SHA-256.
var clientDataHash = SHA256.HashData(response.ClientDataJSON.AsSpan());
// 13. Perform CBOR decoding on the attestationObject field of the AuthenticatorAttestationResponse structure and obtain the
// the authenticator data authenticatorData.
var attestationObjectMemory = response.AttestationObject.AsMemory();
var attestationObject = AttestationObject.Parse(attestationObjectMemory);
var authenticatorData = AuthenticatorData.Parse(attestationObject.AuthenticatorData);
// 14. Verify that the rpIdHash in authenticatorData is the SHA-256 hash of the RP ID expected by the Relying Party.
// 15. If options.mediation is not set to conditional, verify that the UP bit of the flags in authData is set.
// 16. If user verification is required for this registration, verify that the User Verified bit of the flags in authData is set.
// 17. If the BE bit of the flags in authData is not set, verify that the BS bit is not set.
// 18. If the Relying Party uses the credential’s backup eligibility to inform its user experience flows and/or policies,
// evaluate the BE bit of the flags in authData.
// 19. If the Relying Party uses the credential’s backup state to inform its user experience flows and/or policies, evaluate the BS
// bit of the flags in authData.
VerifyAuthenticatorData(
authenticatorData,
originalRpId: originalOptions.Rp.Id,
originalUserVerificationRequirement: originalOptions.AuthenticatorSelection?.UserVerification);
if (!authenticatorData.HasAttestedCredentialData)
{
throw PasskeyException.MissingAttestedCredentialData();
}
// 20. Verify that the "alg" parameter in the credential public key in authData matches the alg attribute of one of the items in pkOptions.pubKeyCredParams.
var attestedCredentialData = authenticatorData.AttestedCredentialData;
if (!originalOptions.PubKeyCredParams.Any(a => attestedCredentialData.CredentialPublicKey.Alg == a.Alg))
{
throw PasskeyException.UnsupportedCredentialPublicKeyAlgorithm();
}
// 21-24. Determine the attestation statement format by performing a USASCII case-sensitive match on fmt against the set of supported WebAuthn
// Attestation Statement Format Identifier values...
// Handles all validation related to the attestation statement (21-24).
var isAttestationStatementValid = await VerifyAttestationStatementAsync(attestationObjectMemory, clientDataHash, context.HttpContext).ConfigureAwait(false);
if (!isAttestationStatementValid)
{
throw PasskeyException.InvalidAttestationStatement();
}
// 25. Verify that the credentialId is <= 1023 bytes.
// NOTE: Handled while parsing the attested credential data.
if (!credential.Id.AsSpan().SequenceEqual(attestedCredentialData.CredentialId.Span))
{
throw PasskeyException.CredentialIdMismatch();
}
var credentialId = attestedCredentialData.CredentialId.ToArray();
// 26. Verify that the credentialId is not yet registered for any user.
var existingUser = await context.UserManager.FindByPasskeyIdAsync(credentialId).ConfigureAwait(false);
if (existingUser is not null)
{
throw PasskeyException.CredentialAlreadyRegistered();
}
// 27. Let credentialRecord be a new credential record with the following contents:
var credentialRecord = new UserPasskeyInfo(
credentialId,
publicKey: attestedCredentialData.CredentialPublicKey.ToArray(),
name: null,
createdAt: DateTime.UtcNow,
signCount: authenticatorData.SignCount,
transports: response.Transports,
isUserVerified: authenticatorData.IsUserVerified,
isBackupEligible: authenticatorData.IsBackupEligible,
isBackedUp: authenticatorData.IsBackedUp,
attestationObject: response.AttestationObject.ToArray(),
clientDataJson: response.ClientDataJSON.ToArray());
// 28. Process the client extension outputs in clientExtensionResults and the authenticator extension
// outputs in the extensions in authData as required by the Relying Party.
// NOTE: Not currently supported.
// 29. If all the above steps are successful, store credentialRecord in the user account that was denoted
// and continue the registration ceremony as appropriate.
return PasskeyAttestationResult.Success(credentialRecord);
}
/// <summary>
/// Performs passkey assertion using the provided credential JSON, original options JSON, and optional user.
/// </summary>
/// <param name="context">The context containing necessary information for passkey assertion.</param>
/// <returns>A task object representing the asynchronous operation containing the <see cref="PasskeyAssertionResult{TUser}"/>.</returns>
protected virtual async Task<PasskeyAssertionResult<TUser>> PerformAssertionCoreAsync(PasskeyAssertionContext<TUser> context)
{
// See https://www.w3.org/TR/webauthn-3/#sctn-verifying-assertion
// NOTE: Quotes from the spec may have been modified.
// NOTE: Steps 1-3 are expected to have been performed prior to the execution of this method.
PublicKeyCredential<AuthenticatorAssertionResponse> credential;
PublicKeyCredentialRequestOptions originalOptions;
try
{
credential = JsonSerializer.Deserialize(context.CredentialJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialAuthenticatorAssertionResponse)
?? throw PasskeyException.NullAssertionCredentialJson();
}
catch (JsonException ex)
{
throw PasskeyException.InvalidAssertionCredentialJsonFormat(ex);
}
try
{
originalOptions = JsonSerializer.Deserialize(context.OriginalOptionsJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialRequestOptions)
?? throw PasskeyException.NullOriginalRequestOptionsJson();
}
catch (JsonException ex)
{
throw PasskeyException.InvalidOriginalRequestOptionsJsonFormat(ex);
}
VerifyCredentialType(credential);
// 3. Let response be credential.response.
var response = credential.Response;
// 4. Let clientExtensionResults be the result of calling credential.getClientExtensionResults().
// NOTE: Not currently supported.
// 5. If originalOptions.allowCredentials is not empty, verify that credential.id identifies one of the public key
// credentials listed in pkOptions.allowCredentials.
if (originalOptions.AllowCredentials is { Count: > 0 } allowCredentials &&
!originalOptions.AllowCredentials.Any(c => c.Id.Equals(credential.Id)))
{
throw PasskeyException.CredentialNotAllowed();
}
var credentialId = credential.Id.ToArray();
var userHandle = response.UserHandle?.ToString();
UserPasskeyInfo? storedPasskey;
// 6. Identify the user being authenticated and let credentialRecord be the credential record for the credential:
if (context.User is { } user)
{
// * If the user was identified before the authentication ceremony was initiated, e.g., via a username or cookie,
// verify that the identified user account contains a credential record whose id equals
// credential.rawId. Let credentialRecord be that credential record. If response.userHandle is
// present, verify that it equals the user handle of the user account.
storedPasskey = await context.UserManager.GetPasskeyAsync(user, credentialId).ConfigureAwait(false);
if (storedPasskey is null)
{
throw PasskeyException.CredentialDoesNotBelongToUser();
}
if (userHandle is not null)
{
var userId = await context.UserManager.GetUserIdAsync(user).ConfigureAwait(false);
if (!string.Equals(userHandle, userId, StringComparison.Ordinal))
{
throw PasskeyException.UserHandleMismatch(userId, userHandle);
}
}
}
else
{
// * If the user was not identified before the authentication ceremony was initiated,
// verify that response.userHandle is present. Verify that the user account identified by
// response.userHandle contains a credential record whose id equals credential.rawId. Let
// credentialRecord be that credential record.
if (userHandle is null)
{
throw PasskeyException.MissingUserHandle();
}
user = await context.UserManager.FindByIdAsync(userHandle).ConfigureAwait(false);
if (user is null)
{
throw PasskeyException.CredentialDoesNotBelongToUser();
}
storedPasskey = await context.UserManager.GetPasskeyAsync(user, credentialId).ConfigureAwait(false);
if (storedPasskey is null)
{
throw PasskeyException.CredentialDoesNotBelongToUser();
}
}
// 7. Let cData, authData and sig denote the value of response’s clientDataJSON, authenticatorData, and signature respectively.
var authenticatorData = AuthenticatorData.Parse(response.AuthenticatorData.AsMemory());
// 8. Let JSONtext be the result of running UTF-8 decode on the value of cData.
// 9. Let C, the client data claimed as used for the signature, be the result of running an implementation-specific JSON parser on JSONtext.
// 10. Verify that the value of C.type is the string webauthn.get.
// 11. Verify that the value of C.challenge equals the base64url encoding of originalOptions.challenge.
// 12-14. Verify that the value of C.origin is an origin expected by the Relying Party.
await VerifyClientDataAsync(
utf8Json: response.ClientDataJSON.AsMemory(),
originalChallenge: originalOptions.Challenge.AsMemory(),
expectedType: "webauthn.get",
context.HttpContext)
.ConfigureAwait(false);
// 15. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party.
// 16. Verify that the UP bit of the flags in authData is set.
// 17. If user verification was determined to be required, verify that the UV bit of the flags in authData is set.
// Otherwise, ignore the value of the UV flag.
// 18. If the BE bit of the flags in authData is not set, verify that the BS bit is not set.
VerifyAuthenticatorData(
authenticatorData,
originalRpId: originalOptions.RpId,
originalUserVerificationRequirement: originalOptions.UserVerification);
// 19. If the credential backup state is used as part of Relying Party business logic or policy, let currentBe and currentBs
// be the values of the BE and BS bits, respectively, of the flags in authData. Compare currentBe and currentBs with
// credentialRecord.backupEligible and credentialRecord.backupState:
// 1. If credentialRecord.backupEligible is set, verify that currentBe is set.
// 2. If credentialRecord.backupEligible is not set, verify that currentBe is not set.
// 3. Apply Relying Party policy, if any.
// NOTE: RP policy applied in VerifyAuthenticatorData() above.
if (storedPasskey.IsBackupEligible && !authenticatorData.IsBackupEligible)
{
throw PasskeyException.ExpectedBackupEligibleCredential();
}
if (!storedPasskey.IsBackupEligible && authenticatorData.IsBackupEligible)
{
throw PasskeyException.ExpectedBackupIneligibleCredential();
}
// 20. Let clientDataHash be the result of computing a hash over the cData using SHA-256.
var clientDataHash = SHA256.HashData(response.ClientDataJSON.AsSpan());
// 21. Using credentialRecord.publicKey, verify that sig is a valid signature over the binary concatenation of authData and hash.
byte[] data = [.. response.AuthenticatorData.AsSpan(), .. clientDataHash];
var cpk = CredentialPublicKey.Decode(storedPasskey.PublicKey);
if (!cpk.Verify(data, response.Signature.AsSpan()))
{
throw PasskeyException.InvalidAssertionSignature();
}
// 22. If authData.signCount is nonzero or credentialRecord.signCount is nonzero, then run the following sub-step:
if (authenticatorData.SignCount != 0 || storedPasskey.SignCount != 0)
{
// * If authData.signCount is greater than credentialRecord.signCount:
// The signature counter is valid.
// * If authData.signCount is less than or equal to credentialRecord.signCount
// This is a signal, but not proof, that the authenticator may be cloned.
// NOTE: We simply fail the ceremony in this case.
if (authenticatorData.SignCount <= storedPasskey.SignCount)
{
throw PasskeyException.SignCountLessThanOrEqualToStoredSignCount();
}
}
// 23. Process the client extension outputs in clientExtensionResults and the authenticator extension outputs
// in the extensions in authData as required by the Relying Party.
// NOTE: Not currently supported.
// 24. Update credentialRecord with new state values
// 1. Update credentialRecord.signCount to the value of authData.signCount.
storedPasskey.SignCount = authenticatorData.SignCount;
// 2. Update credentialRecord.backupState to the value of currentBs.
storedPasskey.IsBackedUp = authenticatorData.IsBackedUp;
// 3. If credentialRecord.uvInitialized is false, update it to the value of the UV bit in the flags in authData.
// This change SHOULD require authorization by an additional authentication factor equivalent to WebAuthn user verification;
// if not authorized, skip this step.
// NOTE: Not currently supported.
// 25. If all the above steps are successful, continue the authentication ceremony as appropriate.
return PasskeyAssertionResult.Success(storedPasskey, user);
}
private static void VerifyCredentialType<TResponse>(PublicKeyCredential<TResponse> credential)
where TResponse : AuthenticatorResponse
{
const string ExpectedType = "public-key";
if (!string.Equals(ExpectedType, credential.Type, StringComparison.Ordinal))
{
throw PasskeyException.InvalidCredentialType(ExpectedType, credential.Type);
}
}
private async Task VerifyClientDataAsync(
ReadOnlyMemory<byte> utf8Json,
ReadOnlyMemory<byte> originalChallenge,
string expectedType,
HttpContext httpContext)
{
// Let JSONtext be the result of running UTF-8 decode on the value of cData.
// Let C, the client data claimed as used for the signature, be the result of running an implementation-specific JSON parser on JSONtext.
CollectedClientData clientData;
try
{
clientData = JsonSerializer.Deserialize(utf8Json.Span, IdentityJsonSerializerContext.Default.CollectedClientData)
?? throw PasskeyException.NullClientDataJson();
}
catch (JsonException ex)
{
throw PasskeyException.InvalidClientDataJsonFormat(ex);
}
// Verify that the value of C.type is either the string webauthn.create or webauthn.get.
// NOTE: The expected value depends on whether we're performing attestation or assertion.
if (!string.Equals(expectedType, clientData.Type, StringComparison.Ordinal))
{
throw PasskeyException.InvalidClientDataType(expectedType, clientData.Type);
}
// Verify that the value of C.challenge equals the base64url encoding of originalOptions.challenge.
if (!CryptographicOperations.FixedTimeEquals(clientData.Challenge.AsSpan(), originalChallenge.Span))
{
throw PasskeyException.InvalidChallenge();
}
// Verify that the value of C.origin is an origin expected by the Relying Party.
// NOTE: The level 3 draft permits having multiple origins and validating the "top origin" when a cross-origin request is made.
// For future-proofing, we pass a PasskeyOriginInfo to the origin validator so that we're able to add more properties to
// it later.
var originInfo = new PasskeyOriginInfo(clientData.Origin, clientData.CrossOrigin == true);
var isOriginValid = await IsValidOriginAsync(originInfo, httpContext).ConfigureAwait(false);
if (!isOriginValid)
{
throw PasskeyException.InvalidOrigin(clientData.Origin);
}
// NOTE: The level 2 spec requires token binding validation, but the level 3 spec does not.
// We'll just validate that the token binding object doesn't have an unexpected format.
if (clientData.TokenBinding is { } tokenBinding)
{
var status = tokenBinding.Status;
if (!string.Equals("supported", status, StringComparison.Ordinal) &&
!string.Equals("present", status, StringComparison.Ordinal) &&
!string.Equals("not-supported", status, StringComparison.Ordinal))
{
throw PasskeyException.InvalidTokenBindingStatus(status);
}
}
}
private void VerifyAuthenticatorData(
AuthenticatorData authenticatorData,
string? originalRpId,
string? originalUserVerificationRequirement)
{
// Verify that the rpIdHash in authenticatorData is the SHA-256 hash of the RP ID expected by the Relying Party.
var originalRpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(originalRpId ?? string.Empty));
if (!CryptographicOperations.FixedTimeEquals(authenticatorData.RpIdHash.Span, originalRpIdHash.AsSpan()))
{
throw PasskeyException.InvalidRelyingPartyIDHash();
}
// If options.mediation is not set to conditional, verify that the UP bit of the flags in authData is set.
// NOTE: We currently check for the UserPresent flag unconditionally. Consider making this optional via options.mediation
// after the level 3 draft becomes standard.
if (!authenticatorData.IsUserPresent)
{
throw PasskeyException.UserNotPresent();
}
// If user verification is required for this registration, verify that the User Verified bit of the flags in authData is set.
if (string.Equals("required", originalUserVerificationRequirement, StringComparison.Ordinal) && !authenticatorData.IsUserVerified)
{
throw PasskeyException.UserNotVerified();
}
// If the BE bit of the flags in authData is not set, verify that the BS bit is not set.
if (!authenticatorData.IsBackupEligible && authenticatorData.IsBackedUp)
{
throw PasskeyException.NotBackupEligibleYetBackedUp();
}
// If the Relying Party uses the credential’s backup eligibility to inform its user experience flows and/or policies,
// evaluate the BE bit of the flags in authData.
if (authenticatorData.IsBackupEligible && _passkeyOptions.BackupEligibleCredentialPolicy is PasskeyOptions.CredentialBackupPolicy.Disallowed)
{
throw PasskeyException.BackupEligibilityDisallowedYetBackupEligible();
}
if (!authenticatorData.IsBackupEligible && _passkeyOptions.BackupEligibleCredentialPolicy is PasskeyOptions.CredentialBackupPolicy.Required)
{
throw PasskeyException.BackupEligibilityRequiredYetNotBackupEligible();
}
// If the Relying Party uses the credential’s backup state to inform its user experience flows and/or policies, evaluate the BS
// bit of the flags in authData.
if (authenticatorData.IsBackedUp && _passkeyOptions.BackedUpCredentialPolicy is PasskeyOptions.CredentialBackupPolicy.Disallowed)
{
throw PasskeyException.BackupDisallowedYetBackedUp();
}
if (!authenticatorData.IsBackedUp && _passkeyOptions.BackedUpCredentialPolicy is PasskeyOptions.CredentialBackupPolicy.Required)
{
throw PasskeyException.BackupRequiredYetNotBackedUp();
}
}
}
|