| | | 1 | | // Licensed to the .NET Foundation under one or more agreements. |
| | | 2 | | // The .NET Foundation licenses this file to you under the MIT license. |
| | | 3 | | |
| | | 4 | | using System.Collections.Generic; |
| | | 5 | | using System.Diagnostics; |
| | | 6 | | using System.IO; |
| | | 7 | | using System.Net; |
| | | 8 | | using System.Net.Http.Headers; |
| | | 9 | | using System.Security.Cryptography; |
| | | 10 | | using System.Text; |
| | | 11 | | using System.Threading.Tasks; |
| | | 12 | | |
| | | 13 | | namespace System.Net.Http |
| | | 14 | | { |
| | | 15 | | internal static partial class AuthenticationHelper |
| | | 16 | | { |
| | | 17 | | // Define digest constants |
| | | 18 | | private const string Qop = "qop"; |
| | | 19 | | private const string Auth = "auth"; |
| | | 20 | | private const string AuthInt = "auth-int"; |
| | | 21 | | private const string Domain = "domain"; |
| | | 22 | | private const string Nonce = "nonce"; |
| | | 23 | | private const string NC = "nc"; |
| | | 24 | | private const string Realm = "realm"; |
| | | 25 | | private const string UserHash = "userhash"; |
| | | 26 | | private const string Username = "username"; |
| | | 27 | | private const string UsernameStar = "username*"; |
| | | 28 | | private const string Algorithm = "algorithm"; |
| | | 29 | | private const string Uri = "uri"; |
| | | 30 | | private const string Sha256 = "SHA-256"; |
| | | 31 | | private const string Md5 = "MD5"; |
| | | 32 | | private const string Sha256Sess = "SHA-256-sess"; |
| | | 33 | | private const string MD5Sess = "MD5-sess"; |
| | | 34 | | private const string CNonce = "cnonce"; |
| | | 35 | | private const string Opaque = "opaque"; |
| | | 36 | | private const string Response = "response"; |
| | | 37 | | private const string Stale = "stale"; |
| | | 38 | | |
| | | 39 | | public static async Task<string?> GetDigestTokenForCredential(NetworkCredential credential, HttpRequestMessage r |
| | 0 | 40 | | { |
| | 0 | 41 | | StringBuilder sb = StringBuilderCache.Acquire(); |
| | | 42 | | |
| | | 43 | | // It is mandatory for servers to implement sha-256 per RFC 7616 |
| | | 44 | | // Keep MD5 for backward compatibility. |
| | | 45 | | string? algorithm; |
| | 0 | 46 | | bool isAlgorithmSpecified = digestResponse.Parameters.TryGetValue(Algorithm, out algorithm); |
| | 0 | 47 | | if (isAlgorithmSpecified) |
| | 0 | 48 | | { |
| | 0 | 49 | | if (!algorithm!.Equals(Sha256, StringComparison.OrdinalIgnoreCase) && |
| | 0 | 50 | | !algorithm.Equals(Md5, StringComparison.OrdinalIgnoreCase) && |
| | 0 | 51 | | !algorithm.Equals(Sha256Sess, StringComparison.OrdinalIgnoreCase) && |
| | 0 | 52 | | !algorithm.Equals(MD5Sess, StringComparison.OrdinalIgnoreCase)) |
| | 0 | 53 | | { |
| | 0 | 54 | | if (NetEventSource.Log.IsEnabled()) NetEventSource.Error(digestResponse, $"Algorithm not supported: |
| | 0 | 55 | | return null; |
| | | 56 | | } |
| | 0 | 57 | | } |
| | | 58 | | else |
| | 0 | 59 | | { |
| | 0 | 60 | | algorithm = Md5; |
| | 0 | 61 | | } |
| | | 62 | | |
| | | 63 | | // Check if nonce is there in challenge |
| | | 64 | | string? nonce; |
| | 0 | 65 | | if (!digestResponse.Parameters.TryGetValue(Nonce, out nonce)) |
| | 0 | 66 | | { |
| | 0 | 67 | | if (NetEventSource.Log.IsEnabled()) NetEventSource.Error(digestResponse, "Nonce missing"); |
| | 0 | 68 | | return null; |
| | | 69 | | } |
| | | 70 | | |
| | | 71 | | // opaque token may or may not exist |
| | | 72 | | string? opaque; |
| | 0 | 73 | | digestResponse.Parameters.TryGetValue(Opaque, out opaque); |
| | | 74 | | |
| | | 75 | | string? realm; |
| | 0 | 76 | | if (!digestResponse.Parameters.TryGetValue(Realm, out realm)) |
| | 0 | 77 | | { |
| | 0 | 78 | | if (NetEventSource.Log.IsEnabled()) NetEventSource.Error(digestResponse, "Realm missing"); |
| | 0 | 79 | | return null; |
| | | 80 | | } |
| | | 81 | | |
| | | 82 | | // Add username |
| | | 83 | | string? userhash; |
| | 0 | 84 | | if (digestResponse.Parameters.TryGetValue(UserHash, out userhash) && userhash == "true") |
| | 0 | 85 | | { |
| | 0 | 86 | | sb.AppendKeyValue(Username, ComputeHash(credential.UserName + ":" + realm, algorithm)); |
| | 0 | 87 | | sb.AppendKeyValue(UserHash, userhash, includeQuotes: false); |
| | 0 | 88 | | } |
| | | 89 | | else |
| | 0 | 90 | | { |
| | 0 | 91 | | if (!Ascii.IsValid(credential.UserName)) |
| | 0 | 92 | | { |
| | 0 | 93 | | string usernameStar = HeaderUtilities.Encode5987(credential.UserName); |
| | 0 | 94 | | sb.AppendKeyValue(UsernameStar, usernameStar, includeQuotes: false); |
| | 0 | 95 | | } |
| | | 96 | | else |
| | 0 | 97 | | { |
| | 0 | 98 | | sb.AppendKeyValue(Username, credential.UserName); |
| | 0 | 99 | | } |
| | 0 | 100 | | } |
| | | 101 | | |
| | | 102 | | // Add realm |
| | 0 | 103 | | sb.AppendKeyValue(Realm, realm); |
| | | 104 | | |
| | | 105 | | // Add nonce |
| | 0 | 106 | | sb.AppendKeyValue(Nonce, nonce); |
| | | 107 | | |
| | 0 | 108 | | Debug.Assert(request.RequestUri != null); |
| | | 109 | | // Add uri |
| | 0 | 110 | | sb.AppendKeyValue(Uri, request.RequestUri.PathAndQuery); |
| | | 111 | | |
| | | 112 | | // Set qop, default is auth |
| | 0 | 113 | | string qop = Auth; |
| | 0 | 114 | | bool isQopSpecified = digestResponse.Parameters.ContainsKey(Qop); |
| | 0 | 115 | | if (isQopSpecified) |
| | 0 | 116 | | { |
| | | 117 | | // Check if auth-int present in qop string |
| | 0 | 118 | | int index1 = digestResponse.Parameters[Qop].IndexOf(AuthInt, StringComparison.Ordinal); |
| | 0 | 119 | | if (index1 != -1) |
| | 0 | 120 | | { |
| | | 121 | | // Get index of auth if present in qop string |
| | 0 | 122 | | int index2 = digestResponse.Parameters[Qop].IndexOf(Auth, StringComparison.Ordinal); |
| | | 123 | | |
| | | 124 | | // If index2 < index1, auth option is available |
| | | 125 | | // If index2 == index1, check if auth option available later in string after auth-int. |
| | 0 | 126 | | if (index2 == index1) |
| | 0 | 127 | | { |
| | 0 | 128 | | index2 = digestResponse.Parameters[Qop].IndexOf(Auth, index1 + AuthInt.Length, StringComparison. |
| | 0 | 129 | | if (index2 == -1) |
| | 0 | 130 | | { |
| | 0 | 131 | | qop = AuthInt; |
| | 0 | 132 | | } |
| | 0 | 133 | | } |
| | 0 | 134 | | } |
| | 0 | 135 | | } |
| | | 136 | | |
| | | 137 | | // Set cnonce |
| | 0 | 138 | | string cnonce = GetRandomAlphaNumericString(); |
| | | 139 | | |
| | | 140 | | // Calculate response |
| | 0 | 141 | | string a1 = credential.UserName + ":" + realm + ":" + credential.Password; |
| | 0 | 142 | | if (algorithm.EndsWith("sess", StringComparison.OrdinalIgnoreCase)) |
| | 0 | 143 | | { |
| | 0 | 144 | | a1 = ComputeHash(a1, algorithm) + ":" + nonce + ":" + cnonce; |
| | 0 | 145 | | } |
| | | 146 | | |
| | 0 | 147 | | string a2 = request.Method.Method + ":" + request.RequestUri.PathAndQuery; |
| | 0 | 148 | | if (qop == AuthInt) |
| | 0 | 149 | | { |
| | 0 | 150 | | string content = request.Content == null ? string.Empty : await request.Content.ReadAsStringAsync().Conf |
| | 0 | 151 | | a2 = a2 + ":" + ComputeHash(content, algorithm); |
| | 0 | 152 | | } |
| | | 153 | | |
| | | 154 | | string response; |
| | 0 | 155 | | if (isQopSpecified) |
| | 0 | 156 | | { |
| | 0 | 157 | | response = ComputeHash(ComputeHash(a1, algorithm) + ":" + |
| | 0 | 158 | | nonce + ":" + |
| | 0 | 159 | | DigestResponse.NonceCount + ":" + |
| | 0 | 160 | | cnonce + ":" + |
| | 0 | 161 | | qop + ":" + |
| | 0 | 162 | | ComputeHash(a2, algorithm), algorithm); |
| | 0 | 163 | | } |
| | | 164 | | else |
| | 0 | 165 | | { |
| | 0 | 166 | | response = ComputeHash(ComputeHash(a1, algorithm) + ":" + |
| | 0 | 167 | | nonce + ":" + |
| | 0 | 168 | | ComputeHash(a2, algorithm), algorithm); |
| | 0 | 169 | | } |
| | | 170 | | |
| | | 171 | | // Add response |
| | 0 | 172 | | sb.AppendKeyValue(Response, response, includeComma: opaque != null || isAlgorithmSpecified || isQopSpecified |
| | | 173 | | |
| | | 174 | | // Add opaque |
| | 0 | 175 | | if (opaque != null) |
| | 0 | 176 | | { |
| | 0 | 177 | | sb.AppendKeyValue(Opaque, opaque, includeComma: isAlgorithmSpecified || isQopSpecified); |
| | 0 | 178 | | } |
| | | 179 | | |
| | 0 | 180 | | if (isAlgorithmSpecified) |
| | 0 | 181 | | { |
| | | 182 | | // Add algorithm |
| | 0 | 183 | | sb.AppendKeyValue(Algorithm, algorithm, includeQuotes: false, includeComma: isQopSpecified); |
| | 0 | 184 | | } |
| | | 185 | | |
| | 0 | 186 | | if (isQopSpecified) |
| | 0 | 187 | | { |
| | | 188 | | // Add qop |
| | 0 | 189 | | sb.AppendKeyValue(Qop, qop, includeQuotes: false); |
| | | 190 | | |
| | | 191 | | // Add nc |
| | 0 | 192 | | sb.AppendKeyValue(NC, DigestResponse.NonceCount, includeQuotes: false); |
| | | 193 | | |
| | | 194 | | // Add cnonce |
| | 0 | 195 | | sb.AppendKeyValue(CNonce, cnonce, includeComma: false); |
| | 0 | 196 | | } |
| | | 197 | | |
| | 0 | 198 | | return StringBuilderCache.GetStringAndRelease(sb); |
| | 0 | 199 | | } |
| | | 200 | | |
| | | 201 | | public static bool IsServerNonceStale(DigestResponse digestResponse) |
| | 0 | 202 | | { |
| | 0 | 203 | | return digestResponse.Parameters.TryGetValue(Stale, out string? stale) && stale == "true"; |
| | 0 | 204 | | } |
| | | 205 | | |
| | | 206 | | private static string GetRandomAlphaNumericString() |
| | 0 | 207 | | { |
| | | 208 | | const int Length = 16; |
| | | 209 | | const string CharacterSet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; |
| | 0 | 210 | | return RandomNumberGenerator.GetString(CharacterSet, Length); |
| | 0 | 211 | | } |
| | | 212 | | |
| | | 213 | | private static string ComputeHash(string data, string algorithm) |
| | 0 | 214 | | { |
| | 0 | 215 | | Span<byte> hashBuffer = stackalloc byte[SHA256.HashSizeInBytes]; // SHA256 is the largest hash produced |
| | 0 | 216 | | byte[] dataBytes = Encoding.UTF8.GetBytes(data); |
| | | 217 | | int written; |
| | | 218 | | |
| | 0 | 219 | | if (algorithm.StartsWith(Sha256, StringComparison.OrdinalIgnoreCase)) |
| | 0 | 220 | | { |
| | 0 | 221 | | written = SHA256.HashData(dataBytes, hashBuffer); |
| | 0 | 222 | | Debug.Assert(written == SHA256.HashSizeInBytes); |
| | 0 | 223 | | } |
| | | 224 | | else |
| | 0 | 225 | | { |
| | | 226 | | // Disable MD5 insecure warning. |
| | | 227 | | #pragma warning disable CA5351 |
| | 0 | 228 | | written = MD5.HashData(dataBytes, hashBuffer); |
| | 0 | 229 | | Debug.Assert(written == MD5.HashSizeInBytes); |
| | | 230 | | #pragma warning restore CA5351 |
| | 0 | 231 | | } |
| | | 232 | | |
| | 0 | 233 | | return Convert.ToHexStringLower(hashBuffer.Slice(0, written)); |
| | 0 | 234 | | } |
| | | 235 | | |
| | | 236 | | internal sealed class DigestResponse |
| | | 237 | | { |
| | 0 | 238 | | internal readonly Dictionary<string, string> Parameters = new Dictionary<string, string>(StringComparer.Ordi |
| | | 239 | | internal const string NonceCount = "00000001"; |
| | | 240 | | |
| | 0 | 241 | | internal DigestResponse(string? challenge) |
| | 0 | 242 | | { |
| | 0 | 243 | | if (!string.IsNullOrEmpty(challenge)) |
| | 0 | 244 | | Parse(challenge); |
| | 0 | 245 | | } |
| | | 246 | | |
| | | 247 | | private static bool CharIsSpaceOrTab(char ch) |
| | 0 | 248 | | { |
| | 0 | 249 | | return ch == ' ' || ch == '\t'; |
| | 0 | 250 | | } |
| | | 251 | | |
| | | 252 | | private static bool MustValueBeQuoted(string key) |
| | 0 | 253 | | { |
| | | 254 | | // As per the RFC, these string must be quoted for historical reasons. |
| | 0 | 255 | | return key.Equals(Realm, StringComparison.OrdinalIgnoreCase) || key.Equals(Nonce, StringComparison.Ordin |
| | 0 | 256 | | key.Equals(Opaque, StringComparison.OrdinalIgnoreCase) || key.Equals(Qop, StringComparison.OrdinalIg |
| | 0 | 257 | | } |
| | | 258 | | |
| | | 259 | | private static string? GetNextKey(string data, int currentIndex, out int parsedIndex) |
| | 0 | 260 | | { |
| | | 261 | | // Skip leading space or tab. |
| | 0 | 262 | | while (currentIndex < data.Length && CharIsSpaceOrTab(data[currentIndex])) |
| | 0 | 263 | | { |
| | 0 | 264 | | currentIndex++; |
| | 0 | 265 | | } |
| | | 266 | | |
| | | 267 | | // Start parsing key |
| | 0 | 268 | | int start = currentIndex; |
| | | 269 | | |
| | | 270 | | // Parse till '=' is encountered marking end of key. |
| | | 271 | | // Key cannot contain space or tab, break if either is found. |
| | 0 | 272 | | while (currentIndex < data.Length && data[currentIndex] != '=' && !CharIsSpaceOrTab(data[currentIndex])) |
| | 0 | 273 | | { |
| | 0 | 274 | | currentIndex++; |
| | 0 | 275 | | } |
| | | 276 | | |
| | 0 | 277 | | if (currentIndex == data.Length) |
| | 0 | 278 | | { |
| | | 279 | | // Key didn't terminate with '=' |
| | 0 | 280 | | parsedIndex = currentIndex; |
| | 0 | 281 | | return null; |
| | | 282 | | } |
| | | 283 | | |
| | | 284 | | // Record end of key. |
| | 0 | 285 | | int length = currentIndex - start; |
| | 0 | 286 | | if (CharIsSpaceOrTab(data[currentIndex])) |
| | 0 | 287 | | { |
| | | 288 | | // Key parsing terminated due to ' ' or '\t'. |
| | | 289 | | // Parse till '=' is found. |
| | 0 | 290 | | while (currentIndex < data.Length && CharIsSpaceOrTab(data[currentIndex])) |
| | 0 | 291 | | { |
| | 0 | 292 | | currentIndex++; |
| | 0 | 293 | | } |
| | | 294 | | |
| | 0 | 295 | | if (currentIndex == data.Length || data[currentIndex] != '=') |
| | 0 | 296 | | { |
| | | 297 | | // Key is invalid. |
| | 0 | 298 | | parsedIndex = currentIndex; |
| | 0 | 299 | | return null; |
| | | 300 | | } |
| | 0 | 301 | | } |
| | | 302 | | |
| | | 303 | | // Skip trailing space and tab and '=' |
| | 0 | 304 | | while (currentIndex < data.Length && (CharIsSpaceOrTab(data[currentIndex]) || data[currentIndex] == '=') |
| | 0 | 305 | | { |
| | 0 | 306 | | currentIndex++; |
| | 0 | 307 | | } |
| | | 308 | | |
| | | 309 | | // Set the parsedIndex to current valid char. |
| | 0 | 310 | | parsedIndex = currentIndex; |
| | 0 | 311 | | return data.Substring(start, length); |
| | 0 | 312 | | } |
| | | 313 | | |
| | | 314 | | private static string? GetNextValue(string data, int currentIndex, bool expectQuotes, out int parsedIndex) |
| | 0 | 315 | | { |
| | 0 | 316 | | Debug.Assert(currentIndex < data.Length && !CharIsSpaceOrTab(data[currentIndex])); |
| | | 317 | | |
| | | 318 | | // If quoted value, skip first quote. |
| | 0 | 319 | | bool quotedValue = false; |
| | 0 | 320 | | if (data[currentIndex] == '"') |
| | 0 | 321 | | { |
| | 0 | 322 | | quotedValue = true; |
| | 0 | 323 | | currentIndex++; |
| | 0 | 324 | | } |
| | | 325 | | |
| | 0 | 326 | | if (expectQuotes && !quotedValue) |
| | 0 | 327 | | { |
| | 0 | 328 | | parsedIndex = currentIndex; |
| | 0 | 329 | | return null; |
| | | 330 | | } |
| | | 331 | | |
| | 0 | 332 | | StringBuilder sb = StringBuilderCache.Acquire(); |
| | 0 | 333 | | while (currentIndex < data.Length && ((quotedValue && data[currentIndex] != '"') || (!quotedValue && dat |
| | 0 | 334 | | { |
| | 0 | 335 | | sb.Append(data[currentIndex]); |
| | 0 | 336 | | currentIndex++; |
| | | 337 | | |
| | 0 | 338 | | if (currentIndex == data.Length) |
| | 0 | 339 | | break; |
| | | 340 | | |
| | 0 | 341 | | if (!quotedValue && CharIsSpaceOrTab(data[currentIndex])) |
| | 0 | 342 | | break; |
| | | 343 | | |
| | 0 | 344 | | if (quotedValue && data[currentIndex] == '"' && data[currentIndex - 1] == '\\') |
| | 0 | 345 | | { |
| | | 346 | | // Include the escaped quote. |
| | 0 | 347 | | sb.Append(data[currentIndex]); |
| | 0 | 348 | | currentIndex++; |
| | 0 | 349 | | } |
| | 0 | 350 | | } |
| | | 351 | | |
| | | 352 | | // Skip the quote. |
| | 0 | 353 | | if (quotedValue) |
| | 0 | 354 | | currentIndex++; |
| | | 355 | | |
| | | 356 | | // Skip any whitespace. |
| | 0 | 357 | | while (currentIndex < data.Length && CharIsSpaceOrTab(data[currentIndex])) |
| | 0 | 358 | | currentIndex++; |
| | | 359 | | |
| | | 360 | | // Return if this is last value. |
| | 0 | 361 | | if (currentIndex == data.Length) |
| | 0 | 362 | | { |
| | 0 | 363 | | parsedIndex = currentIndex; |
| | 0 | 364 | | return StringBuilderCache.GetStringAndRelease(sb); |
| | | 365 | | } |
| | | 366 | | |
| | | 367 | | // A key-value pair should end with ',' |
| | 0 | 368 | | if (data[currentIndex++] != ',') |
| | 0 | 369 | | { |
| | 0 | 370 | | parsedIndex = currentIndex; |
| | 0 | 371 | | return null; |
| | | 372 | | } |
| | | 373 | | |
| | | 374 | | // Skip space and tab |
| | 0 | 375 | | while (currentIndex < data.Length && CharIsSpaceOrTab(data[currentIndex])) |
| | 0 | 376 | | { |
| | 0 | 377 | | currentIndex++; |
| | 0 | 378 | | } |
| | | 379 | | |
| | | 380 | | // Set parsedIndex to current valid char. |
| | 0 | 381 | | parsedIndex = currentIndex; |
| | 0 | 382 | | return StringBuilderCache.GetStringAndRelease(sb); |
| | 0 | 383 | | } |
| | | 384 | | |
| | | 385 | | private void Parse(string challenge) |
| | 0 | 386 | | { |
| | 0 | 387 | | int parsedIndex = 0; |
| | 0 | 388 | | while (parsedIndex < challenge.Length) |
| | 0 | 389 | | { |
| | | 390 | | // Get the key. |
| | 0 | 391 | | string? key = GetNextKey(challenge, parsedIndex, out parsedIndex); |
| | | 392 | | // Ensure key is not empty and parsedIndex is still in range. |
| | 0 | 393 | | if (string.IsNullOrEmpty(key) || parsedIndex >= challenge.Length) |
| | 0 | 394 | | break; |
| | | 395 | | |
| | | 396 | | // Get the value. |
| | 0 | 397 | | string? value = GetNextValue(challenge, parsedIndex, MustValueBeQuoted(key), out parsedIndex); |
| | 0 | 398 | | if (value == null) |
| | 0 | 399 | | break; |
| | | 400 | | |
| | | 401 | | // Ensure value is valid. |
| | | 402 | | // Opaque, Domain and Realm can have empty string |
| | 0 | 403 | | if (value == string.Empty && |
| | 0 | 404 | | !key.Equals(Opaque, StringComparison.OrdinalIgnoreCase) && |
| | 0 | 405 | | !key.Equals(Domain, StringComparison.OrdinalIgnoreCase) && |
| | 0 | 406 | | !key.Equals(Realm, StringComparison.OrdinalIgnoreCase)) |
| | 0 | 407 | | break; |
| | | 408 | | |
| | | 409 | | // Add the key-value pair to Parameters. |
| | 0 | 410 | | Parameters.Add(key, value); |
| | 0 | 411 | | } |
| | 0 | 412 | | } |
| | | 413 | | } |
| | | 414 | | } |
| | | 415 | | |
| | | 416 | | internal static class StringBuilderExtensions |
| | | 417 | | { |
| | | 418 | | public static void AppendKeyValue(this StringBuilder sb, string key, string value, bool includeQuotes = true, bo |
| | | 419 | | { |
| | | 420 | | sb.Append(key).Append('='); |
| | | 421 | | |
| | | 422 | | if (includeQuotes) |
| | | 423 | | { |
| | | 424 | | ReadOnlySpan<char> valueSpan = value; |
| | | 425 | | sb.Append('"'); |
| | | 426 | | while (true) |
| | | 427 | | { |
| | | 428 | | int i = valueSpan.IndexOfAny('"', '\\'); // Characters that require escaping in quoted string |
| | | 429 | | if (i >= 0) |
| | | 430 | | { |
| | | 431 | | sb.Append(valueSpan.Slice(0, i)).Append('\\').Append(valueSpan[i]); |
| | | 432 | | valueSpan = valueSpan.Slice(i + 1); |
| | | 433 | | } |
| | | 434 | | else |
| | | 435 | | { |
| | | 436 | | sb.Append(valueSpan); |
| | | 437 | | break; |
| | | 438 | | } |
| | | 439 | | } |
| | | 440 | | sb.Append('"'); |
| | | 441 | | } |
| | | 442 | | else |
| | | 443 | | { |
| | | 444 | | sb.Append(value); |
| | | 445 | | } |
| | | 446 | | |
| | | 447 | | if (includeComma) |
| | | 448 | | { |
| | | 449 | | sb.Append(',').Append(' '); |
| | | 450 | | } |
| | | 451 | | } |
| | | 452 | | } |
| | | 453 | | } |