Skip to content

Commit

Permalink
Use constant time compare method
Browse files Browse the repository at this point in the history
  • Loading branch information
JamesNK committed Mar 28, 2024
1 parent ffa3428 commit 6cc78f8
Show file tree
Hide file tree
Showing 6 changed files with 87 additions and 58 deletions.
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Buffers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using Aspire.Dashboard.Configuration;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;

Expand Down Expand Up @@ -34,9 +32,9 @@ protected override Task<AuthenticateResult> HandleAuthenticateAsync()
return Task.FromResult(AuthenticateResult.Fail($"Multiple '{ApiKeyHeaderName}' headers in request."));
}

if (!CompareApiKey(options.GetPrimaryApiKeyBytes(), apiKey.ToString()))
if (!CompareHelpers.CompareKey(options.GetPrimaryApiKeyBytes(), apiKey.ToString()))
{
if (options.GetSecondaryApiKeyBytes() is not { } secondaryBytes || !CompareApiKey(secondaryBytes, apiKey.ToString()))
if (options.GetSecondaryApiKeyBytes() is not { } secondaryBytes || !CompareHelpers.CompareKey(secondaryBytes, apiKey.ToString()))
{
return Task.FromResult(AuthenticateResult.Fail($"Incoming API key from '{ApiKeyHeaderName}' header doesn't match configured API key."));
}
Expand All @@ -49,50 +47,6 @@ protected override Task<AuthenticateResult> HandleAuthenticateAsync()

return Task.FromResult(AuthenticateResult.NoResult());
}

// This method is used to compare two API keys in a way that avoids timing attacks.
private static bool CompareApiKey(byte[] expectedApiKeyBytes, string requestApiKey)
{
const int StackAllocThreshold = 256;

var requestByteCount = Encoding.UTF8.GetByteCount(requestApiKey);

// API key will never match if lengths are different. But still do all the work to avoid timing attacks.
var lengthsEqual = expectedApiKeyBytes.Length == requestByteCount;

var requestSpanLength = Math.Max(requestByteCount, expectedApiKeyBytes.Length);
byte[]? requestPooled = null;
var requestBytesSpan = (requestSpanLength <= StackAllocThreshold ?
stackalloc byte[StackAllocThreshold] :
(requestPooled = RentClearedArray(requestSpanLength))).Slice(0, requestSpanLength);

try
{
// Always succeeds because the byte span is always as big or bigger than required.
Encoding.UTF8.GetBytes(requestApiKey, requestBytesSpan);

// Trim request bytes to the same length as expected bytes. Need to be the same size for fixed time comparison.
var equals = CryptographicOperations.FixedTimeEquals(expectedApiKeyBytes, requestBytesSpan.Slice(0, expectedApiKeyBytes.Length));

return equals && lengthsEqual;
}
finally
{
if (requestPooled != null)
{
ArrayPool<byte>.Shared.Return(requestPooled);
}
}

static byte[] RentClearedArray(int byteCount)
{
// UTF8 bytes are copied into the array but remaining bytes are untouched.
// Because all bytes in the array are compared, clear the array to avoid comparing previous data.
var array = ArrayPool<byte>.Shared.Rent(byteCount);
Array.Clear(array);
return array;
}
}
}

public static class OtlpApiKeyAuthenticationDefaults
Expand Down
9 changes: 7 additions & 2 deletions src/Aspire.Dashboard/Components/Pages/Token.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ protected override async Task OnInitializedAsync()
var state = await authStateTask;
if (state.User.Identity?.IsAuthenticated ?? false)
{
NavigationManager.NavigateTo(ReturnUrl ?? "/", forceLoad: true);
NavigationManager.NavigateTo(GetRedirectUrl(), forceLoad: true);
return;
}
}
Expand Down Expand Up @@ -84,7 +84,7 @@ private async Task SubmitAsync()
{
if (success)
{
NavigationManager.NavigateTo(ReturnUrl ?? "/", forceLoad: true);
NavigationManager.NavigateTo(GetRedirectUrl(), forceLoad: true);
return;
}
else
Expand All @@ -99,6 +99,11 @@ private async Task SubmitAsync()
}
}

private string GetRedirectUrl()
{
return ReturnUrl ?? DashboardUrls.ResourcesUrl();
}

public async ValueTask DisposeAsync()
{
await JSInteropHelpers.SafeDisposeAsync(_jsModule);
Expand Down
9 changes: 7 additions & 2 deletions src/Aspire.Dashboard/Components/Pages/Token.razor.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
export async function validateToken(token) {
var response = await fetch(`/validate-token?token=${encodeURIComponent(token)}`);
return response.text();
try {
var url = `/api/validate-token?token=${encodeURIComponent(token)}`;
var response = await fetch(url, { method: 'POST' });
return response.text();
} catch (ex) {
return `Error validating token: ${ex}`;
}
}
5 changes: 5 additions & 0 deletions src/Aspire.Dashboard/Configuration/DashboardOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,14 @@ internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage)
public sealed class FrontendOptions
{
private List<Uri>? _parsedEndpointUrls;
private byte[]? _browserTokenBytes;

public string? EndpointUrls { get; set; }
public FrontendAuthMode? AuthMode { get; set; }
public string? BrowserToken { get; set; }

public byte[]? GetBrowserTokenBytes() => _browserTokenBytes;

public IReadOnlyList<Uri> GetEndpointUris()
{
Debug.Assert(_parsedEndpointUrls is not null, "Should have been parsed during validation.");
Expand Down Expand Up @@ -140,6 +143,8 @@ internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage)
_parsedEndpointUrls = uris;
}

_browserTokenBytes = BrowserToken != null ? Encoding.UTF8.GetBytes(BrowserToken) : null;

errorMessage = null;
return true;
}
Expand Down
15 changes: 10 additions & 5 deletions src/Aspire.Dashboard/DashboardWebApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Otlp.Grpc;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Utils;
using Aspire.Hosting;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Certificate;
Expand Down Expand Up @@ -225,17 +226,21 @@ public DashboardWebApplication(Action<WebApplicationBuilder>? configureBuilder =
_app.MapGrpcService<OtlpTraceService>();
_app.MapGrpcService<OtlpLogsService>();

_app.MapGet("/validate-token", async (string token, HttpContext httpContext, IOptionsMonitor<DashboardOptions> dashboardOptions) =>
_app.MapPost("/api/validate-token", async (string token, HttpContext httpContext, IOptionsMonitor<DashboardOptions> dashboardOptions) =>
{
if (string.IsNullOrEmpty(token) || token != dashboardOptions.CurrentValue.Frontend.BrowserToken)
if (string.IsNullOrEmpty(token) || dashboardOptions.CurrentValue.Frontend.GetBrowserTokenBytes() is not { } browserTokenBytes)
{
return false;
}

var claimsIdentity = new ClaimsIdentity(new List<Claim>
if (!CompareHelpers.CompareKey(browserTokenBytes, token))
{
new Claim(ClaimTypes.NameIdentifier, "Local")
}, authenticationType: CookieAuthenticationDefaults.AuthenticationScheme);
return false;
}

var claimsIdentity = new ClaimsIdentity(
[new Claim(ClaimTypes.NameIdentifier, "Local")],
authenticationType: CookieAuthenticationDefaults.AuthenticationScheme);
var claims = new ClaimsPrincipal(claimsIdentity);

await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, claims).ConfigureAwait(false);
Expand Down
55 changes: 55 additions & 0 deletions src/Aspire.Dashboard/Utils/CompareHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Buffers;
using System.Security.Cryptography;
using System.Text;

namespace Aspire.Dashboard.Utils;

internal static class CompareHelpers
{
// This method is used to compare two keys in a way that avoids timing attacks.
public static bool CompareKey(byte[] expectedKeyBytes, string requestKey)
{
const int StackAllocThreshold = 256;

var requestByteCount = Encoding.UTF8.GetByteCount(requestKey);

// Key will never match if lengths are different. But still do all the comparison work to avoid timing attacks.
var lengthsEqual = expectedKeyBytes.Length == requestByteCount;

var requestSpanLength = Math.Max(requestByteCount, expectedKeyBytes.Length);
byte[]? requestPooled = null;
var requestBytesSpan = (requestSpanLength <= StackAllocThreshold ?
stackalloc byte[StackAllocThreshold] :
(requestPooled = RentClearedArray(requestSpanLength))).Slice(0, requestSpanLength);

try
{
// Always succeeds because the byte span is always as big or bigger than required.
Encoding.UTF8.GetBytes(requestKey, requestBytesSpan);

// Trim request bytes to the same length as expected bytes. Need to be the same size for fixed time comparison.
var equals = CryptographicOperations.FixedTimeEquals(expectedKeyBytes, requestBytesSpan.Slice(0, expectedKeyBytes.Length));

return equals && lengthsEqual;
}
finally
{
if (requestPooled != null)
{
ArrayPool<byte>.Shared.Return(requestPooled);
}
}

static byte[] RentClearedArray(int byteCount)
{
// UTF8 bytes are copied into the array but remaining bytes are untouched.
// Because all bytes in the array are compared, clear the array to avoid comparing previous data.
var array = ArrayPool<byte>.Shared.Rent(byteCount);
Array.Clear(array);
return array;
}
}
}

0 comments on commit 6cc78f8

Please sign in to comment.