using System.Runtime.InteropServices; using System.Net; using System.Net.Security; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; namespace SGU.CredentialProvider.SmokeProbe; internal static class Program { private static readonly Guid ProviderClassId = new("D789CFD8-5AD4-489F-9B83-7EB5D9D09335"); private static readonly string[] ExpectedLabels = [ "Acceso institucional SGU", "Usa tu clave institucional (DO, AL o AD + 6 dígitos) y contraseña.", "Clave institucional", "Contraseña", "Iniciar sesión" ]; private static int Main(string[] args) { string mode = args.Length == 0 ? "enumeration" : args.Single(); if (mode is not ("enumeration" or "direct-broker" or "online-rejection" or "offline-fallback")) { Console.Error.WriteLine("Usage: SGU.CredentialProvider.SmokeProbe.exe [enumeration|direct-broker|online-rejection|offline-fallback]"); return 64; } if (mode == "direct-broker") { return RunDirectBrokerProbe(); } object? instance = null; IntPtr credential = IntPtr.Zero; NativeEmptyUserArray? users = null; try { Type providerType = Type.GetTypeFromCLSID(ProviderClassId, throwOnError: true) ?? throw new InvalidOperationException("The SGU Credential Provider CLSID is not registered."); instance = Activator.CreateInstance(providerType) ?? throw new InvalidOperationException("COM activation returned no provider instance."); ICredentialProvider provider = (ICredentialProvider)instance; ICredentialProviderSetUserArray setUserArray = (ICredentialProviderSetUserArray)instance; users = new NativeEmptyUserArray(); ThrowIfFailed(provider.SetUsageScenario(UsageScenario.Logon, 0), "SetUsageScenario"); ThrowIfFailed(setUserArray.SetUserArray(users.Pointer), "SetUserArray"); ThrowIfFailed(provider.GetFieldDescriptorCount(out uint fieldCount), "GetFieldDescriptorCount"); List labels = []; for (uint index = 0; index < fieldCount; index++) { ThrowIfFailed(provider.GetFieldDescriptorAt(index, out IntPtr descriptorPointer), "GetFieldDescriptorAt"); if (descriptorPointer == IntPtr.Zero) { throw new InvalidOperationException($"Field descriptor {index} was null."); } try { FieldDescriptor descriptor = Marshal.PtrToStructure(descriptorPointer); labels.Add(descriptor.Label ?? string.Empty); } finally { Marshal.DestroyStructure(descriptorPointer); Marshal.FreeCoTaskMem(descriptorPointer); } } ThrowIfFailed(provider.GetCredentialCount(out uint credentialCount, out uint defaultIndex, out int autoLogon), "GetCredentialCount"); if (credentialCount > 0) { ThrowIfFailed(provider.GetCredentialAt(0, out credential), "GetCredentialAt"); } bool passed = fieldCount == ExpectedLabels.Length && credentialCount == 1 && credential != IntPtr.Zero && labels.SequenceEqual(ExpectedLabels, StringComparer.Ordinal); if (mode != "enumeration" && passed) { return RunSerializationProbe(mode, credential, labels); } Console.WriteLine(JsonSerializer.Serialize(new { passed, mode, providerClassId = ProviderClassId, usageScenario = "Logon", fieldCount, labels, credentialCount, defaultIndex, autoLogon = autoLogon != 0 })); return passed ? 0 : 1; } catch (Exception ex) { Console.Error.WriteLine(ex); return 2; } finally { if (credential != IntPtr.Zero) { Marshal.Release(credential); } if (instance is not null && Marshal.IsComObject(instance)) { Marshal.FinalReleaseComObject(instance); } users?.Dispose(); } } private static void ThrowIfFailed(int hresult, string operation) { if (hresult < 0) { Marshal.ThrowExceptionForHR(hresult); throw new COMException($"{operation} failed.", hresult); } } private static int RunSerializationProbe(string mode, IntPtr credential, IReadOnlyList labels) { string[] labelArray = labels.ToArray(); uint userNameFieldId = checked((uint)Array.IndexOf(labelArray, "Clave institucional")); uint passwordFieldId = checked((uint)Array.IndexOf(labelArray, "Contraseña")); string userName = "DO000000"; string password = mode == "offline-fallback" ? $"Probe-{Guid.NewGuid():N}-áΩ" : $"Probe-{Guid.NewGuid():N}"; IntPtr vtable = Marshal.ReadIntPtr(credential); SetStringValueDelegate setStringValue = Marshal.GetDelegateForFunctionPointer( Marshal.ReadIntPtr(vtable, 14 * IntPtr.Size)); GetSerializationDelegate getSerialization = Marshal.GetDelegateForFunctionPointer( Marshal.ReadIntPtr(vtable, 18 * IntPtr.Size)); SetCredentialString(setStringValue, credential, userNameFieldId, userName, "SetStringValue(username)"); SetCredentialString(setStringValue, credential, passwordFieldId, password, "SetStringValue(password)"); CredentialSerialization serialization = default; IntPtr statusTextPointer = IntPtr.Zero; try { ThrowIfFailed( getSerialization(credential, out int response, out serialization, out statusTextPointer, out int statusIcon), "GetSerialization"); string statusText = statusTextPointer == IntPtr.Zero ? string.Empty : Marshal.PtrToStringUni(statusTextPointer) ?? string.Empty; if (mode == "online-rejection") { bool passed = response == (int)SerializationResponse.NoCredentialNotFinished && serialization.SerializationData == IntPtr.Zero && statusIcon == (int)StatusIcon.Error && statusText == "Credenciales institucionales inválidas."; Console.WriteLine(JsonSerializer.Serialize(new { passed, mode, response = (SerializationResponse)response, statusIcon = (StatusIcon)statusIcon, statusText, credentialReturned = serialization.SerializationData != IntPtr.Zero })); return passed ? 0 : 1; } KerberosInteractiveUnlockLogon logon = serialization.SerializationData == IntPtr.Zero ? default : Marshal.PtrToStructure(serialization.SerializationData); string packedDomain = ReadPackedString(serialization.SerializationData, logon.LogonDomainName); string packedUserName = ReadPackedString(serialization.SerializationData, logon.Username); string packedPassword = ReadPackedString(serialization.SerializationData, logon.Password); bool passwordPreserved = string.Equals(packedPassword, password, StringComparison.Ordinal); packedPassword = string.Empty; bool fallbackPassed = response == (int)SerializationResponse.ReturnCredentialFinished && serialization.SerializationData != IntPtr.Zero && serialization.SerializationSize > 0 && statusIcon == (int)StatusIcon.Warning && statusText == "Servicio institucional no disponible; Windows validará la última contraseña de dominio registrada." && string.Equals(packedDomain, "LCI", StringComparison.Ordinal) && string.Equals(packedUserName, userName, StringComparison.Ordinal) && passwordPreserved; Console.WriteLine(JsonSerializer.Serialize(new { passed = fallbackPassed, mode, response = (SerializationResponse)response, statusIcon = (StatusIcon)statusIcon, statusText, packedDomain, packedUserName, passwordPreserved, serializationSize = serialization.SerializationSize })); return fallbackPassed ? 0 : 1; } finally { password = string.Empty; if (statusTextPointer != IntPtr.Zero) { Marshal.FreeCoTaskMem(statusTextPointer); } if (serialization.SerializationData != IntPtr.Zero) { byte[] zeroes = new byte[serialization.SerializationSize]; Marshal.Copy(zeroes, 0, serialization.SerializationData, zeroes.Length); Marshal.FreeCoTaskMem(serialization.SerializationData); } } } private static int RunDirectBrokerProbe() { string settingsPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "SGU", "CredentialProvider", "settings.json"); ProbeSettings settings = JsonSerializer.Deserialize( File.ReadAllText(settingsPath), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? throw new InvalidOperationException("Provider settings could not be read."); using X509Store store = new(StoreName.My, StoreLocation.LocalMachine); store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly); using X509Certificate2 certificate = store.Certificates .Find(X509FindType.FindByThumbprint, settings.ClientCertificateThumbprint, validOnly: true) .OfType() .First(item => item.HasPrivateKey); string expectedThumbprint = NormalizeThumbprint(settings.ServerCertificateThumbprint); using HttpClientHandler handler = new() { AllowAutoRedirect = false, CheckCertificateRevocationList = true, ClientCertificateOptions = ClientCertificateOption.Manual, MaxConnectionsPerServer = 2, MaxResponseHeadersLength = 32, UseCookies = false, UseDefaultCredentials = false, UseProxy = false, ServerCertificateCustomValidationCallback = (_, serverCertificate, _, policyErrors) => policyErrors == SslPolicyErrors.None && serverCertificate is not null && string.Equals( NormalizeThumbprint(serverCertificate.GetCertHashString()), expectedThumbprint, StringComparison.OrdinalIgnoreCase) }; handler.ClientCertificates.Add(certificate); using HttpClient client = new(handler) { Timeout = TimeSpan.FromSeconds(settings.TimeoutSeconds), DefaultRequestVersion = HttpVersion.Version11, DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact }; string password = $"Probe-{Guid.NewGuid():N}"; try { string json = JsonSerializer.Serialize(new { clave = "DO000000", password }); using StringContent content = new(json, Encoding.UTF8, "application/json"); using HttpResponseMessage response = client.PostAsync(settings.BrokerEndpoint, content).GetAwaiter().GetResult(); string responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); string? responseCode = null; try { responseCode = JsonDocument.Parse(responseBody).RootElement.GetProperty("code").GetString(); } catch (Exception exception) when (exception is JsonException or InvalidOperationException or KeyNotFoundException) { } bool passed = response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Unauthorized; Console.WriteLine(JsonSerializer.Serialize(new { passed, mode = "direct-broker", statusCode = (int)response.StatusCode, responseCode })); return passed ? 0 : 1; } catch (Exception exception) { Console.WriteLine(JsonSerializer.Serialize(new { passed = false, mode = "direct-broker", exception = exception.GetType().FullName, innerException = exception.InnerException?.GetType().FullName, hresult = exception.HResult })); return 1; } finally { password = string.Empty; } } private static string NormalizeThumbprint(string value) => value.Replace(" ", string.Empty, StringComparison.Ordinal).ToUpperInvariant(); private static void SetCredentialString( SetStringValueDelegate setStringValue, IntPtr credential, uint fieldId, string value, string operation) { IntPtr valuePointer = Marshal.StringToCoTaskMemUni(value); try { ThrowIfFailed(setStringValue(credential, fieldId, valuePointer), operation); } finally { Marshal.ZeroFreeCoTaskMemUnicode(valuePointer); } } private static string ReadPackedString(IntPtr buffer, PackedUnicodeString value) { if (buffer == IntPtr.Zero || value.Length == 0) { return string.Empty; } return Marshal.PtrToStringUni( IntPtr.Add(buffer, checked((int)value.Buffer.ToInt64())), value.Length / sizeof(char)) ?? string.Empty; } [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int SetStringValueDelegate(IntPtr instance, uint fieldId, IntPtr value); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int GetSerializationDelegate( IntPtr instance, out int response, out CredentialSerialization serialization, out IntPtr statusText, out int statusIcon); } internal sealed class ProbeSettings { public Uri BrokerEndpoint { get; init; } = null!; public int TimeoutSeconds { get; init; } public string ClientCertificateThumbprint { get; init; } = string.Empty; public string ServerCertificateThumbprint { get; init; } = string.Empty; } internal enum SerializationResponse { NoCredentialNotFinished = 0, NoCredentialFinished = 1, ReturnCredentialFinished = 2, ReturnNoCredentialFinished = 3 } internal enum StatusIcon { None = 0, Error = 1, Warning = 2, Success = 3 } internal enum UsageScenario { Invalid = 0, Logon = 1 } internal enum FieldType { Invalid = 0, LargeText, SmallText, CommandLink, EditText, PasswordText, TileImage, CheckBox, ComboBox, SubmitButton } [StructLayout(LayoutKind.Sequential, Pack = 4, CharSet = CharSet.Unicode)] internal struct FieldDescriptor { public uint FieldId; public FieldType FieldType; [MarshalAs(UnmanagedType.LPWStr)] public string? Label; public Guid FieldTypeGuid; } [StructLayout(LayoutKind.Sequential, Pack = 4)] internal struct CredentialSerialization { public uint AuthenticationPackage; public Guid ProviderClassGuid; public uint SerializationSize; public IntPtr SerializationData; } [StructLayout(LayoutKind.Sequential)] internal struct PackedUnicodeString { public ushort Length; public ushort MaxLength; public IntPtr Buffer; } [StructLayout(LayoutKind.Sequential)] internal struct KerberosInteractiveUnlockLogon { public int SubmitType; public PackedUnicodeString LogonDomainName; public PackedUnicodeString Username; public PackedUnicodeString Password; public long LoginId; } [ComImport] [Guid("D27C3481-5A1C-45B2-8AAA-C20EBBE8229E")] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface ICredentialProvider { [PreserveSig] int SetUsageScenario(UsageScenario usageScenario, uint flags); [PreserveSig] int SetSerialization(IntPtr serialization); [PreserveSig] int Advise(IntPtr events, IntPtr adviseContext); [PreserveSig] int UnAdvise(); [PreserveSig] int GetFieldDescriptorCount(out uint count); [PreserveSig] int GetFieldDescriptorAt(uint index, out IntPtr descriptor); [PreserveSig] int GetCredentialCount(out uint count, out uint defaultIndex, out int autoLogonWithDefault); [PreserveSig] int GetCredentialAt( uint index, out IntPtr credential); } [ComImport] [Guid("095C1484-1C0C-4388-9C6D-500E61BF84BD")] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface ICredentialProviderSetUserArray { [PreserveSig] int SetUserArray(IntPtr users); } internal sealed class NativeEmptyUserArray : IDisposable { private static readonly Guid UserArrayInterfaceId = new("90C119AE-0F18-4520-A1F1-114366A40FE8"); private static readonly Guid UnknownInterfaceId = new("00000000-0000-0000-C000-000000000046"); private readonly QueryInterfaceDelegate queryInterface; private readonly AddRefDelegate addRef; private readonly ReleaseDelegate release; private readonly SetProviderFilterDelegate setProviderFilter; private readonly GetAccountOptionsDelegate getAccountOptions; private readonly GetCountDelegate getCount; private readonly GetAtDelegate getAt; private IntPtr instance; private IntPtr vtable; private int referenceCount = 1; public NativeEmptyUserArray() { queryInterface = QueryInterface; addRef = AddRef; release = Release; setProviderFilter = SetProviderFilter; getAccountOptions = GetAccountOptions; getCount = GetCount; getAt = GetAt; vtable = Marshal.AllocHGlobal(IntPtr.Size * 7); Marshal.WriteIntPtr(vtable, IntPtr.Size * 0, Marshal.GetFunctionPointerForDelegate(queryInterface)); Marshal.WriteIntPtr(vtable, IntPtr.Size * 1, Marshal.GetFunctionPointerForDelegate(addRef)); Marshal.WriteIntPtr(vtable, IntPtr.Size * 2, Marshal.GetFunctionPointerForDelegate(release)); Marshal.WriteIntPtr(vtable, IntPtr.Size * 3, Marshal.GetFunctionPointerForDelegate(setProviderFilter)); Marshal.WriteIntPtr(vtable, IntPtr.Size * 4, Marshal.GetFunctionPointerForDelegate(getAccountOptions)); Marshal.WriteIntPtr(vtable, IntPtr.Size * 5, Marshal.GetFunctionPointerForDelegate(getCount)); Marshal.WriteIntPtr(vtable, IntPtr.Size * 6, Marshal.GetFunctionPointerForDelegate(getAt)); instance = Marshal.AllocHGlobal(IntPtr.Size); Marshal.WriteIntPtr(instance, vtable); } public IntPtr Pointer => instance != IntPtr.Zero ? instance : throw new ObjectDisposedException(nameof(NativeEmptyUserArray)); private int QueryInterface(IntPtr self, ref Guid interfaceId, out IntPtr result) { if (interfaceId == UnknownInterfaceId || interfaceId == UserArrayInterfaceId) { result = self; AddRef(self); return 0; } result = IntPtr.Zero; return unchecked((int)0x80004002); } private uint AddRef(IntPtr self) => unchecked((uint)Interlocked.Increment(ref referenceCount)); private uint Release(IntPtr self) => unchecked((uint)Math.Max(0, Interlocked.Decrement(ref referenceCount))); private static int SetProviderFilter(IntPtr self, ref Guid providerToFilterTo) => 0; private static int GetAccountOptions(IntPtr self, out uint accountOptions) { accountOptions = 0; return 0; } private static int GetCount(IntPtr self, out uint userCount) { userCount = 0; return 0; } private static int GetAt(IntPtr self, uint userIndex, out IntPtr user) { user = IntPtr.Zero; return unchecked((int)0x80070057); } public void Dispose() { if (instance != IntPtr.Zero) { Marshal.FreeHGlobal(instance); instance = IntPtr.Zero; } if (vtable != IntPtr.Zero) { Marshal.FreeHGlobal(vtable); vtable = IntPtr.Zero; } GC.KeepAlive(queryInterface); GC.KeepAlive(addRef); GC.KeepAlive(release); GC.KeepAlive(setProviderFilter); GC.KeepAlive(getAccountOptions); GC.KeepAlive(getCount); GC.KeepAlive(getAt); } [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int QueryInterfaceDelegate(IntPtr self, ref Guid interfaceId, out IntPtr result); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate uint AddRefDelegate(IntPtr self); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate uint ReleaseDelegate(IntPtr self); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int SetProviderFilterDelegate(IntPtr self, ref Guid providerToFilterTo); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int GetAccountOptionsDelegate(IntPtr self, out uint accountOptions); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int GetCountDelegate(IntPtr self, out uint userCount); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int GetAtDelegate(IntPtr self, uint userIndex, out IntPtr user); }