From 72a62e78d746d522661be4089e944f46c0a7a277 Mon Sep 17 00:00:00 2001 From: Claude A Date: Mon, 14 Sep 2026 15:59:39 +0200 Subject: [PATCH 1/2] Add explicit Windows credential SSPI provider to the 2.x library Port of the SSPI half of PR 56 onto the restored 2.x main: upgrade Microsoft.Data.SqlClient to 7.0.1 with the dependencies it no longer brings transitively, and add NetworkCredentialSspiContextProvider (.NET 8 only, including the Principal accessor from PR 57). The C# connection-service plumbing from PR 56/57 is 3.0-only and stays on libmigration; on 2.x, Connect-DbaInstance wires the provider itself (dataplat/dbatools#10495) and degrades gracefully when the type is absent. Bumps ModuleVersion to 2026.9.14 so the first release cut from the restored main carries the provider. (do Connect-DbaInstance) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PRG7aY5eVNEXTw5ahQbGW9 --- dbatools.library.psd1 | 2 +- .../Connection/SqlClientCompatibilityTest.cs | 76 +++++++++ .../NetworkCredentialSspiContextProvider.cs | 151 ++++++++++++++++++ project/dbatools/dbatools.csproj | 6 +- 4 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 project/dbatools.Tests/Connection/SqlClientCompatibilityTest.cs create mode 100644 project/dbatools/Connection/NetworkCredentialSspiContextProvider.cs diff --git a/dbatools.library.psd1 b/dbatools.library.psd1 index faf09630..d88066f9 100644 --- a/dbatools.library.psd1 +++ b/dbatools.library.psd1 @@ -7,7 +7,7 @@ # @{ # Version number of this module. - ModuleVersion = '2026.5.3' + ModuleVersion = '2026.9.14' # ID used to uniquely identify this module GUID = '00b61a37-6c36-40d8-8865-ac0180288c84' diff --git a/project/dbatools.Tests/Connection/SqlClientCompatibilityTest.cs b/project/dbatools.Tests/Connection/SqlClientCompatibilityTest.cs new file mode 100644 index 00000000..ac6d025e --- /dev/null +++ b/project/dbatools.Tests/Connection/SqlClientCompatibilityTest.cs @@ -0,0 +1,76 @@ +using Microsoft.Data.SqlClient; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Net; + +namespace Dataplat.Dbatools.Connection +{ + [TestClass] + public class SqlClientCompatibilityTest + { + [TestMethod] + public void SqlConnectionExposesPluggableSspiProvider() + { + Assert.IsNotNull(typeof(SqlConnection).GetProperty("SspiContextProvider")); + } + + [TestMethod] + public void ActiveDirectoryAuthenticationProviderIsRegistered() + { + Assert.IsNotNull(SqlAuthenticationProvider.GetProvider(SqlAuthenticationMethod.ActiveDirectoryIntegrated)); + } + +#if NET8_0_OR_GREATER + [TestMethod] + public void NetworkCredentialSspiProviderCanBeAssignedToSqlConnection() + { + using (NetworkCredentialSspiContextProvider provider = new NetworkCredentialSspiContextProvider( + new NetworkCredential("user", "password", "domain"))) + using (SqlConnection connection = new SqlConnection()) + { + connection.SspiContextProvider = provider; + + Assert.AreSame(provider, connection.SspiContextProvider); + } + } + + [TestMethod] + public void NetworkCredentialSspiProviderRejectsNullCredential() + { + Assert.ThrowsExactly(() => new NetworkCredentialSspiContextProvider(null)); + } + + [TestMethod] + public void NetworkCredentialSspiProviderExposesPasswordFreePrincipal() + { + using (NetworkCredentialSspiContextProvider domainQualified = new NetworkCredentialSspiContextProvider( + new NetworkCredential("user", "password", "domain"))) + using (NetworkCredentialSspiContextProvider domainless = new NetworkCredentialSspiContextProvider( + new NetworkCredential("user", "password", string.Empty))) + { + Assert.AreEqual("domain\\user", domainQualified.Principal); + Assert.AreEqual("user", domainless.Principal); + } + } + + [TestMethod] + public void NetworkCredentialSspiProviderHasStablePoolIdentityPerCredential() + { + using (NetworkCredentialSspiContextProvider first = new NetworkCredentialSspiContextProvider( + new NetworkCredential("user", "password", "domain"))) + using (NetworkCredentialSspiContextProvider second = new NetworkCredentialSspiContextProvider( + new NetworkCredential("USER", "password", "DOMAIN"))) + using (NetworkCredentialSspiContextProvider different = new NetworkCredentialSspiContextProvider( + new NetworkCredential("other-user", "password", "domain"))) + using (NetworkCredentialSspiContextProvider differentPassword = new NetworkCredentialSspiContextProvider( + new NetworkCredential("user", "different-password", "domain"))) + { + Assert.AreEqual(first, second); + Assert.AreEqual(first.GetHashCode(), second.GetHashCode()); + Assert.AreNotEqual(first, different); + Assert.AreNotEqual(first, differentPassword); + } + } +#endif + } +} diff --git a/project/dbatools/Connection/NetworkCredentialSspiContextProvider.cs b/project/dbatools/Connection/NetworkCredentialSspiContextProvider.cs new file mode 100644 index 00000000..7b9fd058 --- /dev/null +++ b/project/dbatools/Connection/NetworkCredentialSspiContextProvider.cs @@ -0,0 +1,151 @@ +#if NET8_0_OR_GREATER +using Microsoft.Data.SqlClient; +using System; +using System.Buffers; +using System.Net; +using System.Net.Security; +using System.Security.Cryptography; +using System.Text; + +namespace Dataplat.Dbatools.Connection +{ + /// + /// Generates SQL Server integrated-authentication tokens from an explicit Windows credential. + /// + public sealed class NetworkCredentialSspiContextProvider : SspiContextProvider, IDisposable + { + private readonly NetworkCredential credential; + private readonly byte[] credentialIdentity; + private readonly string principal; + private NegotiateAuthentication authentication; + private string resource; + private bool disposed; + + /// + /// The case-insensitive Windows principal ("domain\user" or "user") this provider + /// authenticates as. Password-free, safe to use as part of a registry/cache key. + /// + public string Principal + { + get { return principal; } + } + + /// + /// Creates a provider that authenticates with the supplied Windows credential. + /// + /// The Windows credential used for Negotiate authentication. + public NetworkCredentialSspiContextProvider(NetworkCredential credential) + { + if (credential == null) + throw new ArgumentNullException(nameof(credential)); + + this.credential = new NetworkCredential(credential.UserName, credential.Password, credential.Domain); + principal = String.IsNullOrEmpty(credential.Domain) + ? credential.UserName + : credential.Domain + "\\" + credential.UserName; + using (SHA256 sha256 = SHA256.Create()) + { + credentialIdentity = sha256.ComputeHash(Encoding.UTF8.GetBytes( + principal.ToUpperInvariant() + "\0" + credential.Password)); + } + } + + /// + /// Compares providers by Windows principal and credential identity for the SqlClient pool key. + /// + public override bool Equals(object obj) + { + NetworkCredentialSspiContextProvider other = obj as NetworkCredentialSspiContextProvider; + return other != null && String.Equals(principal, other.principal, StringComparison.OrdinalIgnoreCase) && + CryptographicOperations.FixedTimeEquals(credentialIdentity, other.credentialIdentity); + } + + /// + /// Returns the case-insensitive Windows principal hash used by the SqlClient pool key. + /// Credential identity is intentionally excluded to avoid disclosing a password-derived hash value. + /// + public override int GetHashCode() + { + return StringComparer.OrdinalIgnoreCase.GetHashCode(principal); + } + + /// + protected override bool GenerateContext( + ReadOnlySpan incomingBlob, + IBufferWriter outgoingBlobWriter, + SspiAuthenticationParameters authParams) + { + if (disposed) + throw new ObjectDisposedException(nameof(NetworkCredentialSspiContextProvider)); + if (outgoingBlobWriter == null) + throw new ArgumentNullException(nameof(outgoingBlobWriter)); + if (authParams == null) + throw new ArgumentNullException(nameof(authParams)); + + if (authentication == null || authentication.IsAuthenticated || + !String.Equals(resource, authParams.Resource, StringComparison.Ordinal)) + { + if (authentication != null) + authentication.Dispose(); + + resource = authParams.Resource; + authentication = new NegotiateAuthentication(new NegotiateAuthenticationClientOptions + { + Package = "Negotiate", + TargetName = resource, + Credential = credential + }); + } + + try + { + NegotiateAuthenticationStatusCode statusCode; + byte[] outgoingBlob = authentication.GetOutgoingBlob(incomingBlob, out statusCode); + if (statusCode != NegotiateAuthenticationStatusCode.Completed && + statusCode != NegotiateAuthenticationStatusCode.ContinueNeeded) + { + ResetAuthentication(); + return false; + } + + if (outgoingBlob != null) + { + Span destination = outgoingBlobWriter.GetSpan(outgoingBlob.Length); + outgoingBlob.AsSpan().CopyTo(destination); + outgoingBlobWriter.Advance(outgoingBlob.Length); + } + + if (statusCode == NegotiateAuthenticationStatusCode.Completed) + ResetAuthentication(); + + return true; + } + catch + { + ResetAuthentication(); + throw; + } + } + + private void ResetAuthentication() + { + if (authentication != null) + authentication.Dispose(); + authentication = null; + resource = null; + } + + /// + /// Releases the active Negotiate authentication context. + /// + public void Dispose() + { + if (disposed) + return; + + disposed = true; + ResetAuthentication(); + } + } +} +#endif diff --git a/project/dbatools/dbatools.csproj b/project/dbatools/dbatools.csproj index 05f7c011..cdff1628 100644 --- a/project/dbatools/dbatools.csproj +++ b/project/dbatools/dbatools.csproj @@ -55,7 +55,11 @@ - + + + + + From 43e019db0fc0ebef2a2d262658313e82086cfcac Mon Sep 17 00:00:00 2001 From: Claude A Date: Mon, 14 Sep 2026 16:13:40 +0200 Subject: [PATCH 2/2] Update dependency ceilings to match libmigration The 6.x-only SqlClient ceiling predates PR 56, which validated 7.0.1 against the pinned SMO and DacFx packages (PS3 golden image, Azure matrix, three-OS package build). libmigration's table already says so; bring main's copy in line so it stops contradicting the csproj. (do Connect-DbaInstance) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PRG7aY5eVNEXTw5ahQbGW9 --- CLAUDE.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e54e07cd..644d8916 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,10 +127,10 @@ The library targets **both** `net472` (Windows PowerShell 5.1) and `net8.0` (Pow | Package | Ceiling | Why | |---------|---------|-----| -| Microsoft.Data.SqlClient | 6.x only | DacFx/SMO compiled against 6.x; 7.x causes type-load failures | -| Microsoft.PowerShell.SDK | 7.4.x only | 7.5+ requires net9.0 target change | -| MSTest.* | 3.x only | 4.x drops `Assert.ThrowsException()` on net472 | -| Microsoft.NET.Test.Sdk | 17.x only | 18.x aligns with MSTest 4.x ecosystem | +| Microsoft.Data.SqlClient | 7.x | 7.0.1 is validated with the pinned SMO and DacFx packages; revalidate their loaders before upgrading | +| Microsoft.PowerShell.SDK | 7.4.x | 7.5+ requires a net9.0 target change | +| MSTest.* | 3.x | 4.x drops `Assert.ThrowsException()` on net472 | +| Microsoft.NET.Test.Sdk | 17.x | 18.x aligns with the MSTest 4.x ecosystem | For full details and current versions, see the [dependency constraints memory](file://memory/dependency_constraints.md).