Credential Provider, используется для передачи пользовательских учетных данных в стек безопасности Windows. По умолчанию в системе присутствуют поставщики для входа через пароль, PIN-код, смарт-карту и Windows Hello. Однако что делать если они нам не подходят?
Credential Providerы основаны на технологии COM и запускаются в процессе пользовательского интерфейса winlogon. Создание такого провайдера на C# уже описывалось в статье Стива Сайфуса, однако в его реализации не корректно отрабатывалась разблокировка рабочей станции, да и было желание переписать код на фреймворк Net Core, с которым чаще всего мне приходится работать.
Для того что бы начать разработку, необходимо настроить COM-взаимодействие позволяет вызывать код на .NET из компонентов COM. Для взаимодействия необходимо правильно настроить интерфейсы, для этого можно использовать определения MSDN, или воспользоваться облегченным вариантом используя файл IDL, определяющий нужные интерфейсы, из Windows SDK. Все что необходимо, преобразовать его в библиотеку типов, а затем конвертировать в сборку .NET.
Для компиляции библиотеки типов используется утилита midl.exe, но перед тем как воспользоваться ей необходимо подправить файл credentialprovider.idl. Как выяснилось если использовать данный файл без изменения часть методов в интрефейсы не доступны, что бы это исправить необходимо перенести объявление CLSID в начало файла.
// C:\Program Files (x86)\Windows Kits\10\Include\10.0.19041.0\um\credentialprovider.idl
[
uuid(d545db01-e522-4a63-af83-d8ddf954004f), // LIBID_CredentialProviders
]
library CredentialProviders
{
// код credentialprovider.idl
}
После чего можно выполнить команду:
midl .\credentialprovider.idl -target NT100 /x64
После выполнения мы получим несколько файлов, но нас будет интересовать только библиотека типов, которую необходимо преобразовать в сборку .NET. Обычно для преобразования используется утилита tlbimp.exe, но по умолчанию данная утилита ожидает что вы будете генерировать исключения вместо возврата HRESULT. Для преодоления этой проблемы была создана утилита tlbimp2.exe, к сожалению я не смог найти её оригинал, поэтому воспользовался файлом из репозитория Стива. После выполнения команды на выходе мы получаем библиотеку OTP.Provider.Interop.dll, которую надо привязать к проекту, добавив ссылку на полученный файл.
./TlbImp2.exe .\credentialprovider.tlb /out:OTP.Provider.Interop.dll /unsafe /preservesig namespace:OTP.Provider
После чего нем станут доступны следующие интерфейсы, методы которых необходимо описать:
ICredentialProvider — Предоставляет методы, используемые при настройке и управлении поставщиком учетных данных.
ICredentialProviderCredential — Предоставляет методы, позволяющие обрабатывать учетные данные.
ICredentialProviderCredential2 — Расширяет интерфейс ICredentialProviderCredential, добавляя метод, извлекающий идентификатор безопасности (SID) пользователя. Учетные данные связаны с этим пользователем и могут быть сгруппированы на плитке пользователя.
ICredentialProviderSetUserArray — Предоставляет метод, который позволяет поставщику учетных данных получать набор пользователей, которые будут отображаться в пользовательском интерфейсе входа или учетных данных.
Класс который реализует интерфейс ICredentialProvider необходимо сделать видимым для подсистемы COM, а также сгенерировать для него уникальный идентификатор класса. Таким образом система сможет обратится к библиотеке при выборе метода авторизации указывающего на тот же идентификатор.
[ComVisible(true)]
[Guid("D26F523C-A346-4FC8-B9B4-2B57EAEDA723")]
[ClassInterface(ClassInterfaceType.None)]
[ProgId("OTP.Provider")]
public class CredentialProvider : ICredentialProvider
{
// код CredentialProvider
}
При сборке проекта так же необходимо включить параметр EnableComHosting, для создания класса COM, а также правильного манифеста. При включенном параметре, кроме основной библиотеке, создается библиотека с приставкой comhost, которую мы регистрируем в системе для работы. Второй параметр который необходим для правильной сборки это CopyLocalLockFileAssemblies, таким образом все зависимости будут находиться в выходном каталоге и библиотека сможет спокойно к ним обратится. Например пакет System.Drawing.Common используется для отображения картинки плитки и я сначала не мог понять почему у меня отображается пустой квадрат, вместо изображения.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>annotations</Nullable>
<EnableComHosting>true</EnableComHosting>
<PlatformTarget>x64</PlatformTarget>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
</PropertyGroup>
// ...
</Project>
Методов которые нас интересуют для изменения конфигурации не так много, один из них метод Initialize, он отвечает за передачу списка полей, которые выводятся на экране. Если мы хотим добавить поле необходимо воспользоваться методом AddField, все что необходимо это указать пару параметров, по умолчанию Windows даёт нам 6 видов типов полей, которые включают в себя текстовое поле, пароль, метку и т.п. По умолчанию необходимо перечислить поля для отображения ввода логина, пароля, подтверждения пароля, метки и изображения иконки для провайдера, но этот список может быть изменен.
protected override CredentialView Initialize(_CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus, uint dwFlags)
{
var flags = (CredentialFlag)dwFlags;
Logger.Write($"cpus: {cpus}; dwFlags: {flags}");
var isSupported = IsSupportedScenario(cpus);
if (!isSupported)
{
if (NotActive == null) NotActive = new CredentialView(this) { Active = false };
return NotActive;
}
var view = new CredentialView(this) { Active = true };
var userNameState = (cpus == _CREDENTIAL_PROVIDER_USAGE_SCENARIO.CPUS_CREDUI) ?
_CREDENTIAL_PROVIDER_FIELD_STATE.CPFS_DISPLAY_IN_SELECTED_TILE : _CREDENTIAL_PROVIDER_FIELD_STATE.CPFS_HIDDEN;
var confirmPasswordState = (cpus == _CREDENTIAL_PROVIDER_USAGE_SCENARIO.CPUS_CHANGE_PASSWORD) ?
_CREDENTIAL_PROVIDER_FIELD_STATE.CPFS_DISPLAY_IN_BOTH : _CREDENTIAL_PROVIDER_FIELD_STATE.CPFS_HIDDEN;
view.AddField(
cpft: _CREDENTIAL_PROVIDER_FIELD_TYPE.CPFT_TILE_IMAGE,
pszLabel: "Icon",
state: _CREDENTIAL_PROVIDER_FIELD_STATE.CPFS_DISPLAY_IN_BOTH,
guidFieldType: Guid.Parse(CredentialView.CPFG_CREDENTIAL_PROVIDER_LOGO)
);
view.AddField(
cpft: _CREDENTIAL_PROVIDER_FIELD_TYPE.CPFT_EDIT_TEXT,
pszLabel: "Username",
state: userNameState
);
view.AddField(
cpft: _CREDENTIAL_PROVIDER_FIELD_TYPE.CPFT_PASSWORD_TEXT,
pszLabel: "Password",
state: _CREDENTIAL_PROVIDER_FIELD_STATE.CPFS_DISPLAY_IN_SELECTED_TILE,
guidFieldType: Guid.Parse(CredentialView.CPFG_LOGON_PASSWORD_GUID)
);
view.AddField(
cpft: _CREDENTIAL_PROVIDER_FIELD_TYPE.CPFT_PASSWORD_TEXT,
pszLabel: "Confirm password",
state: confirmPasswordState,
guidFieldType: Guid.Parse(CredentialView.CPFG_LOGON_PASSWORD_GUID)
);
view.AddField(
cpft: _CREDENTIAL_PROVIDER_FIELD_TYPE.CPFT_LARGE_TEXT,
pszLabel: "Custom Provider",
defaultValue: "Custom Provider",
state: _CREDENTIAL_PROVIDER_FIELD_STATE.CPFS_DISPLAY_IN_DESELECTED_TILE
);
return view;
}
Следующий метод, который нас интересует это IsSupportedScenario, в котором перечисляеются сценарии использования нашего провайдера. В нём мы можем например разрешить использовать его для авторизации, но запретить использовать для разблокировки рабочей станции или не использовать его для смены пароля, такой кейс актуален например для авторизации через смарт карты.
private static bool IsSupportedScenario(_CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus)
{
switch (cpus)
{
case _CREDENTIAL_PROVIDER_USAGE_SCENARIO.CPUS_CREDUI:
case _CREDENTIAL_PROVIDER_USAGE_SCENARIO.CPUS_UNLOCK_WORKSTATION:
case _CREDENTIAL_PROVIDER_USAGE_SCENARIO.CPUS_LOGON:
case _CREDENTIAL_PROVIDER_USAGE_SCENARIO.CPUS_CHANGE_PASSWORD:
return true;
case _CREDENTIAL_PROVIDER_USAGE_SCENARIO.CPUS_PLAP:
case _CREDENTIAL_PROVIDER_USAGE_SCENARIO.CPUS_INVALID:
default:
return false;
}
}
И последний интересный метод, который отвечает непосредственно при отправке учетных данных для проверки подлинности. Данный метод долен вернуть указание на успех или неудачу попытки сериализации учетных данных, а также указатель на учетные данные. В примере Стива используется метод из библиотеки CredPackAuthenticationBuffer из библиотеки credui.dll, с его помощью мы можем реализовать стандартную авторизацию через учетные данные локального иди доменного пользователя. Однако с реализацией данного метода есть проблема, в Windows 10 сценарии авторизации и разблокировки были объединены в единый сценарии CPUS_LOGON, который данный метод успешно отрабатывает, но согласно документации в ряде случаев используется сценарий CPUS_UNLOCK_WORKSTATION, с которым данный метод не отрабатывает. Когда я пробовал протестировать провайдер написанный Стивом, я столкнулся с этой проблемой, при попытке разблокировать рабочую станцию, возникала проблема, но стоило воспользоваться кнопкой Switch User, как авторизация успешно проходила. Что бы решить данную проблему необходимо обратиться к примеру предоставленному непосредственно компанией Microsoft и в реализации метода авторизации можно найти интересный комментарий, где сами программисты Microsoft указывают, что стандартная функция не подходит для успешного выполнения входа в систему. Больше похоже что сами разрабы Windows используют костыль для авторизации.
HRESULT KerbInteractiveUnlockLogonInit(
_In_ PWSTR pwzDomain,
_In_ PWSTR pwzUsername,
_In_ PWSTR pwzPassword,
_In_ CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus,
_Out_ KERB_INTERACTIVE_UNLOCK_LOGON *pkiul
)
{
// Note: this method uses custom logic to pack a KERB_INTERACTIVE_UNLOCK_LOGON with a
// serialized credential. We could replace the calls to UnicodeStringInitWithString
// and KerbInteractiveUnlockLogonPack with a single cal to CredPackAuthenticationBuffer,
// but that API has a drawback: it returns a KERB_INTERACTIVE_UNLOCK_LOGON whose
// MessageType is always KerbInteractiveLogon.
//
// If we only handled CPUS_LOGON, this drawback would not be a problem. For
// CPUS_UNLOCK_WORKSTATION, we could cast the output buffer of CredPackAuthenticationBuffer
// to KERB_INTERACTIVE_UNLOCK_LOGON and modify the MessageType to KerbWorkstationUnlockLogon,
// but such a cast would be unsupported -- the output format of CredPackAuthenticationBuffer
// is not officially documented.
}
Так как в стандартных библиотеках нужного нам метода нет, для решения проблемы необходимо написать маленькую библиотеку на C++, в которую нам необходимо скопировать функции для упаковки учетных данных, а именно KerbInteractiveUnlockLogonPack и KerbInteractiveUnlockLogonInit. Далее мы сможем вызвать нужную функцию через использование PInvoke и проблема с неподдерживаемым сценарием будет решена. Что интересно, основное отличие заключается в установка типа сообщения сериализации, а именно сценария авторизации, для библиотеки ntsecapi, так как она использует различные подходы для сериализации учетных данных.
switch (cpus)
{
case CPUS_UNLOCK_WORKSTATION:
pkil->MessageType = KerbWorkstationUnlockLogon;
hr = S_OK;
break;
case CPUS_LOGON:
pkil->MessageType = KerbInteractiveLogon;
hr = S_OK;
break;
case CPUS_CREDUI:
pkil->MessageType = (KERB_LOGON_SUBMIT_TYPE)0; // MessageType does not apply to CredUI
hr = S_OK;
break;
default:
hr = E_FAIL;
break;
}
После компиляции нашей небольшой библиотеки на С++, достаточно включить её в основную библиотеку Credential Providerа
[DllImport("./OTP.Provider.Helper.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern uint ProtectIfNecessaryAndCopyPassword(
string pwzPassword,
_CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus,
ref string ppwzProtectedPassword
);
[DllImport("./OTP.Provider.Helper.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern uint KerbInteractiveUnlockLogonInit(
string pwzDomain,
string pwzUsername,
string pwzPassword,
_CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus,
ref IntPtr prgb,
ref int pcb
);
И вызвать её уже в методе GetSerialization
try
{
PInvoke.ProtectIfNecessaryAndCopyPassword(password, usage, ref protectedPassword);
}
catch (Exception ex)
{
Logger.Write(ex.Message);
}
var inCredSize = 0;
var inCredBuffer = Marshal.AllocCoTaskMem(0);
try
{
Marshal.FreeCoTaskMem(inCredBuffer);
inCredBuffer = Marshal.AllocCoTaskMem(inCredSize);
PInvoke.KerbInteractiveUnlockLogonInit(domain, shortUsername, protectedPassword, usage, ref inCredBuffer, ref inCredSize);
pcpcs.clsidCredentialProvider = Guid.Parse("D26F523C-A346-4FC8-B9B4-2B57EAEDA723");
pcpcs.rgbSerialization = inCredBuffer;
pcpcs.cbSerialization = (uint)inCredSize;
pcpcs.ulAuthenticationPackage = authPackage;
pcpgsr = _CREDENTIAL_PROVIDER_GET_SERIALIZATION_RESPONSE.CPGSR_RETURN_CREDENTIAL_FINISHED;
return HRESULT.S_OK;
}
catch (Exception ex)
{
Logger.Write(ex.Message);
}
После того как мы собрали библиотеку, для её использования , необходимо добавить ключ в реестр. В ветке [HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\] создаём ветку с GUID нашей библиотеки, который мы прописывали в классе CredentialProvider, тогда при авторизации появится плитка с выбором вашего провайдера. А также необходимо зарегистрировать CLSID нашего провайдера в ветке HKEY_CLASSES_ROOT.
Windows Registry Editor Version 5.00
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\{D26F523C-A346-4FC8-B9B4-2B57EAEDA723}]
@="OTP.Provider"
[HKEY_CLASSES_ROOT\OTP.Provider\CLSID]
@="{D26F523C-A346-4FC8-B9B4-2B57EAEDA723}"
Эксперименты и отладку рекомендуется делать в виртуальной машине, иначе можно потерять возможность авторизации в системе и придется заходить в безопасный режим, что бы удалить библиотеку и её упоминание из реестра. Исходный код можно найти на github.
Комментарии (10)
hobogene
05.06.2022 01:51+1Если все равно без плюсов не вышло, зачем вообще весь огород с .Net городить? Ну т.е. как фокус интересно, но каков практический смысл?
HackcatDev
05.06.2022 03:53+1Удобство и интеграция с существующим кодом. Никто не будет всё приложение на С++ переписывать ради одной фичи. На .NET можно быстро и без боли реализовать почти что угодно в отличие от неудобного С++.
Ну и за PoC автору спасибо, сам эту тему тыкал - не получилось заставить работать как надо
hobogene
05.06.2022 12:20+2Какое "все приложение"? CP - это, в общем, вещь в себе. Даже когда является частью боле объемного решения. А все сильно навороченное, к нему относящееся, если оно есть, все равно в отдельный сервис выносить надо. Вне зависимости от того, на каком языке это сложное написано. Просто чтобы в ноги себе лишний раз не стрелять.
И через пять минут выяснится, что Вам еще нужен subauthentication или типа того, и опять ныряете в нативный код.
Все это заслуг автора не умаляет. Я знаю некоторое число людей, которые пытались, да не совладали, даже с подсказками с GitHub. А он смог.
reficul0
05.06.2022 17:27Всегда можно реализовать CLI обёртку, которая будет проксировать вызовы C# <-> C++. И тогда можно будет часть функционала писать на С++, а часть на C#, пользуясь преимуществами обоих языков и при этом не переписывая всё с нуля.
Пример: часто видел как делают UI на WPF, а бизнес логику на C++.Skykharkov
05.06.2022 18:01А еще лучше разделить бизнес-логику и UI вообще на две совершенно разные части и между ними API поставить. И тогда что на чем реализовать вообще без разницы.
hobogene
07.06.2022 10:38И Вы, и немного автор выше, совершенно не учитываете специфику предметной области. Речь идет не о приложении. Речь идет о довольно специфичной штуке. "Совершенно разная часть" которой, отвечающая за UI, реализована за нас (CredUI и проч.). И нужны очень веские причины, чтобы ей не пользоваться (хотя совсем не пользоваться может не выйти).
kuku147 Автор
05.06.2022 12:58Идея была, что какую то логику, например ту же проверку OTP мне легче реализовать на C#. Но да можно взять готовый пример от Microsoft на плюсах и использовать его, особенно если необходимо погружаться ещё глубже.
Тут же скорее реализация для каких то простых доработок.hobogene
05.06.2022 13:42ОК, я примерно так и думал. Если вдруг всерьез что-то такое делать надо, на pGina имеет смысл взглянуть, если еще не. Там совсем не дураки писали, и уже масса заготовок к тому, чтобы вот вынести логику типа проверок ОТП в отдельный сервис, написанный пофиг, на чем.
Кстати, для совсем простых доработок можно взять штатные примеры для wrapper'ов вокруг CP, и даже переписать это на шарпе. Скорее всего, нужда в плюсовом коде вообще отпадет. И возможностей такой подход дает куда больше, чем может показаться на первый взгляд.
Но, повторюсь, заслуг никак не умаляет. Если бы такое появилось лет 8-10 назад, я бы лишился толики денег :-) Индусы, начинавшие писать CP на шарпе, и не совладавшие, приносили небольшой, но стабильный доход. Не знаю, что у них там за поветрие было, но массово писали. Наверное, какая-нибудь кампания типа нашего импортозамещения была.
mayorovp
А почему исключения вместо HRESULT — это проблема?
Или же можно использовать команду publish вместо build, и получить нужный результат безо всяких параметров.
hobogene
Winlogon'у от этого плохеет, если я правильно помню.