Skip to content

Commit 1290857

Browse files
committed
feat(mssql): support Integrated Security with native NTLMv2 handshake
1 parent 678deba commit 1290857

7 files changed

Lines changed: 338 additions & 19 deletions

File tree

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77
<Authors>vkuttyp</Authors>
88
<PackageLicenseExpression>MIT</PackageLicenseExpression>
99
<RepositoryUrl>https://github.com/vkuttyp/CosmoSQLClient-Dotnet</RepositoryUrl>
10-
<Version>1.5.9</Version>
10+
<Version>1.6.0</Version>
1111
</PropertyGroup>
1212
</Project>
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
using System.Security.Cryptography;
2+
using System.Text;
3+
4+
namespace CosmoSQLClient.MsSql.Auth;
5+
6+
/// <summary>
7+
/// Implements the NTLMv2 authentication protocol used by Windows domain auth.
8+
/// Ported from CosmoSQLClient-Swift.
9+
/// </summary>
10+
internal static class NtlmAuth
11+
{
12+
private static readonly byte[] NtlmSignature = { 0x4E, 0x54, 0x4C, 0x4D, 0x53, 0x53, 0x50, 0x00 };
13+
private const uint NegotiateFlags = 0x62088235;
14+
15+
public static byte[] BuildNegotiate()
16+
{
17+
using var ms = new MemoryStream();
18+
using var bw = new BinaryWriter(ms);
19+
20+
bw.Write(NtlmSignature);
21+
bw.Write(1u); // MessageType = 1
22+
bw.Write(NegotiateFlags);
23+
// DomainNameFields (len=0, maxLen=0, offset=0)
24+
bw.Write(new byte[8]);
25+
// WorkstationFields (len=0, maxLen=0, offset=0)
26+
bw.Write(new byte[8]);
27+
// Version (8 bytes: Windows 10 marker)
28+
bw.Write(new byte[] { 0x0A, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x00, 0x0F });
29+
30+
return ms.ToArray();
31+
}
32+
33+
public record NtlmChallenge(byte[] ServerChallenge, byte[] TargetInfo, uint Flags);
34+
35+
public static NtlmChallenge ParseChallenge(byte[] data)
36+
{
37+
if (data.Length < 56) throw new Exception("Invalid NTLM challenge length.");
38+
39+
for (int i = 0; i < 8; i++)
40+
if (data[i] != NtlmSignature[i]) throw new Exception("Invalid NTLM signature.");
41+
42+
uint type = BitConverter.ToUInt32(data, 8);
43+
if (type != 2) throw new Exception("Invalid NTLM message type.");
44+
45+
uint flags = BitConverter.ToUInt32(data, 20);
46+
byte[] challenge = new byte[8];
47+
Array.Copy(data, 24, challenge, 0, 8);
48+
49+
short tiLen = BitConverter.ToInt16(data, 40);
50+
int tiOffset = BitConverter.ToInt32(data, 44);
51+
byte[] targetInfo = new byte[0];
52+
if (tiLen > 0 && tiOffset + tiLen <= data.Length)
53+
{
54+
targetInfo = new byte[tiLen];
55+
Array.Copy(data, tiOffset, targetInfo, 0, tiLen);
56+
}
57+
58+
return new NtlmChallenge(challenge, targetInfo, flags);
59+
}
60+
61+
public static byte[] BuildAuthenticate(
62+
byte[] challengeData,
63+
string username,
64+
string password,
65+
string domain,
66+
string workstation)
67+
{
68+
var challenge = ParseChallenge(challengeData);
69+
70+
// Derive NTLMv2 key
71+
byte[] ntHash = ComputeMd4(Encoding.Unicode.GetBytes(password));
72+
byte[] identity = Encoding.Unicode.GetBytes(username.ToUpperInvariant() + domain);
73+
byte[] ntlmv2Key = ComputeHmacMd5(ntHash, identity);
74+
75+
// NTLMv2 blob
76+
byte[] clientChallenge = new byte[8];
77+
using (var rng = RandomNumberGenerator.Create()) { rng.GetBytes(clientChallenge); }
78+
79+
byte[] timestamp = GetWindowsTimestamp();
80+
byte[] blob = BuildBlob(timestamp, clientChallenge, challenge.TargetInfo);
81+
82+
// NT response = HMAC-MD5(ntlmv2Key, serverChallenge + blob) + blob
83+
byte[] challengeAndBlob = new byte[8 + blob.Length];
84+
Array.Copy(challenge.ServerChallenge, 0, challengeAndBlob, 0, 8);
85+
Array.Copy(blob, 0, challengeAndBlob, 8, blob.Length);
86+
87+
byte[] ntProofStr = ComputeHmacMd5(ntlmv2Key, challengeAndBlob);
88+
byte[] ntResponse = new byte[ntProofStr.Length + blob.Length];
89+
Array.Copy(ntProofStr, 0, ntResponse, 0, ntProofStr.Length);
90+
Array.Copy(blob, 0, ntResponse, ntProofStr.Length, blob.Length);
91+
92+
// LM response = HMAC-MD5(ntlmv2Key, serverChallenge + clientChallenge) + clientChallenge
93+
byte[] challengeAndClient = new byte[8 + 8];
94+
Array.Copy(challenge.ServerChallenge, 0, challengeAndClient, 0, 8);
95+
Array.Copy(clientChallenge, 0, challengeAndClient, 8, 8);
96+
byte[] lmProofStr = ComputeHmacMd5(ntlmv2Key, challengeAndClient);
97+
byte[] lmResponse = new byte[lmProofStr.Length + 8];
98+
Array.Copy(lmProofStr, 0, lmResponse, 0, lmProofStr.Length);
99+
Array.Copy(clientChallenge, 0, lmResponse, lmProofStr.Length, 8);
100+
101+
byte[] domainBytes = Encoding.Unicode.GetBytes(domain);
102+
byte[] userBytes = Encoding.Unicode.GetBytes(username);
103+
byte[] wsBytes = Encoding.Unicode.GetBytes(workstation);
104+
105+
int headerSize = 72;
106+
using var ms = new MemoryStream();
107+
using var bw = new BinaryWriter(ms);
108+
109+
var payload = new List<byte[]>();
110+
111+
byte[] BuildSecBuf(byte[] data)
112+
{
113+
short len = (short)data.Length;
114+
int offset = headerSize + payload.Sum(p => p.Length);
115+
payload.Add(data);
116+
117+
var buf = new byte[8];
118+
Array.Copy(BitConverter.GetBytes(len), 0, buf, 0, 2);
119+
Array.Copy(BitConverter.GetBytes(len), 0, buf, 2, 2);
120+
Array.Copy(BitConverter.GetBytes(offset), 0, buf, 4, 4);
121+
return buf;
122+
}
123+
124+
byte[] lmBuf = BuildSecBuf(lmResponse);
125+
byte[] ntBuf = BuildSecBuf(ntResponse);
126+
byte[] domBuf = BuildSecBuf(domainBytes);
127+
byte[] usrBuf = BuildSecBuf(userBytes);
128+
byte[] wsBuf = BuildSecBuf(wsBytes);
129+
130+
bw.Write(NtlmSignature);
131+
bw.Write(3u); // MessageType = 3
132+
bw.Write(lmBuf);
133+
bw.Write(ntBuf);
134+
bw.Write(domBuf);
135+
bw.Write(usrBuf);
136+
bw.Write(wsBuf);
137+
bw.Write(new byte[8]); // EncryptedRandomSessionKey
138+
bw.Write(NegotiateFlags);
139+
140+
foreach (var p in payload) bw.Write(p);
141+
142+
return ms.ToArray();
143+
}
144+
145+
private static byte[] BuildBlob(byte[] timestamp, byte[] clientChallenge, byte[] targetInfo)
146+
{
147+
using var ms = new MemoryStream();
148+
using var bw = new BinaryWriter(ms);
149+
150+
bw.Write(new byte[] { 0x01, 0x01, 0x00, 0x00 }); // Blob signature
151+
bw.Write(0u); // Reserved
152+
bw.Write(timestamp);
153+
bw.Write(clientChallenge);
154+
bw.Write(0u); // Reserved
155+
bw.Write(targetInfo);
156+
bw.Write(0u); // MsvAvEOL
157+
158+
return ms.ToArray();
159+
}
160+
161+
private static byte[] GetWindowsTimestamp()
162+
{
163+
return BitConverter.GetBytes(DateTime.UtcNow.ToFileTimeUtc());
164+
}
165+
166+
private static byte[] ComputeMd4(byte[] input)
167+
{
168+
return MD4.Hash(input);
169+
}
170+
171+
private static byte[] ComputeHmacMd5(byte[] key, byte[] data)
172+
{
173+
using var hmac = new HMACMD5(key);
174+
return hmac.ComputeHash(data);
175+
}
176+
}
177+
178+
internal static class MD4
179+
{
180+
public static byte[] Hash(byte[] input)
181+
{
182+
uint a = 0x67452301;
183+
uint b = 0xefcdab89;
184+
uint c = 0x98badcfe;
185+
uint d = 0x10325476;
186+
187+
byte[] data = new byte[((input.Length + 8) / 64 + 1) * 64];
188+
Array.Copy(input, data, input.Length);
189+
data[input.Length] = 0x80;
190+
191+
byte[] lengthBits = BitConverter.GetBytes((ulong)input.Length * 8);
192+
Array.Copy(lengthBits, 0, data, data.Length - 8, 8);
193+
194+
for (int i = 0; i < data.Length; i += 64)
195+
{
196+
uint[] x = new uint[16];
197+
for (int j = 0; j < 16; j++)
198+
x[j] = BitConverter.ToUInt32(data, i + j * 4);
199+
200+
uint aa = a, bb = b, cc = c, dd = d;
201+
202+
a = Round1(a, b, c, d, x[0], 3); d = Round1(d, a, b, c, x[1], 7);
203+
c = Round1(c, d, a, b, x[2], 11); b = Round1(b, c, d, a, x[3], 19);
204+
a = Round1(a, b, c, d, x[4], 3); d = Round1(d, a, b, c, x[5], 7);
205+
c = Round1(c, d, a, b, x[6], 11); b = Round1(b, c, d, a, x[7], 19);
206+
a = Round1(a, b, c, d, x[8], 3); d = Round1(d, a, b, c, x[9], 7);
207+
c = Round1(c, d, a, b, x[10], 11); b = Round1(b, c, d, a, x[11], 19);
208+
a = Round1(a, b, c, d, x[12], 3); d = Round1(d, a, b, c, x[13], 7);
209+
c = Round1(c, d, a, b, x[14], 11); b = Round1(b, c, d, a, x[15], 19);
210+
211+
a = Round2(a, b, c, d, x[0], 3); d = Round2(d, a, b, c, x[4], 5);
212+
c = Round2(c, d, a, b, x[8], 9); b = Round2(b, c, d, a, x[12], 13);
213+
a = Round2(a, b, c, d, x[1], 3); d = Round2(d, a, b, c, x[5], 5);
214+
c = Round2(c, d, a, b, x[9], 9); b = Round2(b, c, d, a, x[13], 13);
215+
a = Round2(a, b, c, d, x[2], 3); d = Round2(d, a, b, c, x[6], 5);
216+
c = Round2(c, d, a, b, x[10], 9); b = Round2(b, c, d, a, x[14], 13);
217+
a = Round2(a, b, c, d, x[3], 3); d = Round2(d, a, b, c, x[7], 5);
218+
c = Round2(c, d, a, b, x[11], 9); b = Round2(b, c, d, a, x[15], 13);
219+
220+
a = Round3(a, b, c, d, x[0], 3); d = Round3(d, a, b, c, x[8], 9);
221+
c = Round3(c, d, a, b, x[4], 11); b = Round3(b, c, d, a, x[12], 15);
222+
a = Round3(a, b, c, d, x[2], 3); d = Round3(d, a, b, c, x[10], 9);
223+
c = Round3(c, d, a, b, x[6], 11); b = Round3(b, c, d, a, x[14], 15);
224+
a = Round3(a, b, c, d, x[1], 3); d = Round3(d, a, b, c, x[9], 9);
225+
c = Round3(c, d, a, b, x[5], 11); b = Round3(b, c, d, a, x[13], 15);
226+
a = Round3(a, b, c, d, x[3], 3); d = Round3(d, a, b, c, x[11], 9);
227+
c = Round3(c, d, a, b, x[7], 11); b = Round3(b, c, d, a, x[15], 15);
228+
229+
a += aa; b += bb; c += cc; d += dd;
230+
}
231+
232+
byte[] res = new byte[16];
233+
Array.Copy(BitConverter.GetBytes(a), 0, res, 0, 4);
234+
Array.Copy(BitConverter.GetBytes(b), 0, res, 4, 4);
235+
Array.Copy(BitConverter.GetBytes(c), 0, res, 8, 4);
236+
Array.Copy(BitConverter.GetBytes(d), 0, res, 12, 4);
237+
return res;
238+
}
239+
240+
private static uint Round1(uint a, uint b, uint c, uint d, uint x, int s) => Rol(a + ((b & c) | (~b & d)) + x, s);
241+
private static uint Round2(uint a, uint b, uint c, uint d, uint x, int s) => Rol(a + ((b & c) | (b & d) | (c & d)) + x + 0x5a827999, s);
242+
private static uint Round3(uint a, uint b, uint c, uint d, uint x, int s) => Rol(a + (b ^ c ^ d) + x + 0x6ed9eba1, s);
243+
private static uint Rol(uint x, int n) => (x << n) | (x >> (32 - n));
244+
}

src/CosmoSQLClient.MsSql/MsSqlConnection.cs

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using System.Security.Cryptography.X509Certificates;
99
using System.Text;
1010
using CosmoSQLClient.Core;
11+
using CosmoSQLClient.MsSql.Auth;
1112
using CosmoSQLClient.MsSql.Tds;
1213

1314
namespace CosmoSQLClient.MsSql;
@@ -656,26 +657,62 @@ await WritePacketsAsync(TdsPacketType.PreLogin, TdsPreLogin.BuildRequest(wantEnc
656657
}
657658

658659
// ── Login7 ────────────────────────────────────────────────────────────
659-
if (_config.IntegratedSecurity && string.IsNullOrEmpty(_config.Username))
660-
{
661-
throw SqlException.Auth("Integrated Security (SSPI) is not yet supported by this driver. Please provide a User Id and Password.");
662-
}
663-
664-
var loginPayload = TdsLogin7.Build(new TdsLogin7.Options
660+
bool useNtlm = _config.IntegratedSecurity || !string.IsNullOrEmpty(_config.Domain);
661+
662+
var loginOptions = new TdsLogin7.Options
665663
{
666664
Host = _config.Host,
667665
Database = _config.Database,
668-
Username = _config.Username ?? string.Empty,
669-
Password = _config.Password ?? string.Empty,
666+
Username = useNtlm ? string.Empty : (_config.Username ?? string.Empty),
667+
Password = useNtlm ? string.Empty : (_config.Password ?? string.Empty),
670668
Domain = _config.Domain,
671669
AppName = _config.AppName,
672-
});
670+
};
671+
672+
var loginPayload = TdsLogin7.Build(loginOptions);
673+
674+
// If NTLM is requested, we send NTLM_NEGOTIATE in the SSPI field of Login7
675+
if (useNtlm)
676+
{
677+
// We need to inject the SSPI data into the Login7 payload.
678+
loginOptions = loginOptions with { SspiData = NtlmAuth.BuildNegotiate() };
679+
loginPayload = TdsLogin7.Build(loginOptions);
680+
}
673681

674682
await WritePacketsAsync(TdsPacketType.Login7, loginPayload).ConfigureAwait(false);
675683
var loginPayloadResp = await ReceiveAsync(ct).ConfigureAwait(false);
676-
677684
var loginTokens = TdsDecoder.Decode(loginPayloadResp);
678685

686+
if (useNtlm)
687+
{
688+
// Step 2: Server sends NTLM_CHALLENGE in an SSPI token (0xED)
689+
var sspiToken = loginTokens.OfType<TdsSspi>().FirstOrDefault();
690+
if (sspiToken == null)
691+
{
692+
RaiseMessages(loginTokens);
693+
throw SqlException.Auth("NTLM challenge not received from server.");
694+
}
695+
696+
// Step 3: Client sends NTLM_AUTHENTICATE
697+
string effectiveUser = _config.Username ?? string.Empty;
698+
if (string.IsNullOrEmpty(effectiveUser)) effectiveUser = Environment.UserName;
699+
700+
string effectivePass = _config.Password ?? string.Empty;
701+
string effectiveDom = _config.Domain ?? string.Empty;
702+
703+
var authBlob = NtlmAuth.BuildAuthenticate(
704+
sspiToken.Data,
705+
effectiveUser,
706+
effectivePass,
707+
effectiveDom,
708+
Environment.MachineName
709+
);
710+
711+
await WritePacketsAsync(TdsPacketType.SspiAuth, authBlob).ConfigureAwait(false);
712+
var authRespPayload = await ReceiveAsync(ct).ConfigureAwait(false);
713+
loginTokens = TdsDecoder.Decode(authRespPayload);
714+
}
715+
679716
// If the server returned a LoginAck the connection is established, even if it
680717
// also sent error 4064 ("cannot open database X, using default instead").
681718
// In that case treat error messages as info and don't throw.

src/CosmoSQLClient.MsSql/Tds/TdsDecoder.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,10 @@ internal static (List<object> Decoded, int NewOffset, List<TdsColumnMeta> ColMet
193193
results.Add(ReadLoginAck(ref r));
194194
break;
195195

196+
case TdsTokenType.SspiMessage:
197+
results.Add(ReadSspi(ref r));
198+
break;
199+
196200
// Length-prefixed tokens that are not needed — skip over them.
197201
case TdsTokenType.Order:
198202
case TdsTokenType.ColInfo:
@@ -235,6 +239,13 @@ private static TdsLoginAck ReadLoginAck(ref SpanReader r)
235239
return new TdsLoginAck(serverName);
236240
}
237241

242+
private static TdsSspi ReadSspi(ref SpanReader r)
243+
{
244+
var len = r.ReadUInt16();
245+
var data = r.ReadBytes(len).ToArray();
246+
return new TdsSspi(data);
247+
}
248+
238249
private static TdsEnvChange ReadEnvChange(ref SpanReader r)
239250
{
240251
// Read the full body using the token length so we never get out of sync.

0 commit comments

Comments
 (0)