Inside CloudAP: what a third-party plug-in needs
When a user logs on with an Entra ID or a Microsoft account, the authentication package is CloudAP. CloudAP itself knows nothing about Azure: it loads plug-ins (aadcloudap.dll, MicrosoftAccountCloudAP.dll) and asks them for the identity, the token and the keys. The plug-in interface is not documented. This page describes it, from cloudAP.dll (Windows 11 26100, public symbols) and the two Microsoft plug-ins, along with a sample plug-in that logs a user on and unlocks the session. The code is on GitHub.
The same registry as the PKU2U identity providers. cloudAP!InitializePlugins enumerates IdentityStore\Providers, and for every provider other than its own two, cloudAP!LoadPlugin wants:
HKLM\SOFTWARE\Microsoft\IdentityStore\Providers\{5A0C2E62-7D4B-4C1A-9F3E-2B8D6E1A4C70}
Name REG_SZ MYCP
ApPluginDLLPath REG_EXPAND_SZ %SystemRoot%\System32\CloudApPlugin.dll
HKLM\SOFTWARE\Microsoft\IdentityStore\LoadParameters\{5A0C2E62-7D4B-4C1A-9F3E-2B8D6E1A4C70}
Enabled REG_DWORD 1
- Without
Enabled, the provider is skipped with0xC00002E9. - The DLL is loaded with
LoadLibraryExW(path, NULL, 0x880): System32 search, andLOAD_LIBRARY_REQUIRE_SIGNED_TARGET. - Then
GetProcAddress("CloudAPPluginInitialize"), the only export a plug-in needs. - After it returns, cloudAP registers the GUID with LSA as an identity provider (the
LsapRegisterIdentityProviderentry of the PKU2U page). If something already registered that GUID,LoadPluginfails withSTATUS_CALLBACK_RETURNED_ALREADY_REGISTERED(0xC0000718) and unloads the plug-in. A plug-in needs its own GUID. - CloudAP's call-package message 0 (
CloudAPReinitPlugins) unloads and loads all plug-ins again, without a reboot.
NTSTATUS CloudAPPluginInitialize(
ULONG Version, // 1
GUID* ProviderGuid,
PCLOUDAP_SECPKG_FUNCTION_TABLE CloudApFunctions, // what cloudAP gives the plug-in
PULONG Flags, // out
PWSTR Name, // out, WCHAR[21]
PUCHAR Sid, // out, SID buffer, RtlValidSid
PVOID* Sid2, // out, optional
PVOID* Context, // out, first argument of every plug-in function
PAPPLUGIN_SECPKG_FUNCTION_TABLE PluginFunctions); // out, a 0xB0-byte buffer to fill
This is the prototype of aadcloudap!CloudAPPluginInitialize: its public symbol is a C++ decorated name, so it carries the parameter types and the names of both tables. All the out-parameters point into the 0x188-byte record cloudAP keeps per plug-in. The last one is a trap: it is a buffer, and the plug-in copies its function table into it. Writing a pointer there instead makes cloudAP read garbage and unload the plug-in two milliseconds later. Context comes back as the first argument of every plug-in function. In Flags, cloudAP tests 0x01 (no DPAPI key sync), 0x04 (can take smart card logons from the surrogate) and 0x10 (default plug-in).
CLOUDAP_SECPKG_FUNCTION_TABLE has 15 entries, filled once by cloudAP!SpInitialize. Ten are copied from the LSA_SECPKG_FUNCTION_TABLE LSA gave cloudAP, so ntsecpkg.h already has their typedef. The five others are cloudAP functions, typed from their symbols:
| # | Entry | Type | Source |
|---|---|---|---|
| 0 | ImpersonateClient | PLSA_IMPERSONATE_CLIENT | LSA +0x58 |
| 1 | LsaProtectMemory | PLSA_PROTECT_MEMORY | LSA +0x160 |
| 2 | LsaUnprotectMemory | PLSA_PROTECT_MEMORY | LSA +0x168 |
| 3 | OpenTokenByLogonId | PLSA_OPEN_TOKEN_BY_LOGON_ID | LSA +0x170 |
| 4 | AllocateLsaHeap | PLSA_ALLOCATE_LSA_HEAP | LSA +0x28 |
| 5 | FreeLsaHeap | PLSA_FREE_LSA_HEAP | LSA +0x30 |
| 6 | AllocateUserInfo | CLOUDAP_ALLOCATE_USER_INFO* | cloudAP |
| 7 | FreeUserInfo | CLOUDAP_FREE_USER_INFO* | cloudAP |
| 8 | CrediRead | CredReadFn* | LSA +0x148 |
| 9 | CrediFreeCredentials | CredFreeCredentialsFn* | LSA +0x158 |
| 10 | CrediWrite | CredWriteFn* | LSA +0x198 |
| 11 | SignMessageWithNgc | CLOUDAP_SIGN_MESSAGE_WITH_NGC* | cloudAP |
| 12 | GetCallInfo | PLSA_GET_CALL_INFO | LSA +0xC0 |
| 13 | GenerateKeyBindingKeyPair | CLOUDAP_GENERATE_KEY_BINDING_KEY_PAIR* | cloudAP |
| 14 | VerifyKeyBindingKeyPair | CLOUDAP_VERIFY_KEY_BINDING_KEY_PAIR* | cloudAP |
NTSTATUS AllocateUserInfo(ULONG Version, PSID UserSid, ULONG GroupCount, PSID* Groups,
PCWSTR String1, PCWSTR String2, PCWSTR String3, PCWSTR String4, PCWSTR Name,
APPLUGIN_SSO_USER_INFO*, APPLUGIN_PWD_EXPIRY_INFO*, APPLUGIN_PUBLIC_CACHED_INFO*,
ULONG Flags, APPLUGIN_USER_INFO** UserInfo);
VOID FreeUserInfo(APPLUGIN_USER_INFO* UserInfo);
NTSTATUS SignMessageWithNgc(PUCHAR KeyBlob, USHORT cbKeyBlob, HWND Window,
PUCHAR Message, ULONG cbMessage, PUCHAR* Signature, PULONG cbSignature);
NTSTATUS GenerateKeyBindingKeyPair(PUCHAR* PublicKey, PULONG cbPublicKey,
PUCHAR* PrivateKey, PULONG cbPrivateKey);
BOOL VerifyKeyBindingKeyPair(PUCHAR PublicKey, ULONG cbPublicKey,
PUCHAR PrivateKey, ULONG cbPrivateKey);
Two details. CredReadFn, CredWriteFn and CredFreeCredentialsFn are in ntsecpkg.h only when wincred.h is included first; otherwise those entries of the LSA table are called DummyFunction1..5. And right after the table in cloudAP's memory comes another variable, g_pLsaFunctionTable: reading a 16th entry "works" and gives the whole LSA table, but only by accident of layout. AllocateLsaHeap is entry 4.
APPLUGIN_SECPKG_FUNCTION_TABLE is { ULONG Version; NoNetwork[9]; NetworkOk[12]; }: the Microsoft plug-ins build it from two static tables, the functions usable without network and the others. Names and prototypes come from the aad and MSA symbols (slot 3 is NULL in both, its name is unknown):
| 0 | Uninitialize | 7 | AssembleOpaqueData | 14 | UserProfileLoaded |
| 1 | ValidateUserInfo | 8 | DisassembleOpaqueData | 15 | ConnectIdentity |
| 2 | GetUnlockKey | 9 | GetToken | 16 | DisconnectIdentity |
| 3 | (unnamed) | 10 | RefreshToken | 17 | RenewCertificate |
| 4 | GetDefaultCredentialComplexity | 11 | GetKeys | 18 | GetCertificateFromCred |
| 5 | IsConnected | 12 | LookupSIDFromIdentityName | 19 | GenericCallPkg |
| 6 | AcceptPeerCertificate | 13 | LookupIdentityFromSIDName | 20 | PostLogonProcessing |
GetToken is the first function a logon reaches:
NTSTATUS GetToken(PVOID Context, SECURITY_LOGON_TYPE LogonType,
DECRYPTED_AUTH_DATA* Auth, // { PWSTR UserLower; PWSTR User; SEC_WINNT_AUTH_PACKED_CREDENTIALS* Creds; }
AP_BLOB* BlobIn, AP_BLOB* BlobOut,
APPLUGIN_USER_INFO** UserInfo, SECPKG_SUPPLEMENTAL_CRED_ARRAY** Supplemental);
The plug-in checks the credential and builds the user info with callback 6. cloudAP!MakeTokenInformationV2 reads only its user SID and group list, so the identity has to be a SID LSA can resolve. The sample maps the user name to a local account, as PKU2U's built-in provider does. BlobOut is kept in cloudAP's user cache and handed back to GetKeys.
GetKeys, called right after by cloudAP!GetDPAPIKeys, returns the credential key that protects the user's DPAPI master keys:
NTSTATUS GetKeys(PVOID Context, AP_BLOB* Blob, GUID* KeyId, AP_BLOB* SavedKeyBlob, BOOL Flag,
CREDENTIAL_KEY** Key, int* NeedNewKey, CREDENTIAL_KEY** Key2, AP_BLOB* KeyBlobOut);
typedef struct { ULONG cbTotal; ULONG Reserved; ULONG cbHeader /* 0x20 */; ULONG cbKey;
GUID Id; BYTE Key[]; } CREDENTIAL_KEY; // LSA heap
When cloudAP asks for Key2, KeyBlobOut must not be empty, else the logon fails with STATUS_INTERNAL_ERROR. In the sample, the other slots are stubs that return STATUS_NOT_IMPLEMENTED, and cloudAP accepts that. A NULL slot is not the same: cloudAP skips some NULL slots, but takes a stub's error as a failure.
cloudAP registers the plug-in by GUID, but finds it by name at logon. There are three ways in:
- A logon sent to the CloudAP package. The buffer is a documented
SEC_WINNT_AUTH_IDENTITY_EX2(version0x201):User= the user,Domain= the plug-in name,PackedCredentials= a credential type GUID and its bytes (not empty).cloudAP!LsaApLogonUserEx2comparesDomainwith the name each plug-in returned fromCloudAPPluginInitializeand calls the one that matches. A credential provider only has to serialize this buffer withulAuthenticationPackage= the CloudAP package id. No cloud join, no surrogate. - A password logon
MYCP\userin the standard password tile. That tile always sends aKERB_INTERACTIVE_UNLOCK_LOGONto Negotiate. Inlsasrv!SspiExLogonUser,LsapConvertLogonAuthInfolooks the domain up among the identity providers registered with LSA, and on a match rewrites the logon into the EX2 above, which Negotiate passes to CloudAP. This is howAzureAD\userworks. lsasrv uses theNameof theProviderskey, cloudAP the name returned by the plug-in: they must be the same string. - The surrogate, the path of Entra ID itself.
cloudAP!LsaApPreLogonUserSurrogateruns on every interactive logon, but stops withSEC_E_NO_CREDENTIALSunless the machine's primary domain has a SID (joined), and then only goes on for FIDO, Windows Hello or delegation-token credentials. Not a way in for a third-party provider.
A plain LogonUser with a UPN does nothing more: cloudAP receives an empty surrogate logon (no entries) and declines.
On a Windows 11 VM in a workgroup, offline, the plug-in returns a token for a local account, with authentication type CloudAP:
LsaLogonUser(CloudAP, Interactive, MYCP\User): 0x00000000 token: PKU2U-SRV\User, auth type CloudAP Everyone, BUILTIN\Users, INTERACTIVE, CONSOLE LOGON, Authenticated Users, This Organization, Local account, S-1-5-64-36 (Cloud Account Authentication)
- Logon screen: with the sample credential provider's tile, and with
MYCP\Usertyped in the standard password tile. Event 4624: type 2, logon session owned by CloudAP. - Unlock: also a type 2 logon, Winlogon matches the SID with the locked session. No unlock-specific code in the plug-in.
- Order of calls on a first logon:
GetToken,GetKeys, slot 3,UserProfileLoaded,PostLogonProcessing. Once the user is cached,ValidateUserInfocomes first.
DPAPI works inside a CloudAP session. But when the same local account also logs on with its password, the two kinds of session do not share their master keys: MSV protects them with a key derived from the password, CloudAP with the key from GetKeys. Each side finds the other's preferred master key unreadable and creates a new one, and a secret protected in one kind of session cannot be read in the other. Entra ID accounts (S-1-12-1-...) do not have the problem because they only ever log on through CloudAP. A third-party plug-in that maps to local accounts has to choose: its own accounts only, or a key that MSV would derive too.
- Microsoft signature required.
LOAD_LIBRARY_REQUIRE_SIGNED_TARGETrefuses an unsigned DLL and a DLL signed by your own CA (error 577), with LSA protection on or off. The interface is there and works, but on a normal machine only Microsoft's plug-ins can use it. For a product, the supported way remains an authentication package of your own plus a credential provider. - Lab parts in the sample. One fixed secret, a DPAPI key derived from the user SID: test VM only.
- Not implemented in the sample:
GetUnlockKey(unlock with cached keys),RefreshToken, the SID/name lookups,GenericCallPkg, and a real credential.