diff --git a/src/Security/Authentication/Negotiate/src/Internal/INegotiateStateFactory.cs b/src/Security/Authentication/Negotiate/src/Internal/INegotiateStateFactory.cs index 1907e9098c67..6f9c7b58f89c 100644 --- a/src/Security/Authentication/Negotiate/src/Internal/INegotiateStateFactory.cs +++ b/src/Security/Authentication/Negotiate/src/Internal/INegotiateStateFactory.cs @@ -6,5 +6,5 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate; // For testing internal interface INegotiateStateFactory { - INegotiateState CreateInstance(); + INegotiateState CreateInstance(ReadOnlyMemory channelBindingToken); } diff --git a/src/Security/Authentication/Negotiate/src/Internal/NegotiateChannelBinding.cs b/src/Security/Authentication/Negotiate/src/Internal/NegotiateChannelBinding.cs new file mode 100644 index 000000000000..7d83af31662e --- /dev/null +++ b/src/Security/Authentication/Negotiate/src/Internal/NegotiateChannelBinding.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.InteropServices; +using System.Security.Authentication.ExtendedProtection; + +namespace Microsoft.AspNetCore.Authentication.Negotiate; + +internal sealed class NegotiateChannelBinding : ChannelBinding +{ + public unsafe NegotiateChannelBinding(ReadOnlyMemory channelBindingToken) + { + // ITlsConnectionFeature exposes managed bytes, but NegotiateAuthentication requires + // a ChannelBinding handle that remains valid throughout the authentication exchange. + Size = channelBindingToken.Length; + SetHandle(Marshal.AllocHGlobal(Size)); + using var pinnedToken = channelBindingToken.Pin(); + Buffer.MemoryCopy(pinnedToken.Pointer, (void*)handle, Size, Size); + } + + public override int Size { get; } + + protected override bool ReleaseHandle() + { + Marshal.FreeHGlobal(handle); + return true; + } +} diff --git a/src/Security/Authentication/Negotiate/src/Internal/NegotiateState.cs b/src/Security/Authentication/Negotiate/src/Internal/NegotiateState.cs index 2b0340fc16a7..2fc964570324 100644 --- a/src/Security/Authentication/Negotiate/src/Internal/NegotiateState.cs +++ b/src/Security/Authentication/Negotiate/src/Internal/NegotiateState.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net.Security; +using System.Security.Authentication.ExtendedProtection; using System.Security.Claims; using System.Security.Principal; @@ -10,11 +11,26 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate; internal sealed class NegotiateState : INegotiateState { private static readonly NegotiateAuthenticationServerOptions _serverOptions = new(); + private readonly ChannelBinding? _channelBinding; private readonly NegotiateAuthentication _instance; - public NegotiateState() + public NegotiateState(ReadOnlyMemory channelBindingToken) { - _instance = new NegotiateAuthentication(_serverOptions); + _channelBinding = channelBindingToken.IsEmpty ? null : new NegotiateChannelBinding(channelBindingToken); + + try + { + var serverOptions = _channelBinding is null + ? _serverOptions + : new NegotiateAuthenticationServerOptions { Binding = _channelBinding }; + _instance = new NegotiateAuthentication(serverOptions); + } + catch + { + // NegotiateAuthentication construction can fail after the binding has been allocated. + _channelBinding?.Dispose(); + throw; + } } public string? GetOutgoingBlob(string incomingBlob, out BlobErrorType status, out Exception? error) @@ -65,7 +81,14 @@ public IIdentity GetIdentity() public void Dispose() { - _instance.Dispose(); + try + { + _instance.Dispose(); + } + finally + { + _channelBinding?.Dispose(); + } } private static bool IsCredentialError(NegotiateAuthenticationStatusCode error) diff --git a/src/Security/Authentication/Negotiate/src/Internal/NegotiateStateFactory.cs b/src/Security/Authentication/Negotiate/src/Internal/NegotiateStateFactory.cs index 07bb5ff79610..fd15122d8adf 100644 --- a/src/Security/Authentication/Negotiate/src/Internal/NegotiateStateFactory.cs +++ b/src/Security/Authentication/Negotiate/src/Internal/NegotiateStateFactory.cs @@ -5,8 +5,8 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate; internal sealed class NegotiateStateFactory : INegotiateStateFactory { - public INegotiateState CreateInstance() + public INegotiateState CreateInstance(ReadOnlyMemory channelBindingToken) { - return new NegotiateState(); + return new NegotiateState(channelBindingToken); } } diff --git a/src/Security/Authentication/Negotiate/src/Microsoft.AspNetCore.Authentication.Negotiate.csproj b/src/Security/Authentication/Negotiate/src/Microsoft.AspNetCore.Authentication.Negotiate.csproj index cfa0110f73c3..8850b1adb6a5 100644 --- a/src/Security/Authentication/Negotiate/src/Microsoft.AspNetCore.Authentication.Negotiate.csproj +++ b/src/Security/Authentication/Negotiate/src/Microsoft.AspNetCore.Authentication.Negotiate.csproj @@ -6,6 +6,7 @@ true aspnetcore;authentication;security true + true diff --git a/src/Security/Authentication/Negotiate/src/NegotiateHandler.cs b/src/Security/Authentication/Negotiate/src/NegotiateHandler.cs index 3e343dabe837..582b156b3489 100644 --- a/src/Security/Authentication/Negotiate/src/NegotiateHandler.cs +++ b/src/Security/Authentication/Negotiate/src/NegotiateHandler.cs @@ -3,11 +3,13 @@ using System.Diagnostics; using System.Linq; +using System.Security.Authentication.ExtendedProtection; using System.Security.Claims; using System.Security.Principal; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Connections.Features; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; @@ -129,7 +131,7 @@ public async Task HandleRequestAsync() persistence?.State = null; } - _negotiateState ??= Options.StateFactory.CreateInstance(); + _negotiateState ??= Options.StateFactory.CreateInstance(GetChannelBindingToken()); var outgoing = _negotiateState.GetOutgoingBlob(token, out var errorType, out var exception); if (errorType != BlobErrorType.None) @@ -408,6 +410,18 @@ private AuthPersistence EstablishConnectionPersistence(IDictionary GetChannelBindingToken() + { + if (Request.IsHttps && + Context.Features.Get() is { } tlsConnectionFeature && + tlsConnectionFeature.TryGetChannelBindingBytes(ChannelBindingKind.Endpoint, out var channelBindingToken)) + { + return channelBindingToken; + } + + return default; + } + private void RegisterForConnectionDispose(IDisposable authState) { var connectionCompleteFeature = Context.Features.Get() diff --git a/src/Security/Authentication/Negotiate/test/Negotiate.Test/EventTests.cs b/src/Security/Authentication/Negotiate/test/Negotiate.Test/EventTests.cs index 0a9f556c8363..15396e487e3f 100644 --- a/src/Security/Authentication/Negotiate/test/Negotiate.Test/EventTests.cs +++ b/src/Security/Authentication/Negotiate/test/Negotiate.Test/EventTests.cs @@ -482,7 +482,7 @@ public void OnCompleted(Func callback, object state) private class TestNegotiateStateFactory : INegotiateStateFactory { - public INegotiateState CreateInstance() => new TestNegotiateState(); + public INegotiateState CreateInstance(ReadOnlyMemory channelBindingToken) => new TestNegotiateState(); } private class TestNegotiateState : INegotiateState diff --git a/src/Security/Authentication/Negotiate/test/Negotiate.Test/NegotiateChannelBindingTests.cs b/src/Security/Authentication/Negotiate/test/Negotiate.Test/NegotiateChannelBindingTests.cs new file mode 100644 index 000000000000..96a19b8f7434 --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/Negotiate.Test/NegotiateChannelBindingTests.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.InteropServices; + +namespace Microsoft.AspNetCore.Authentication.Negotiate; + +public class NegotiateChannelBindingTests +{ + [Fact] + public void Constructor_CopiesChannelBindingToken() + { + var channelBindingToken = new byte[] { 0x01, 0x23, 0x45, 0x67 }; + + using var channelBinding = new NegotiateChannelBinding(channelBindingToken); + channelBindingToken[0] = 0xff; + var copiedToken = new byte[channelBinding.Size]; + Marshal.Copy(channelBinding.DangerousGetHandle(), copiedToken, 0, copiedToken.Length); + + Assert.Equal(new byte[] { 0x01, 0x23, 0x45, 0x67 }, copiedToken); + } +} diff --git a/src/Security/Authentication/Negotiate/test/Negotiate.Test/NegotiateHandlerTests.cs b/src/Security/Authentication/Negotiate/test/Negotiate.Test/NegotiateHandlerTests.cs index 7b8e0b105f84..335eb64e076a 100644 --- a/src/Security/Authentication/Negotiate/test/Negotiate.Test/NegotiateHandlerTests.cs +++ b/src/Security/Authentication/Negotiate/test/Negotiate.Test/NegotiateHandlerTests.cs @@ -1,13 +1,16 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Security.Authentication.ExtendedProtection; using System.Security.Claims; +using System.Security.Cryptography.X509Certificates; using System.Security.Principal; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Connections.Features; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Caching.Memory; @@ -123,6 +126,74 @@ public async Task NtlmStage1And2Auth_Success(bool persistNtlm) await NtlmStage1And2Auth(server, testConnection); } + [Fact] + public async Task NtlmStage1And2Auth_HttpsEndpointChannelBinding_UsesSingleStateAndReadsChannelBindingOnce() + { + var expectedToken = new byte[] { 0x01, 0x23, 0x45, 0x67 }; + var factory = new TestNegotiateStateFactory(); + using var host = await CreateHostAsync(options => options.StateFactory = factory); + var server = host.GetTestServer(); + var connection = new TestConnection + { + IsHttps = true, + HasTlsConnectionFeature = true, + ChannelBindingAvailable = true, + ChannelBindingToken = expectedToken, + }; + + await NtlmStage1Auth(server, connection); + + Assert.Equal(ChannelBindingKind.Endpoint, Assert.Single(connection.RequestedKinds)); + Assert.Equal(expectedToken, Assert.Single(factory.ChannelBindingTokens).ToArray()); + Assert.Equal(1, factory.CreateCount); + Assert.Single(factory.CreatedStates); + + connection.ChannelBindingAvailable = false; + connection.ChannelBindingToken = new byte[] { 0x89 }; + await NtlmStage2Auth(server, connection); + + Assert.Single(factory.ChannelBindingTokens); + Assert.Equal(1, factory.CreateCount); + Assert.Single(factory.CreatedStates); + Assert.Equal(1, connection.ChannelBindingReadCount); + Assert.Equal(expectedToken, factory.ChannelBindingTokens[0].ToArray()); + } + + [Theory] + [InlineData(true, false, false, 0)] + [InlineData(true, true, false, 1)] + [InlineData(false, true, true, 0)] + public async Task NtlmStage1Auth_NoUsableChannelBinding_CreatesStateWithEmptyToken( + bool isHttps, + bool hasTlsConnectionFeature, + bool channelBindingAvailable, + int expectedTlsReads) + { + var factory = new TestNegotiateStateFactory(); + using var host = await CreateHostAsync(options => options.StateFactory = factory); + var server = host.GetTestServer(); + var connection = new TestConnection + { + IsHttps = isHttps, + HasTlsConnectionFeature = hasTlsConnectionFeature, + ChannelBindingAvailable = channelBindingAvailable, + ChannelBindingToken = new byte[] { 0x01 }, + }; + + await NtlmStage1Auth(server, connection); + + Assert.Single(factory.ChannelBindingTokens); + Assert.True(factory.ChannelBindingTokens[0].IsEmpty); + Assert.Equal(1, factory.CreateCount); + Assert.Single(factory.CreatedStates); + Assert.Equal(expectedTlsReads, connection.ChannelBindingReadCount); + Assert.Equal(expectedTlsReads, connection.RequestedKinds.Count); + if (expectedTlsReads == 1) + { + Assert.Equal(ChannelBindingKind.Endpoint, connection.RequestedKinds[0]); + } + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -498,22 +569,59 @@ private static Task SendAsync(TestServer server, string path, TestC { context.Features.Set(connection); context.Features.Set(connection); + if (connection.IsHttps) + { + context.Request.Scheme = "https"; + } + if (connection.HasTlsConnectionFeature) + { + context.Features.Set(connection); + } } }); } - private class TestConnection : IConnectionItemsFeature, IConnectionCompleteFeature + private class TestConnection : IConnectionItemsFeature, IConnectionCompleteFeature, ITlsConnectionFeature { public IDictionary Items { get; set; } = new ConnectionItems(); + public bool IsHttps { get; set; } + public bool HasTlsConnectionFeature { get; set; } + public bool ChannelBindingAvailable { get; set; } + public ReadOnlyMemory ChannelBindingToken { get; set; } + public int ChannelBindingReadCount { get; private set; } + public List RequestedKinds { get; } = new(); + public X509Certificate2 ClientCertificate { get; set; } public void OnCompleted(Func callback, object state) { } + + public Task GetClientCertificateAsync(CancellationToken cancellationToken) + => Task.FromResult(ClientCertificate); + + public bool TryGetChannelBindingBytes(ChannelBindingKind kind, out ReadOnlyMemory channelBindingToken) + { + ChannelBindingReadCount++; + RequestedKinds.Add(kind); + channelBindingToken = ChannelBindingAvailable ? ChannelBindingToken : default; + return ChannelBindingAvailable; + } } private class TestNegotiateStateFactory : INegotiateStateFactory { - public INegotiateState CreateInstance() => new TestNegotiateState(); + public int CreateCount { get; private set; } + public List> ChannelBindingTokens { get; } = new(); + public List CreatedStates { get; } = new(); + + public INegotiateState CreateInstance(ReadOnlyMemory channelBindingToken) + { + CreateCount++; + ChannelBindingTokens.Add(channelBindingToken.ToArray()); + var state = new TestNegotiateState(); + CreatedStates.Add(state); + return state; + } } private class TestNegotiateState : INegotiateState