The credential provider, from zero

For someone who has not written COM. It explains what this code assumes so the rest of the

repository reads as engineering rather than incantation.

1. One sentence

A credential provider is a COM object Windows loads into LogonUI.exe to draw the tiles

on the sign-in screen and to hand back a serialized credential when the user submits.

2. What it is not

This is the most important section, and the old version of this repository got part of it

wrong.

It is not an authentication mechanism. Microsoft says so directly. A credential

provider collects and serializes credentials; it does not perform authentication and it

does not decide whether a credential is valid. LSA does that, afterwards. So:

as anything but opaque bytes, and never compares it to anything.

provider at all.

It is not a complete view of who signed in. It sees what Windows routes through it, on

this machine, in interactive scenarios it registered for. That is why

supports_complete_session_audit is fixed false in the contract.

Putting the filter and the provider in one DLL does not guarantee recovery. The

previous version of this repository claimed it did: if the DLL fails to load, neither

object is created, so no filtering happens, so every tile is visible. That holds for a

load failure and only for a load failure. A DLL that loads, filters successfully, and

then faults during enumeration or serialization has already hidden the alternatives. The

claim has been removed, and installation.md section 5.5 replaces it

with a rehearsed independent route.

3. COM, the minimum

An interface is a table of function pointers. ICredentialProvider is a struct of

pointers; EtProvider is a C++ class whose vtable happens to have the right shape.

HRESULT is how everything reports. S_OK is success, S_FALSE is a meaningful

non-error (and GetUserSid uses it deliberately), E_NOTIMPL means "I do not handle

this", and anything negative is failure. Note that SUCCEEDED(S_FALSE) is true, which is

a trap worth knowing about.

IUnknown is the three methods every interface begins with. QueryInterface asks "do

you support this interface?", AddRef and Release count references, and an object

deletes itself at zero. Getting the pairing right, every time, is most of what COM

correctness means. This code uses Microsoft::WRL::ComPtr for anything held across a

function boundary, so an early return cannot skip a Release.

A CLSID names a class. Ours is {E7B4C1A2-…}. Windows looks it up under

HKLM\SOFTWARE\Classes\CLSID\{…}\InprocServer32 to find the DLL, then calls the DLL's

DllGetClassObject to get a factory, then asks the factory for an instance.

Apartment threading. Our classes register ThreadingModel = Apartment, which means

Windows calls us on one thread and serializes calls. Two consequences run through the

whole design: GetSerialization must never block, and an interface pointer Windows hands

us on that thread cannot simply be used from a worker — it has to be marshalled.

4. The interfaces this build implements

InterfaceRole
ICredentialProviderThe factory for the sign-in screen: what scenario, how many tiles, tile N. Owns the field layout.
ICredentialProviderSetUserArrayReceives the list of users Windows wants tiles for. This is what makes it a V2 provider.
ICredentialProviderCredential2One tile. Owns the field values. GetSerialization is the product.
ICredentialProviderFilterWhich other providers appear, and how an incoming remote credential is routed.

Field descriptions belong to the provider; field values belong to the credential. That

split trips everyone up once.

5. Why this is a custom provider and not a wrapper

The previous version created Microsoft's password provider, forwarded almost every call to

it, appended two fields, and gated the result.

Microsoft discourages wrapping, and the reason is practical: the wrapper depends on the

system provider's field layout, its field count, its ordering, and its behaviour. Any of

those can change in a Windows update. When it does, the wrapper breaks silently, on

the logon screen, on Microsoft's schedule rather than yours.

So this build collects the username and password itself and packs the

KERB_INTERACTIVE_UNLOCK_LOGON blob itself. That is more code — serialization.cpp — but

it is code whose behaviour is ours, and every offset it reads out of an incoming blob is

validated before use.

6. GetSerialization, the product

Windows calls it when the user submits. What it returns decides whether the sign-in

proceeds.

ReturnMeans
CPGSR_RETURN_CREDENTIAL_FINISHEDHere is the credential; go authenticate it
CPGSR_NO_CREDENTIAL_NOT_FINISHEDNot yet — keep the tile open, show this message
CPGSR_NO_CREDENTIAL_FINISHEDGive up on this attempt

It runs on LogonUI's UI thread. It must not block and must not wait for user input. A

blocking network call here freezes the sign-in screen until the network answers.

The flow across several calls:

  1. First submit. Collect the fields, start a worker, return NOT_FINISHED with a

status line. The worker — not this thread — resolves the account to a SID and classifies

the path: LookupAccountName goes to a domain controller, which on a slow or

disconnected network is seconds, and seconds on this thread is a frozen logon screen.

  1. Worker finishes. On require_sms the code box appears and a challenge is

requested. On an allow, the tile asks to be auto-submitted.

  1. Second submit. The typed code goes to verification, again on a worker, again

NOT_FINISHED.

  1. Worker finishes. On allow_mfa the tile asks to be auto-submitted.
  2. Final call. The attempt is authorized. AuthorizeRelease re-checks every deadline

that applies, performs the durable debit if one is owed, BeginSubmission claims the

single release, the credential is packed, and RETURN_CREDENTIAL_FINISHED comes back —

exactly once.

What gets packed, and why it is not the live fields

The identity that was authorized is captured when the attempt is created — the account,

the SID, the Windows session, the authentication package, the credential generation — and

that is what gets serialized. Re-reading the live fields at release time is how an

approval obtained for one account hands Windows a different one, and it is what the first

revision of this code did: completing MFA for CORP\alice and then receiving an incoming

RDP credential for CORP\bob released Bob's credential under Alice's approval.

So anything that could change what would be serialized moves the credential generation on,

and an authorization from an earlier generation is not an authorization: typing in the

username or password box, deselecting the tile, and an incoming credential arriving. The

session the decision was bound to is re-checked too — a decision for session 1 may not

complete a sign-in in session 3.

A path this provider does not intercept still needs a credential

ignore, log and the designated local recovery account are all dispositions where

Windows was always going to do the authenticating. They still have to produce a blob, and

the engine mints no Attempt for a local subject — so these take an explicit, single-use

local authorization carrying the precise Windows identity that was judged. Without it

the recovery account could not sign in through this provider at all: the only branch that

packed anything required an authorized Attempt, so every submission simply re-ran

validation.

7. Auto-submission, precisely

There is a widespread belief that automatic submission skips GetSerialization. **Do not

rely on it either way.** GetCredentialCount's pbAutoLogonWithDefault asks Windows to

submit the default tile, and Windows then calls GetSerialization on it.

So auto-submission here saves the user pressing Enter a second time after a code they have

already entered was verified. It is not a way to skip the gate, and the gate does not

depend on it being one. windows-acceptance.md section 5 records

the actual call sequence rather than assuming it.

The request is honoured only during GetCredentialCount — a re-enumeration, which is a

safe boundary. Never in the middle of a live authentication callback.

8. Threading and marshalling

ICredentialProviderCredentialEvents and ICredentialProviderEvents are apartment-bound.

AddRef'ing one and calling it from a worker thread is a COM rule violation that happens to

work until it does not.

This build registers them in the Global Interface Table when Windows supplies them,

and the worker retrieves a proxy valid in its own apartment. That is the documented way to

move an interface pointer between apartments, and it is why worker.h exists.

Detaching is the other half. The credential and its worker share a Session by

shared_ptr. Deselection, an UnAdvise, or a credential-generation bump sets

abandoned, and every callback checks it before and after obtaining the proxy, holding

the session's publish lock across the check and the update — checking without holding

anything leaves a window in which an abandoned worker's field state lands on a live tile.

A late answer for a superseded attempt is dropped, not applied to whatever the tile holds

now.

Three rules fall out of this, and all three were defects in the first revision:

lookup, not an audit flush.

dropped, not reset in place. Attempt is a plain state machine with no locking of its

own, and the worker may be inside an engine call holding it; calling

OnCredentialChanged on it from a keystroke was a data race on the object deciding an

authentication.

it, released, and only then is anything pushed to Windows.

Engine is not thread-safe either, and two detached workers can be inside it at once — an

abandoned attempt's worker still unwinding a network call, and its replacement. So the

host owns a mutex that serialises engine use; workers take it for the duration of their

calls, and GetSerialization takes it without waiting, because blocking the UI thread

behind a network call is the thing the worker exists to avoid.

The module is kept loaded for the worker's whole life by DllAddRef / DllRelease, so a

DLL unload cannot pull the code out from under a running thread.

Cancellation is scoped, not latched

Deselecting a tile abandons the requests that are running. It does not set a flag: one

WinHttpTransport is shared by every tile of a provider, and a sticky cancelled flag meant

that switching away from a tile and back stopped validation, SMS, verification and audit

for the rest of that provider's life. Cancel() moves an epoch forward; a request is

cancelled only if the epoch moves while it is running.

9. The remote path

For RDP with NLA, the client's credentials arrive at the target. The filter's

UpdateRemoteCredential is offered the incoming serialization and may rewrite it —

including the provider CLSID, which is how it reaches this provider rather than going

straight to the password provider. ICredentialProvider::SetSerialization then delivers

it.

Returning E_NOTIMPL from UpdateRemoteCredential and then claiming RDP support would be

a lie: the credential would never reach us. The previous version did exactly that, and the

claim has been corrected.

UpdateRemoteCredential answers the same questions Filter does, because it is the

same authority. A filter that has not read a verified deployment configuration, or that

cannot tell which channel it is on, or that cannot parse the blob, abstains. So do the

ignore and log actions — the administrator asked for that path to be left to Windows,

and log claims no control at all. Only intercept and block claim a remote

credential: block, because abstaining would hand it to the password provider and

complete the sign-in the configuration forbids.

The credential is also routed to the tile whose account it names, not to the first

tile. Windows builds a tile per cached user; seeding the wrong one means the account that

gets validated and the account whose blob is held are two different people.

Two things this does not do:

stack before any of this ran.

released only if and when the attempt is authorized. Auto-logon on selection is refused,

so the user is prompted at the target — which breaks the seamless saved-credential

experience, and is exactly what MFA requires.

9a. Logon versus unlock

CPUS_LOGON does not mean "a new sign-in". Microsoft documents that from Windows 10

onward it covers workstation unlock as well, so the usage scenario alone cannot make the

contract's logon/unlock distinction — and that distinction decides which Core policy and

which scenario-scoped grant apply, and what the audit says.

The operation therefore comes from the session: a session that already has an

interactive user is being unlocked, whatever the scenario says.

SessionClassification::operation_from_session_facts records whether that question could

actually be answered, so a report never claims a distinction it did not make.

10. GetUserSid and the Other-user tile

For a tile associated with a user, return that user's SID. For a tile that is not — the

domain "Other user" tile — return S_FALSE with a null SID. That is the documented way to

say "this tile is not about a particular person", and it is what associates it with an

empty user slot rather than with somebody.

11. Reading order

FileWhat it shows
cp/dllmain.cppHow Windows finds and loads us; the class factory. Short, and nothing in it does real work.
cp/provider.cppTiles, the field layout, the user array, and where auto-submission is honoured
cp/credential.cppOne tile. GetSerialization at the bottom is the product. Read it last and slowly.
cp/serialization.cppPacking and unpacking KERB blobs, and every offset check on an incoming one
cp/worker.cppGetting off the UI thread, and back, safely
cp/filter.cppProvider visibility, and the remote routing that makes RDP possible
windows/tests/suite_windows_callbacks.cppThe callback behaviour that has automated coverage, run in an ordinary process: nothing registered, no LSA, no request