| File: DeviceBoundSessionChallengeProtector.cs | Web Access |
| Project: src\aspnetcore\src\Security\Authentication\DeviceBoundSessions\src\Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj (Microsoft.AspNetCore.Authentication.DeviceBoundSessions) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Formats.Cbor; using System.Security.Claims; using System.Security.Cryptography; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; internal sealed class DeviceBoundSessionChallengeProtector { private readonly ITimeLimitedDataProtector _registrationProtector; private readonly ITimeLimitedDataProtector _refreshProtector; private readonly ILogger<DeviceBoundSessionChallengeProtector> _logger; public DeviceBoundSessionChallengeProtector(IDataProtectionProvider dataProtectionProvider, ILogger<DeviceBoundSessionChallengeProtector> logger) { _logger = logger; // Registration and refresh challenges use distinct data-protection purposes so a challenge // minted for one flow cannot be decrypted (let alone accepted) by the other. This makes // cross-type challenge confusion cryptographically impossible, independent of payload shape. _registrationProtector = dataProtectionProvider .CreateProtector("Microsoft.AspNetCore.Authentication.DeviceBoundSessions.Challenge.Registration.v1") .ToTimeLimitedDataProtector(); _refreshProtector = dataProtectionProvider .CreateProtector("Microsoft.AspNetCore.Authentication.DeviceBoundSessions.Challenge.Refresh.v1") .ToTimeLimitedDataProtector(); } public string GenerateRegistrationChallenge(ClaimsPrincipal principal, TimeSpan lifetime) { var claimUid = ComputeClaimUid(principal); var writer = new CborWriter(allowMultipleRootLevelValues: true); writer.WriteTextString(claimUid); return WebEncoders.Base64UrlEncode(_registrationProtector.Protect(writer.Encode(), lifetime)); } public string GenerateRefreshChallenge(ClaimsPrincipal principal, string sessionId, TimeSpan lifetime) { var claimUid = ComputeClaimUid(principal); var writer = new CborWriter(allowMultipleRootLevelValues: true); writer.WriteTextString(claimUid); writer.WriteTextString(sessionId); return WebEncoders.Base64UrlEncode(_refreshProtector.Protect(writer.Encode(), lifetime)); } public bool TryValidateRegistrationChallenge(string challenge, ClaimsPrincipal principal) { if (!TryUnprotect(_registrationProtector, challenge, out var payload)) { // Expired, tampered, or minted for a different flow/version — undecryptable. _logger.RegistrationChallengeUndecryptable(); return false; } try { var reader = new CborReader(payload, allowMultipleRootLevelValues: true); var storedClaimUid = reader.ReadTextString(); if (reader.PeekState() != CborReaderState.Finished) { // Extra trailing data — not a registration challenge in the shape this version writes. _logger.RegistrationChallengeMalformed(); return false; } if (!string.Equals(storedClaimUid, ComputeClaimUid(principal), StringComparison.Ordinal)) { // Decrypted but bound to a different principal than the request. _logger.RegistrationChallengePrincipalMismatch(); return false; } return true; } catch (Exception ex) when (ex is CborContentException or InvalidOperationException) { // Decrypted (so authentic) but not in the expected registration-challenge shape — e.g. a // challenge minted by a different/older serialization version. Reading the wrong type throws // InvalidOperationException; genuinely-malformed CBOR throws CborContentException. _logger.RegistrationChallengeMalformed(); return false; } } public bool TryValidateRefreshChallenge(string challenge, ClaimsPrincipal principal, string expectedSessionId) { if (!TryUnprotect(_refreshProtector, challenge, out var payload)) { // Expired, tampered, or minted for a different flow/version — undecryptable. _logger.RefreshChallengeUndecryptable(expectedSessionId); return false; } try { var reader = new CborReader(payload, allowMultipleRootLevelValues: true); var storedClaimUid = reader.ReadTextString(); var storedSessionId = reader.ReadTextString(); if (reader.PeekState() != CborReaderState.Finished) { // Extra trailing data — not a refresh challenge in the shape this version writes. _logger.RefreshChallengeMalformed(expectedSessionId); return false; } if (!string.Equals(storedClaimUid, ComputeClaimUid(principal), StringComparison.Ordinal)) { // Decrypted but bound to a different principal than the request. _logger.RefreshChallengePrincipalMismatch(expectedSessionId); return false; } if (!string.Equals(storedSessionId, expectedSessionId, StringComparison.Ordinal)) { // Decrypted but bound to a different session than the request. _logger.RefreshChallengeSessionMismatch(expectedSessionId); return false; } return true; } catch (Exception ex) when (ex is CborContentException or InvalidOperationException) { // Decrypted (so authentic) but not in the expected refresh-challenge shape — e.g. a challenge // minted by a different/older serialization version, or a registration challenge (one field) // replayed as a refresh proof. Reading the wrong type or past the end throws // InvalidOperationException; genuinely-malformed CBOR (practically unreachable after an // authenticated decrypt) throws CborContentException. Either way the challenge is unusable. _logger.RefreshChallengeMalformed(expectedSessionId); return false; } } private static bool TryUnprotect(ITimeLimitedDataProtector protector, string challenge, out byte[] payload) { try { payload = protector.Unprotect(WebEncoders.Base64UrlDecode(challenge)); return true; } catch { payload = []; return false; } } internal static string ComputeClaimUid(ClaimsPrincipal principal) { foreach (var identity in principal.Identities) { if (!identity.IsAuthenticated) { continue; } var subClaim = identity.FindFirst(c => string.Equals("sub", c.Type, StringComparison.Ordinal)); if (subClaim is not null && !string.IsNullOrEmpty(subClaim.Value)) { return EncodeClaim(subClaim); } var nameIdClaim = identity.FindFirst(c => string.Equals(ClaimTypes.NameIdentifier, c.Type, StringComparison.Ordinal)); if (nameIdClaim is not null && !string.IsNullOrEmpty(nameIdClaim.Value)) { return EncodeClaim(nameIdClaim); } var upnClaim = identity.FindFirst(c => string.Equals(ClaimTypes.Upn, c.Type, StringComparison.Ordinal)); if (upnClaim is not null && !string.IsNullOrEmpty(upnClaim.Value)) { return EncodeClaim(upnClaim); } } return ComputeClaimHashFallback(principal); } private static string EncodeClaim(Claim claim) { var writer = new CborWriter(allowMultipleRootLevelValues: true); writer.WriteTextString(claim.Type); writer.WriteTextString(claim.Value); writer.WriteTextString(claim.Issuer); return WebEncoders.Base64UrlEncode(writer.Encode()); } private static string ComputeClaimHashFallback(ClaimsPrincipal principal) { var allClaims = new List<Claim>(); foreach (var identity in principal.Identities) { if (identity.IsAuthenticated) { allClaims.AddRange(identity.Claims); } } if (allClaims.Count == 0) { return string.Empty; } allClaims.Sort((a, b) => string.Compare(a.Type, b.Type, StringComparison.Ordinal)); var writer = new CborWriter(allowMultipleRootLevelValues: true); foreach (var claim in allClaims) { writer.WriteTextString(claim.Type); writer.WriteTextString(claim.Value); writer.WriteTextString(claim.Issuer); } var encoded = writer.Encode(); Span<byte> hashBytes = stackalloc byte[SHA256.HashSizeInBytes]; SHA256.HashData(encoded, hashBytes); return WebEncoders.Base64UrlEncode(hashBytes); } }