From e2d172aac54ba8ef4f0d914c1f650da83b18c20a Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 10 Aug 2026 13:46:41 +0200 Subject: [PATCH 1/3] Use TLS channel binding in Negotiate authentication Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/Internal/INegotiateStateFactory.cs | 2 +- .../src/Internal/NegotiateChannelBinding.cs | 26 ++++ .../Negotiate/src/Internal/NegotiateState.cs | 29 ++++- .../src/Internal/NegotiateStateFactory.cs | 4 +- .../Negotiate/src/NegotiateHandler.cs | 16 ++- .../test/Negotiate.Test/EventTests.cs | 2 +- .../NegotiateChannelBindingTests.cs | 22 ++++ .../Negotiate.Test/NegotiateHandlerTests.cs | 112 +++++++++++++++++- 8 files changed, 202 insertions(+), 11 deletions(-) create mode 100644 src/Security/Authentication/Negotiate/src/Internal/NegotiateChannelBinding.cs create mode 100644 src/Security/Authentication/Negotiate/test/Negotiate.Test/NegotiateChannelBindingTests.cs 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..b80f9e3dea53 --- /dev/null +++ b/src/Security/Authentication/Negotiate/src/Internal/NegotiateChannelBinding.cs @@ -0,0 +1,26 @@ +// 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 NegotiateChannelBinding(ReadOnlyMemory channelBindingToken) + { + var bytes = channelBindingToken.ToArray(); + Size = bytes.Length; + SetHandle(Marshal.AllocHGlobal(Size)); + Marshal.Copy(bytes, 0, handle, 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..d86618f6c4e5 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; @@ -9,12 +10,25 @@ 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 + { + _instance = new NegotiateAuthentication(new NegotiateAuthenticationServerOptions + { + Binding = _channelBinding, + }); + } + catch + { + _channelBinding?.Dispose(); + throw; + } } public string? GetOutgoingBlob(string incomingBlob, out BlobErrorType status, out Exception? error) @@ -65,7 +79,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/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 From 46d91c62a08a8716bb7ebc12769c305a6d3306f8 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 10 Aug 2026 14:31:24 +0200 Subject: [PATCH 2/3] Clarify Negotiate channel binding lifetime Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Negotiate/src/Internal/NegotiateChannelBinding.cs | 2 ++ .../Authentication/Negotiate/src/Internal/NegotiateState.cs | 1 + .../Authentication/Negotiate/src/NegotiateHandler.cs | 5 ++++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Security/Authentication/Negotiate/src/Internal/NegotiateChannelBinding.cs b/src/Security/Authentication/Negotiate/src/Internal/NegotiateChannelBinding.cs index b80f9e3dea53..b919b93e8711 100644 --- a/src/Security/Authentication/Negotiate/src/Internal/NegotiateChannelBinding.cs +++ b/src/Security/Authentication/Negotiate/src/Internal/NegotiateChannelBinding.cs @@ -10,6 +10,8 @@ internal sealed class NegotiateChannelBinding : ChannelBinding { public NegotiateChannelBinding(ReadOnlyMemory channelBindingToken) { + // ITlsConnectionFeature exposes managed bytes, but NegotiateAuthentication requires + // a ChannelBinding handle that remains valid throughout the authentication exchange. var bytes = channelBindingToken.ToArray(); Size = bytes.Length; SetHandle(Marshal.AllocHGlobal(Size)); diff --git a/src/Security/Authentication/Negotiate/src/Internal/NegotiateState.cs b/src/Security/Authentication/Negotiate/src/Internal/NegotiateState.cs index d86618f6c4e5..7af78115555f 100644 --- a/src/Security/Authentication/Negotiate/src/Internal/NegotiateState.cs +++ b/src/Security/Authentication/Negotiate/src/Internal/NegotiateState.cs @@ -26,6 +26,7 @@ public NegotiateState(ReadOnlyMemory channelBindingToken) } catch { + // NegotiateAuthentication construction can fail after the binding has been allocated. _channelBinding?.Dispose(); throw; } diff --git a/src/Security/Authentication/Negotiate/src/NegotiateHandler.cs b/src/Security/Authentication/Negotiate/src/NegotiateHandler.cs index 582b156b3489..fbc75909aed1 100644 --- a/src/Security/Authentication/Negotiate/src/NegotiateHandler.cs +++ b/src/Security/Authentication/Negotiate/src/NegotiateHandler.cs @@ -131,7 +131,10 @@ public async Task HandleRequestAsync() persistence?.State = null; } - _negotiateState ??= Options.StateFactory.CreateInstance(GetChannelBindingToken()); + if (_negotiateState is null) + { + _negotiateState = Options.StateFactory.CreateInstance(GetChannelBindingToken()); + } var outgoing = _negotiateState.GetOutgoingBlob(token, out var errorType, out var exception); if (errorType != BlobErrorType.None) From f45c32db9a8f419fa7a2f0947ebb477af91fb76d Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 12 Aug 2026 13:55:25 +0200 Subject: [PATCH 3/3] address PR comments --- .../Negotiate/src/Internal/NegotiateChannelBinding.cs | 8 ++++---- .../Negotiate/src/Internal/NegotiateState.cs | 9 +++++---- .../Microsoft.AspNetCore.Authentication.Negotiate.csproj | 1 + .../Authentication/Negotiate/src/NegotiateHandler.cs | 5 +---- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/Security/Authentication/Negotiate/src/Internal/NegotiateChannelBinding.cs b/src/Security/Authentication/Negotiate/src/Internal/NegotiateChannelBinding.cs index b919b93e8711..7d83af31662e 100644 --- a/src/Security/Authentication/Negotiate/src/Internal/NegotiateChannelBinding.cs +++ b/src/Security/Authentication/Negotiate/src/Internal/NegotiateChannelBinding.cs @@ -8,14 +8,14 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate; internal sealed class NegotiateChannelBinding : ChannelBinding { - public NegotiateChannelBinding(ReadOnlyMemory channelBindingToken) + public unsafe NegotiateChannelBinding(ReadOnlyMemory channelBindingToken) { // ITlsConnectionFeature exposes managed bytes, but NegotiateAuthentication requires // a ChannelBinding handle that remains valid throughout the authentication exchange. - var bytes = channelBindingToken.ToArray(); - Size = bytes.Length; + Size = channelBindingToken.Length; SetHandle(Marshal.AllocHGlobal(Size)); - Marshal.Copy(bytes, 0, handle, Size); + using var pinnedToken = channelBindingToken.Pin(); + Buffer.MemoryCopy(pinnedToken.Pointer, (void*)handle, Size, Size); } public override int Size { get; } diff --git a/src/Security/Authentication/Negotiate/src/Internal/NegotiateState.cs b/src/Security/Authentication/Negotiate/src/Internal/NegotiateState.cs index 7af78115555f..2fc964570324 100644 --- a/src/Security/Authentication/Negotiate/src/Internal/NegotiateState.cs +++ b/src/Security/Authentication/Negotiate/src/Internal/NegotiateState.cs @@ -10,6 +10,7 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate; internal sealed class NegotiateState : INegotiateState { + private static readonly NegotiateAuthenticationServerOptions _serverOptions = new(); private readonly ChannelBinding? _channelBinding; private readonly NegotiateAuthentication _instance; @@ -19,10 +20,10 @@ public NegotiateState(ReadOnlyMemory channelBindingToken) try { - _instance = new NegotiateAuthentication(new NegotiateAuthenticationServerOptions - { - Binding = _channelBinding, - }); + var serverOptions = _channelBinding is null + ? _serverOptions + : new NegotiateAuthenticationServerOptions { Binding = _channelBinding }; + _instance = new NegotiateAuthentication(serverOptions); } catch { 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 fbc75909aed1..582b156b3489 100644 --- a/src/Security/Authentication/Negotiate/src/NegotiateHandler.cs +++ b/src/Security/Authentication/Negotiate/src/NegotiateHandler.cs @@ -131,10 +131,7 @@ public async Task HandleRequestAsync() persistence?.State = null; } - if (_negotiateState is null) - { - _negotiateState = Options.StateFactory.CreateInstance(GetChannelBindingToken()); - } + _negotiateState ??= Options.StateFactory.CreateInstance(GetChannelBindingToken()); var outgoing = _negotiateState.GetOutgoingBlob(token, out var errorType, out var exception); if (errorType != BlobErrorType.None)