Пишем свой Credential Provider на C# для авторизации в Windows

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 используется для отображения картинки плитки и я сначала не мог понять почему у меня отображается пустой квадрат, вместо изображения.



  
    net6.0-windows
    enable
    annotations
    true
    x64
    true
    True
  

// ...


Методов которые нас интересуют для изменения конфигурации не так много, один из них метод 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.

© Habrahabr.ru